6 Commits
Author SHA1 Message Date
hansdezwartandClaude Opus 5 6653541bb9 Release 1.7
versionCode 8. public_html/ changed, so the service worker cache goes to
bgclock-v23.

Confirmed on device from a debug APK: the tally reads as a tally in the bar at
arm's length, and from the far side of the board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:47:49 +02:00
hansdezwartandClaude Opus 5 7095e2b86e Draw the score button as a tally
The ruled card it had been reads as a document, a table or a form at the size
it actually renders — six strokes inside a 16x18 box at clamp(23px,6.4vw,32px)
— and nothing in it said score. A tally does, with no box around it to explain
itself, and it is the one score mark that survives being seen from the far side
of the board: upside down it is still a tally.

Three uprights and the slash, not four and the slash. At 23px a fourth upright
closes the gaps and the whole glyph turns into a picket fence; the slash is kept
inside the uprights' span for the same reason, since running it past them made
the icon read wider than the gear and speaker beside it.

Drawn against six alternatives in two passes, judged rendered in the real bar
at both ends of the size clamp rather than described. The passes that lost are
worth a line each, because the reasons were not visible in the geometry: two
stacks of checkers flips to say the other player is ahead when the far player
looks at it, which is worse than merely asymmetric; a bar chart is the stock
analytics glyph and says statistics rather than this match; and a tally inside a
card is smothered by the card, whatever the count.

Screenshots 1 and 5 are redone. Not 2 and 3, where the open sheet covers the bar
and the icon is not in shot at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:47:37 +02:00
hansdezwartandClaude Opus 5 5ad69c6ff2 Release 1.6
versionCode 7. public_html/ changed, so the service worker cache goes to
bgclock-v22.

Confirmed on device, from a debug APK over the top of 1.5: the settings kept
from 1.5 came back on the You row with the switch off; the gear stayed
reachable with the two bases set apart, which is the failure that would have
had no way out; each side ran on its own delay, including a player set to zero
starting on their reserve immediately; and a turn left running well past the
delay, force-quit and reopened, came back paused with the split still in force.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:55:33 +02:00
hansdezwartandClaude Opus 5 a34d6058a1 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>
2026-08-16 11:55:20 +02:00
hansdezwartandClaude Opus 5 e207478e79 Release 1.5
versionCode 6. public_html/ changed, so the service worker cache goes to
bgclock-v21.

Not confirmed on device: the force-quit-and-reopen check from RELEASING.md
step 4 has not been run for this build. Nothing in it touches storage, timing
or the state machine — the change is a label, an inherited colour and a lit
button, all written from render() — but the check is still owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:35 +02:00
hansdezwartandClaude Opus 5 3f6fe5abf1 Say Paused on both halves while the clock is stopped
A stopped clock looked exactly like a running one. The panel keeps its accent
while paused, and isLive() counts a paused side as live, so the delay bar just
freezes mid-drain — nothing about either half says the clocks aren't moving.
The only tell was the play glyph in the bar, which is small, grey, and upside
down for one of the two players. It matters most in the state the app is
designed to land in: a match restored from localStorage comes back paused on
purpose, and nothing said so.

So both halves say it in words, under the delay bar, sized to sit between the
bar and the clock: wider than the bar at every screen size, narrower than the
clock at m:ss. The band is tighter than it sounds, since "3:00" is only about
2.2x the bar, so part of the width comes from the tracking rather than the
glyph size. Measured from 320 to 1024px wide it lands at 1.3-1.4x the delay
row and 74-82% of the clock. It is wider than the clock in the last ten
seconds of a reserve, where the readout drops to three narrow characters —
unavoidable inside these bounds, and the two are never confusable at four
times the height.

The word hangs off the delay row rather than joining .stack, so the big clock
does not move by a pixel when the clock stops. It cannot live inside .delay
either: that is opacity:0 whenever no delay is running, and a child can never
be more opaque than its parent, which would lose the word on the waiting half,
on a spent delay, and at a delay of 0. It takes no colour of its own, so it
wears whatever ink the clock above it is wearing, in every theme.

The play button lights up while paused, the same --icon-hot a held Reset uses.
Both toggles live in render() rather than in paintPlay(), for the reason the
comment there already gives: state written from the render loop cannot go
stale.

Screenshot 2 is redone — the score sheet pauses, so the word shows behind the
scrim. The other four are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:25 +02:00
11 changed files with 310 additions and 86 deletions
+29 -9
View File
@@ -70,7 +70,10 @@ everything works except installation and the service worker.
stray thumb can't wipe a live match. It's absent on a fresh match, where there stray thumb can't wipe a live match. It's absent on a fresh match, where there
is nothing yet to wipe. is nothing yet to wipe.
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay - **Pause** freezes mid-turn and resumes exactly where it stopped, delay
included. Opening either sheet pauses automatically. The settings sheet leaves included. Both halves read **Paused** underneath the delay bar while it is, in
the same ink as the clock above it, and the play button lights up — so a
stopped clock can't be mistaken for a running one from either side of the
board. Opening either sheet pauses automatically. The settings sheet leaves
it paused — everything in there resets the clocks anyway — while the score it paused — everything in there resets the clocks anyway — while the score
sheet picks the turn back up when you close it, so glancing at the score sheet picks the turn back up when you close it, so glancing at the score
doesn't cost you a tap on play. It's absent whenever there's no clock running doesn't cost you a tap on play. It's absent whenever there's no clock running
@@ -93,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, 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 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: Each half reads the state from that player's own side of the board:
@@ -133,17 +137,33 @@ score down to fit.
## Settings ## Settings
Under **Delay** are three presets — **2m / 12s**, **3m / 12s** and **3m / 15s**. 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 Tapping one puts both the time and the delay there and lights it up. Change
hand and the highlight goes out; land back on a preset's numbers and it comes either by hand and the highlight goes out; land back on a preset's numbers and it
back on. 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 | | Setting | Range | Default |
| --- | --- | --- | | --- | --- | --- |
| Match | 1 – 29 points, odd | 1 | | Match | 1 – 29 points, odd | 1 |
| Time | 0:15 – 99:00 per point | 3:00 | | Time | 0:15 – 99:00 per point, per player | 3:00 |
| Delay | 0 – 60 s | 12 s | | Delay | 0 – 60 s, per player | 12 s |
| Different time per player | on / off | off |
| Colour | Sage, Brass, Slate, Teal, Plum | Sage | | 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. 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 Changing match, time or delay resets the clocks — the score is left alone, so you
@@ -154,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 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). 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 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 the app was closed, so it comes back paused rather than charging anyone for the
gap: a crash shouldn't decide a game. gap: a crash shouldn't decide a game.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "nl.hansdezwart.bgclock" applicationId = "nl.hansdezwart.bgclock"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 8
versionName = "1.4" versionName = "1.7"
} }
compileOptions { compileOptions {
@@ -0,0 +1 @@
Added a 'Paused' indicator when the clock is paused.
@@ -0,0 +1 @@
Rationalised the settings page and added the option to set a different time and delay per player.
@@ -0,0 +1 @@
Change the score icon to a tally.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

+275 -74
View File
@@ -182,6 +182,49 @@
text-align:right; text-align:right;
} }
/* ---------- paused ----------
A stopped clock used to look exactly like a running one: the panel keeps its
accent, and the delay bar simply freezes mid-drain. The only tell was the
play glyph in the bar — small, grey, and upside down for one of the two
players. So both halves say it in words.
Hung off the delay row rather than added to .stack, so the big clock does
not move a pixel when the clock stops — the same promise .btn--vacant makes
in the bar, and the reason .delay fades rather than hides. It also cannot
live *inside* .delay: that is opacity:0 whenever no delay is running, and a
child can never be more opaque than its parent, which would lose the word on
the waiting half, on a spent delay, and at a delay of 0. */
.foot{position:relative}
.paused{
position:absolute;
top:100%;
left:50%;
/* the bar and its number sit dead centre of the panel (see .dnum), so
centring on that row centres on the panel, whatever the count reads */
transform:translateX(-50%);
/* a clear step below the bar rather than .stack's gap: near enough to belong
to the delay row, far enough not to crowd it */
margin-top:clamp(21px,5.8vw,41px);
white-space:nowrap;
line-height:1;
/* Wider than the delay bar, narrower than the clock — the band is tighter
than it sounds, since "3:00" is only about 2.2x the bar. The crossovers
are .track's own (20vw between 360 and 700px wide), so the word holds its
proportion to the bar at every size instead of drifting at the ends.
Part of the width comes from the tracking rather than the glyph size:
spaced letters carry "held" without shouting over the clock. */
font-size:clamp(34px,9.4vw,66px);
font-weight:700;
letter-spacing:.08em;
/* no colour of its own: it inherits the panel's, which is what the clock and
the delay count are already wearing. Each theme picks its own --accent-ink
(white on most of them), so the word follows the palette without a rule
per theme, and the paused half never has two different inks on it. */
opacity:0;
transition:opacity .16s ease;
}
.paused.on{opacity:1}
/* ---------- control bar ---------- */ /* ---------- control bar ---------- */
.bar{ .bar{
background:var(--bar); background:var(--bar);
@@ -199,6 +242,9 @@
cursor:pointer; cursor:pointer;
} }
.btn:active{color:var(--icon-hot)} .btn:active{color:var(--icon-hot)}
/* the way back from a paused clock, lit like a held Reset: the bar is the only
thing either player can act on while the clocks are stopped */
#playpause.lit{color:var(--icon-hot)}
.btn[aria-pressed="false"]{color:#5E5954} .btn[aria-pressed="false"]{color:#5E5954}
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block} .btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
.btn svg[hidden]{display:none} .btn svg[hidden]{display:none}
@@ -323,8 +369,33 @@
border-top:1px solid var(--sheet-line); border-top:1px solid var(--sheet-line);
} }
.row:first-of-type{border-top:0} .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 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 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 /* 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 */ label on any phone — stack it under one instead of overflowing the sheet */
@@ -432,9 +503,12 @@
<div class="stack"> <div class="stack">
<div class="time" data-time>3:00</div> <div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div> <div class="out" data-out hidden>Out of<br>time</div>
<div class="delay" data-delay> <div class="foot">
<span class="track"><i class="fill" data-fill></i></span> <div class="delay" data-delay>
<span class="dnum" data-dnum>15</span> <span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
<div class="paused" data-paused>Paused</div>
</div> </div>
</div> </div>
</div> </div>
@@ -462,10 +536,13 @@
<span class="gap"></span> <span class="gap"></span>
<button class="btn" id="score" aria-label="Score" hidden> <button class="btn" id="score" aria-label="Score" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"> <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"/> <!-- a tally: three uprights and the fifth-mark slash. Three rather than
<path d="M12 7.5v10"/> four, and the slash kept inside their span, because at 23px a fourth
<path d="M7.6 10.6h2.6"/><path d="M13.8 10.6h2.6"/> upright closes the gaps and the whole thing reads as a picket fence.
<path d="M7.6 14.6h2.6"/><path d="M13.8 14.6h2.6"/> It is the one score mark that needs no box around it to explain
itself, and it stays a tally seen from the far side of the board. -->
<path d="M6.5 6v12"/><path d="M12 6v12"/><path d="M17.5 6v12"/>
<path d="M4 18.5L20 5.5"/>
</svg> </svg>
</button> </button>
<span class="gap"></span> <span class="gap"></span>
@@ -498,9 +575,12 @@
<div class="stack"> <div class="stack">
<div class="time" data-time>3:00</div> <div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div> <div class="out" data-out hidden>Out of<br>time</div>
<div class="delay" data-delay> <div class="foot">
<span class="track"><i class="fill" data-fill></i></span> <div class="delay" data-delay>
<span class="dnum" data-dnum>15</span> <span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
<div class="paused" data-paused>Paused</div>
</div> </div>
</div> </div>
</div> </div>
@@ -524,34 +604,75 @@
</div> </div>
</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 class="row row--wide">
<div> <div>
<h2>Time</h2> <h2>Time</h2>
<p id="p-time">Reserve time per point.</p> <p id="p-time">Reserve time per point.</p>
</div> </div>
<div class="stepper"> <div class="sides">
<button class="step" data-time-adj="-60">–</button> <div class="side">
<span class="val"><span id="v-min">3</span><span class="unit">min</span></span> <span class="who">You</span>
<button class="step" data-time-adj="60">+</button> <div class="stepper">
<button class="step" data-time-adj="-15" style="margin-left:8px">–</button> <button class="step" data-time-adj="-60" data-who="1">–</button>
<span class="val"><span id="v-sec">00</span><span class="unit">sec</span></span> <span class="val"><span data-v="min" data-who="1">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="15">+</button> <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> </div>
<div class="row"> <div class="row row--delay">
<div> <div>
<h2>Delay</h2> <h2>Delay</h2>
<p>Free seconds each turn. Never carries over.</p> <p>Free seconds each turn. Never carries over.</p>
</div> </div>
<div class="stepper"> <div class="sides">
<button class="step" data-delay-adj="-1">–</button> <div class="side">
<span class="val"><span id="v-delay">15</span><span class="unit">sec</span></span> <span class="who">You</span>
<button class="step" data-delay-adj="1">+</button> <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> </div>
<div class="row row--presets"> <div class="row row--presets" id="row-presets">
<div> <div>
<h2>Preset time limits</h2> <h2>Preset time limits</h2>
<p>Quickly pick a setting.</p> <p>Quickly pick a setting.</p>
@@ -571,16 +692,7 @@
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div> <div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
</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> <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>
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score"> <div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
@@ -639,8 +751,7 @@
</div> </div>
<button class="done" id="score-done">Done</button> <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 <p class="hint" id="p-score-hint"></p>
the first tap. The clocks keep their times.</p>
</div> </div>
<script> <script>
@@ -670,24 +781,46 @@
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF", mute:.77 } { 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 = { var cfg = {
base: store.num("bg.base", 180000), // reserve per point base: [split ? clampTimer("base", store.num("bg.base.them", yBase)) : yBase, yBase],
delay: store.num("bg.delay", 12000), 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 points: store.num("bg.points", 1), // match length, odd, 1..29
theme: store.get("bg.theme", "sage"), theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1" sound: store.get("bg.sound", "1") === "1"
}; };
if(!(cfg.points >= 1 && cfg.points <= 29 && cfg.points % 2 === 1)) cfg.points = 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 // a match to N points is worth N times that side's per-point reserve
function matchTime(){ return cfg.base * cfg.points; } function matchTime(i){ return cfg.base[i] * cfg.points; }
/* ============ state ============ */ /* ============ state ============ */
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3; var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
var st = { var st = {
phase: IDLE, phase: IDLE,
active: -1, 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 score: [0, 0], // match points, by side
moves: [0, 0], moves: [0, 0],
turnAt: 0, // performance.now() when the turn began turnAt: 0, // performance.now() when the turn began
@@ -709,7 +842,8 @@
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")), scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
delay: p.querySelector("[data-delay]"), delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"), fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]") dnum: p.querySelector("[data-dnum]"),
paused: p.querySelector("[data-paused]")
}; };
}); });
var btnPlay = document.getElementById("playpause"); var btnPlay = document.getElementById("playpause");
@@ -808,7 +942,7 @@
} }
// how much of this turn has already come off the reserve (0 while the delay runs) // how much of this turn has already come off the reserve (0 while the delay runs)
function consumed(i){ 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 // the number the panel is showing, which is what the score sheet edits
function shownTime(i){ function shownTime(i){
@@ -824,7 +958,7 @@
return st.phase === IDLE && st.active === -1 && return st.phase === IDLE && st.active === -1 &&
st.moves[0] === 0 && st.moves[1] === 0 && st.moves[0] === 0 && st.moves[1] === 0 &&
st.score[0] === 0 && st.score[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 ---- /* ---- write only on change ----
@@ -856,10 +990,10 @@
var main = st.reserve[i], dLeft = 0; var main = st.reserve[i], dLeft = 0;
if(live){ if(live){
if(e < cfg.delay){ if(e < cfg.delay[i]){
dLeft = cfg.delay - e; dLeft = cfg.delay[i] - e;
}else{ }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(); } if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
} }
} }
@@ -878,14 +1012,19 @@
setText(el[i].them, st.score[1 - i]); setText(el[i].them, st.score[1 - i]);
setText(el[i].target, cfg.points); 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 el[i].delay.classList.toggle("on", showDelay); // no-op when already right
if(showDelay){ if(showDelay){
// the bar really does move every frame, but transform is compositor-only // 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)); setText(el[i].dnum, Math.ceil(dLeft / 1000));
} }
// both halves, not just the one whose turn it is: the waiting player's
// clock is stopped too, and they are the one who can't read the bar's
// play glyph the right way up
el[i].paused.classList.toggle("on", st.phase === PAUSE);
setAttr(el[i].root, "data-state", setAttr(el[i].root, "data-state",
(st.phase === FLAG && i === st.active) ? "flagged" (st.phase === FLAG && i === st.active) ? "flagged"
: (i === st.active && st.phase !== IDLE) ? "active" : "idle"); : (i === st.active && st.phase !== IDLE) ? "active" : "idle");
@@ -901,6 +1040,7 @@
// nothing running to pause: before a game's first tap, and after a flag, // 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 // 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); btnPlay.classList.toggle("btn--vacant", st.phase === IDLE || st.phase === FLAG);
btnPlay.classList.toggle("lit", st.phase === PAUSE);
} }
function loop(){ render(); requestAnimationFrame(loop); } function loop(){ render(); requestAnimationFrame(loop); }
@@ -937,7 +1077,7 @@
// the same instant charges the outgoing turn and starts the incoming one, // the same instant charges the outgoing turn and starts the incoming one,
// so nothing falls between the two and goes to neither player // so nothing falls between the two and goes to neither player
var e = now - st.turnAt; 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.moves[side]++;
st.active = 1 - side; st.active = 1 - side;
st.turnAt = now; st.turnAt = now;
@@ -993,7 +1133,7 @@
// clocks back to full for the match length; the score is not this function's business // clocks back to full for the match length; the score is not this function's business
function resetClocks(){ function resetClocks(){
cancelHold(); cancelHold();
st.reserve = [matchTime(), matchTime()]; st.reserve = [matchTime(0), matchTime(1)];
newGame(); newGame();
saveGame(); saveGame();
} }
@@ -1047,7 +1187,9 @@
st.active = g.active; st.active = g.active;
st.phase = g.phase === RUN ? PAUSE : g.phase; st.phase = g.phase === RUN ? PAUSE : g.phase;
st.held = st.phase === PAUSE ? g.held : 0; 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 if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
} }
@@ -1178,43 +1320,103 @@
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2); 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(){ function paintSettings(){
var s = Math.round(cfg.base / 1000); for(var i = 0; i < 2; i++){
document.getElementById("v-min").textContent = Math.floor(s / 60); var s = Math.round(cfg.base[i] / 1000);
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2); vals[i].min.textContent = Math.floor(s / 60);
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000); 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("v-points").textContent = cfg.points;
document.getElementById("p-time").textContent = cfg.points === 1 /* At a single point the match total is the per-point time, so there is no
? "Reserve time per point." sum to introduce and the colon would dangle. Split, the two sums are put
: "Reserve time per point. " + cfg.points + " × " + clock(cfg.base) + on their own line — together they are too long to sit after the label
" = " + clock(matchTime()) + " each."; 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(); paintMatchChrome();
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false"); btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
showIcon(btnSound, "on", cfg.sound); showIcon(btnSound, "on", cfg.sound);
showIcon(btnSound, "off", !cfg.sound); showIcon(btnSound, "off", !cfg.sound);
paintPresets(); 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 /* 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(){ function paintPresets(){
[].forEach.call(document.querySelectorAll(".preset"), function(b){ [].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"); 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){ [].forEach.call(document.querySelectorAll(".preset"), function(b){
b.addEventListener("click", function(){ b.addEventListener("click", function(){
cfg.base = parseInt(b.dataset.base, 10); // only reachable while shared, so both sides take the preset
cfg.delay = parseInt(b.dataset.presetDelay, 10); var base = parseInt(b.dataset.base, 10), d = parseInt(b.dataset.presetDelay, 10);
store.set("bg.base", String(cfg.base)); cfg.base = [base, base];
store.set("bg.delay", String(cfg.delay)); cfg.delay = [d, d];
saveTimers();
paintSettings(); resetClocks(); 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){ [].forEach.call(document.querySelectorAll("[data-points-adj]"), function(b){
b.addEventListener("click", function(){ b.addEventListener("click", function(){
// stepping by 2 from 1 keeps it odd without any parity juggling // stepping by 2 from 1 keeps it odd without any parity juggling
@@ -1228,18 +1430,14 @@
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){ [].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
b.addEventListener("click", function(){ b.addEventListener("click", function(){
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000; var who = parseInt(b.dataset.who, 10);
cfg.base = Math.min(99 * 60000, Math.max(15000, next)); setTimer("base", who, cfg.base[who] + parseInt(b.dataset.timeAdj, 10) * 1000);
store.set("bg.base", String(cfg.base));
paintSettings(); resetClocks();
}); });
}); });
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){ [].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
b.addEventListener("click", function(){ b.addEventListener("click", function(){
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000; var who = parseInt(b.dataset.who, 10);
cfg.delay = Math.min(60000, Math.max(0, next)); setTimer("delay", who, cfg.delay[who] + parseInt(b.dataset.delayAdj, 10) * 1000);
store.set("bg.delay", String(cfg.delay));
paintSettings(); resetClocks();
}); });
}); });
@@ -1249,7 +1447,6 @@
paintSettings(); paintSettings();
if(on){ audio(); sndTick(); } if(on){ audio(); sndTick(); }
} }
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); }); btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
/* ============ sheets ============ */ /* ============ sheets ============ */
@@ -1300,6 +1497,10 @@
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60); document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2); 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(){ function openScore(){
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */ /* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v20"; const CACHE = "bgclock-v23";
const FILES = [ const FILES = [
"./", "./",
"./index.html", "./index.html",