The readout hung and then skipped numbers in a focused desktop window.
render() runs every animation frame and was writing unconditionally —
measured at 11 DOM mutations per frame — whether or not anything had
changed. Two of those are the .time text node, a glyph run up to 190px, so
every frame forced a style recalc and a full relayout to redraw digits that
turn over once a second. Routing the per-frame writes through change guards
takes a steady frame from 11 writes to 0.
The timing itself was never at fault and is untouched here. elapsed() is one
subtraction from a single monotonic performance.now() stamp and the reserve
is debited once, in tap(), so nothing accumulates per frame and a dropped
frame cannot cost or gift anyone a millisecond. Measured against wall clock
over a running turn: 0.0000ms drift across 5s, 12s and 17s segments.
Also fixes a real skip in fmt(). It switched from m:ss to tenths at
ms < 10000 with both branches rounding up, so the coarse branch read a
second high: counting down gave 0:11 for a full second, 0:10 for a single
millisecond, then 10.0. Handing 10000ms to the tenths branch gives a
monotone 0:11 -> 10.0 -> 9.9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing the score sheet left the clock paused, so glancing at the score or
nudging a clock cost a tap on play. It now picks the turn back up on the way
out. openScore() records whether it was the thing that paused the clock, so a
pause the players set themselves survives. After a score change there is
nothing to resume — newGame() has already moved to IDLE — and resumeNow()
only acts on PAUSE, so that case needs no special handling. The scrim behaves
the same, since dismissing discards the staged edits and leaves the turn live.
togglePause() had the resume arithmetic inline; it splits out as resumeNow()
to match pauseNow(), leaving the play button and the sheet on one path.
The score line becomes three labelled lines:
You: 2 out of 5
Them: 1
Moves: 7
.head is a two-column grid with a max-content label column, so the values
line up whatever the system font renders — no hand-tuned em width to drift
between iOS and Android. Each line is a display:contents wrapper, which keeps
one element per line to hide when a one-point match drops the score. The gap
above Moves rides on that row's two cells, since a display:contents row can't
carry a margin, and a :not([hidden]) guard drops it when there is no score
line above to separate from.
Each half reads from its own side of the board, so both players see their own
score on the You line. st.score is already indexed by side, so the mirroring
costs nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single game has no match to keep score of, so showing "Score: 0 - 0, out
of 1" and a sheet to edit it was just clutter. Both are hidden while the
match is one point and come back at three or more.
`hidden` alone doesn't do it for the button: .btn sets display:grid, and an
author rule beats the UA's [hidden] rule whatever the specificity — the same
trap behind the original both-icons-showing bug — so .btn[hidden] has to say
it explicitly. The markup carries hidden by default too, so the common
one-point case can't flash the score before the script runs.
The bar returns to its original spacing when the icon goes: the two .75
gaps either side of it merge back into the 1.5 it had before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Match length is a new first row in the settings, 1 to 29 points, stepping by
2 so it can only ever be odd. The Time setting now means time *per point* and
each clock starts at time × points, which the settings sheet spells out.
The middle bar icon opens a score sheet. Edits are staged there and only
reach the state on Done, which then does one of three things:
nothing changed turn, delay position and moves all untouched
score changed displayed times become the reserves, then back to the
first tap with moves zeroed — the clocks keep their value
clock changed the spent delay is added back on so the panel shows
exactly what was typed, and the turn carries on
Tapping outside the sheet discards the staged edits, so a stray tap can't
restart a game.
reset() was called both by the Reset button and by every settings change, so
clearing the score in it would have wiped a live match whenever the delay was
nudged. It splits into resetClocks() and resetAll(); only the button clears
the score. Shortening a match clamps a score that no longer fits.
The whole game now persists, not just the settings: score, both reserves,
moves, whose turn and how much delay was left, saved on the discrete
transitions and on pagehide/visibilitychange. Time passing 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. A malformed saved game is ignored rather than
half-applied.
Each panel shows its own score first, stacked above its move count at the
top left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two real bugs, both worst on iOS:
The frame was taller than the screen in Safari. .app declared
`height:100dvh;height:100vh`, so the fallback won and 100vh on iOS is the
large viewport — the control bar and part of the bottom panel sat behind
the toolbar. The declarations are now in the right order.
The wake lock never came back. iOS drops it whenever the app backgrounds,
but `lock` was never cleared, so the `!lock` guard blocked every later
re-request and the screen started sleeping mid-game. It now listens for
`release` and clears the handle; against a stubbed API the old code stays
at one request after a release-and-return, the new code makes a second.
Smaller iOS fixes: claim a playback audio session so the ringer switch
doesn't silence the clock (this pauses other audio — the README says how
to drop it), resume the audio context when returning from the background,
suppress the long-press callout, keep sheet scrolling from rubber-banding
the app, back 88dvh with 88vh, and name the home-screen icon.
Untested on real hardware — there's no WebKit engine here, so this is a
code audit plus a Chromium regression run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The play/pause and sound icons never toggled: SVGElement doesn't inherit
from HTMLElement, so it has no .hidden property and `svg.hidden = true`
only set a JS expando. Both icons then rendered at once because
`.btn svg{display:block}` out-specifies the UA's `[hidden]{display:none}`.
Fixed on both sides — a showIcon() helper sets the attribute, and the CSS
now honours [hidden]. Starting the clock by tapping a panel didn't repaint
the button either, which the working toggle made visible.
Also:
- conventional gear for the settings icon, replacing the clock glyph
- opening settings pauses the clock, and leaves it paused on close
- reset needs two taps within 2s, with a ring that drains over the window
- 2m/12s and 3m/15s presets atop the settings sheet; the highlight is
derived from cfg, so it clears and returns as the steppers move
- ask for persistent storage so settings aren't evicted, and fall back to
the defaults rather than NaN if a stored value is ever corrupt
sw.js cache bumped to v2 so installed devices pick up the new index.html.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>