20 Commits
Author SHA1 Message Date
hansdezwartandClaude Opus 5 7f1b246679 Release 1.4
versionCode 5. public_html/ changed, so the service worker cache goes to
bgclock-v20.

Confirmed on device: a turn left running well past the delay, force-quit and
reopened, now comes back with the time it had actually spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:07:26 +02:00
hansdezwartandClaude Opus 5 bf3f565dd0 Release 1.3
versionCode 4. public_html/ changed, so the service worker cache goes to
bgclock-v19 — without it an installed web user would keep serving the old page
from cache indefinitely.

The five store screenshots are untouched: the ring only exists while a finger
is on the button, and none of them shows that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:51:14 +02:00
hansdezwartandClaude Opus 5 404fdb5538 Raise the contrast of the labels, digits and delay readout
F-Droid's reviewer measured the score and move lines at 2.1:1 against the
4.5:1 normal text needs. Raising them past 14pt bold puts them under the 3:1
large-text bar instead, which is the only way ink still readable as "muted"
can be conformant on a mid-tone accent: reaching 4.5:1 at the old size would
have taken near-black labels on plum and slate, out-shouting the clock digits
above them. Hence the 19px floor, with a comment saying so — lowering it
breaks the contrast claim silently.

Measuring turned up a second failure nobody had flagged: the active player's
clock digits are white on the accent, and brass sat at 2.46:1 against the same
3:1 bar. Sage and brass are darkened just far enough to clear it, scaled in
linear light so only lightness moves. Slate, teal and plum already passed and
are untouched. Darkening further was tempting and wrong — it would have taken
the headroom the muted labels need.

--accent-mute was doing double duty as the delay bar's background, where it
only ever agreed with the bar by accident. Splitting off --track keeps the bar
pixel-identical: 234px wide, fill and unfilled segments unchanged.

The delay number is right-aligned in a box exactly two digits wide. The bar and
the number together now sit within a pixel of the panel's centre rather than
10px left of it, and the digit that changes every second stays put instead of
sliding when the count drops out of double figures; the gap absorbs it.

The settings sheet's Done button and preset chips are still white on accent at
15px, which needs 4.5:1 and gets 3.2:1. Known, and left alone: fixing them
means 19px floors and visibly taller buttons.

Screenshots come from tools/screenshots.py now instead of being made by hand.
It seeds localStorage and lets the app render its own saved state, so scenes
are reproducible. The traps are in its docstring and RELEASING.md — including
one that cost an afternoon today: snap-confined Chromium cannot write into any
hidden directory, and says only "Permission denied".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:41:40 +02:00
hansdezwartandClaude Opus 5 9815b04e35 Hide play/pause once someone has run out
At a flag the game is over and togglePause is inert anyway — pauseNow
needs RUN, resumeNow needs PAUSE — so the button was there without being
able to do anything. It joins the other two visibility rules in render():
vacant at IDLE or FLAG. Reset stays, being the way out of a finished
game, and the bar comes down to Reset, score and sound.

Checked the whole state machine: shown while running and while paused,
vacant before a game's first tap and at a flag, and pausing and resuming
still work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:20:30 +02:00
hansdezwartandClaude Opus 5 998009629b Say "Out of time", and stop the sheets shadowing the clock
Two things Hans spotted playing on the phone.

A blackish gradient sat across the bottom of the screen. Both sheets are
fixed to bottom:0 and pushed off with translateY(101%), but their
box-shadow is thrown 18px *upward* with a 50px blur — so a closed sheet,
sitting 1% below the viewport, blurred darkness back onto the clock and
left it there, twice over, once per sheet. The shadow now only exists on
.sheet.on. Down one column the bottom used to fade 154 to 111 over the
last 70px; it is flat 154 to the edge. An open sheet still casts exactly
what it did.

A flagged clock now reads "Out of time" over two lines in white instead
of showing 0:00, on the red the panel already turned. Sizing it off the
viewport would not work: the score lines above eat into what is left, so
a 5-point match has less room than a 1-point one on the same phone. The
stack is a size container and the words take the smaller of 23vw and
42cqh — two lines at line-height 1.04 need 2.08x the font size, so 42% of
the container leaves a tenth spare. Measured in real viewports it lands
between 52px on a 320x568 at five points and 95px on a 412x915, fitting
every combination; with two lines it is the width that binds first, "Out
of" being the long line. The other half keeps showing its own time.

Checked that normal play still shows clocks and hides the words on both
panels, and that Reset puts the flagged panel back to its clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:05:24 +02:00
hansdezwartandClaude Opus 5 6069ef270e Drop the haptics
The clock buzzed on every turn change, on a flag and on the confirming
second tap of Reset. Sound and the colour change carry all of it, so the
vibration goes: buzz() and its four call sites, and the iOS README bullet
that explained why the buzz was silent there — a platform difference that
no longer exists now there are no haptics anywhere.

Verified by stubbing navigator.vibrate and driving a turn change, a
timeout and a confirmed reset: zero calls, while the turn still counted
and the flag still fell.

It also means the Android package can declare no permissions at all.
VIBRATE was the only one it would have needed — there is no INTERNET
either, since the whole clock ships inside the APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:10:23 +02:00
hansdezwartandClaude Opus 5 485aa9728d Fit the settings sheet on the screen again
Adding the presets heading pushed the sheet past the bottom of the
screen. It was closer to the edge than it looked: at 390x844 the old
content cleared the bottom by 10px, so 49px of new row tipped it over.

Height alone couldn't fix it. Measured in real viewports the content was
733px at 390 wide and 750px at 375 — and a 375x667 phone is 667px tall,
so on that screen no max-height reaches the content at all. It had to get
shorter. Raising the cap 88dvh -> 92dvh does the rest, and stops there:
the scrim is what you tap to dismiss (scrim's click handler closes
whichever sheet is open), and the sheet's top padding is a flat 8px with
no safe-area inset, so a full-height sheet would tuck the grab handle
under the notch.

Trimmed the row padding 16 -> 14, the grab handle, the Done margin and
padding, the hint margin and the presets gap: content down to 685px at
390 wide, which clears the top of the screen by 159px. The two smallest
phone sizes still scroll, by 88px at 375x667 — the rest of the fat there
is description text wrapping to three lines in the narrow left column,
which needs the copy or the row layout changed, not more spacing.

The score sheet shares .row and got the same trim: 466px, fits at every
size tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:25:27 +02:00
hansdezwartandClaude Opus 5 2c31c0f02b Move the presets under Delay and give them a heading
They sat between Match and Time as three bare buttons, above the two
settings they set. Now they follow Delay, after the numbers they are a
shortcut for, with a "Preset time limits" heading and "Quickly pick a
setting." — the same heading-and-description shape every other row in the
sheet already has.

The row stacks at every width rather than only under the .row--wide
breakpoint: three buttons wide enough to read "3m / 15s" can't share a
line with a label on any phone.

The buttons themselves are untouched, and are still found by class rather
than position, so the highlight logic came along unchanged — checked that
opening the sheet lights the preset matching the current numbers,
stepping the delay off it clears every highlight, and stepping back
relights it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:11:04 +02:00
hansdezwartandClaude Opus 5 261cebd10b Charge the hold, and drop the two buttons that have nothing to do
Three things, plus the README moves up to the repo root where a README
belongs — it was being served as part of the site.

The clock now runs until the finger lifts. The previous commit registered
a tap on the lift but backdated the turn to the press, which showed up as
the display counting down during a hold and then jumping back. Worse than
cosmetic: the hold was refunded, so a thumb resting on your own half
bought unlimited thinking time for nothing, and holding past zero
couldn't flag you either, because render()'s flag check watches the live
display while tap() charged from an earlier instant. One
performance.now() at the lift now serves both the outgoing charge and the
incoming turnAt, so nothing falls between them and goes to neither
player. With the delay at 0 and a turn 1000ms old, an instant lift costs
1001ms and a 600ms hold costs 1602ms — the inverse of what the last
commit was verified against. Swipe rejection is untouched; it lives in
the pointer handler, not here.

Reset and pause step out when they have nothing to do: pause before each
game's first tap, reset while the match is still pristine. matchPristine()
already meant exactly that, so both toggles sit beside the settings one in
render(), where they can't go stale. Reset and settings turn out to be
exact opposites. btn--vacant like the gear, so the space is kept — a
fresh-match bar differs from the old build only inside those two slots,
every other pixel in the frame identical, gear and speaker unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:47:50 +02:00
hansdezwartandClaude Opus 5 632f1ef47d Change the turn on the lift, not the touch
Android's swipe-up-from-the-bottom is how you leave an app, and the
bottom panel owns that edge — .app is 1fr auto 1fr with the bar in the
middle, so both panels run to the screen edges. Acting on pointerdown
meant starting a swipe home was a finger landing on the clock, and the
turn changed before the gesture had even been recognised. The status bar
over the top panel has the same problem. Android won't let an app opt
out either; setSystemGestureExclusionRects is ignored for the home
gesture.

So a tap is now a press and a lift in the same place, within 12px. A
swipe travels further; a gesture the system claims arrives as
pointercancel and never becomes a lift.

The clock is still stamped at the press. tap() takes the pointerdown
timestamp, which meant dropping elapsed() for now - st.turnAt — elapsed()
always measures to the current instant, the one thing that can't be
backdated. With the delay forced to 0, holding a finger down 600ms longer
charges the outgoing player 1033ms against 1037ms for an instant lift:
the same thinking time, not the extra 600.

The fullscreen request moved to the lift too, so a swipe can't force it.
touch-action:none on .panel means a slow drag ends in a lift we reject
rather than a cancel the browser issues on its own account; it changes no
pixels.

Falls out of it: a hand dragged across a live panel no longer flips the
turn, which the README only promised of the waiting player's hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:26:56 +02:00
hansdezwartandClaude Opus 5 365605dd6c Default a fresh install to 3:00 + 12s
The reserve was already three minutes; only the delay moves, 15s to 12s,
which is the control both of the other presets use.

It is a default, not a migration: store.num only falls back when nothing
is saved, so anyone already carrying a bg.delay keeps it. Checked both
ways — an empty profile comes up with 12s and the 3m/12s preset already
lit, a profile holding 15000 comes up unchanged with 3m/15s lit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:00:50 +02:00
hansdezwartandClaude Opus 5 7a820b44f8 Share a real baseline between the score labels and their numbers
The panel readout lined its labels up with their numbers only by luck.
.head is a grid with align-items left at its default, so both cells
stretch and each one's text sits at the top of its own box. The two
baselines coincided because the label run and the digit run happened to
measure the same, not because anything asked for it.

Force the value cell onto a face with different metrics and the old CSS
drifts 1-4px either way — Amiri -4px, Avenir and Alegreya -2px,
AnjaliOldLipi +2px, that last one putting the label baseline below the
number, which is the reported symptom. With align-items:baseline every
one of those is 0.0000px.

Rendering is untouched where the metrics already agree: before and after
differ by 0 pixels across the whole frame, same text bands, same 7px of
whitespace above the Moves line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:17:05 +02:00
hansdezwartandClaude Opus 5 81826589d8 Put the settings out of reach once a match is under way
Every control in the settings sheet calls resetClocks(), so opening it
mid-match could throw away two live clocks. The gear now only appears on an
untouched match: before the first tap, or after a two-tap reset.

"Untouched" is derived rather than tracked — phase, turn, moves, score and
both reserves matching matchTime() — so it cannot fall out of sync the way a
stored flag could. The phase alone would be wrong: a score change runs
newGame(), which returns to IDLE waiting for the next game's first tap while
the match is still very much on. The reserve check also catches a clock
corrected through the score sheet.

The button uses visibility rather than [hidden], so its slot stays put and
the rest of the bar doesn't jump the instant the clock starts; verified
pixel-for-pixel that reset, play/pause, score and sound do not move.
visibility:hidden also drops it from the tab order and the accessibility
tree. The rule is enforced in the click handler as well, because a
programmatic click still reaches a visibility:hidden button — CSS is the
affordance, the guard is the rule.

The trade, deliberately: a mistake in the time or delay now costs a reset and
a restarted match. Correcting a clock mid-match is the score sheet's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:57:37 +02:00
hansdezwartandClaude Opus 5 b438630796 Only touch the DOM when a displayed value changes
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>
2026-08-11 15:40:11 +02:00
hansdezwartandClaude Opus 5 0f69c78b74 Resume the clock on leaving the score sheet, and split the readout in three
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>
2026-08-11 15:11:31 +02:00
hansdezwartandClaude Opus 5 9834296722 Hide the score line and score icon in a one-point match
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>
2026-08-11 14:48:44 +02:00
hansdezwartandClaude Opus 5 92bb31b18e Track a match: length, score sheet, and a clock that survives a quit
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>
2026-08-11 14:39:32 +02:00
hansdezwartandClaude Opus 5 247eb0cf04 Make the clock behave on iOS
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>
2026-08-11 11:53:56 +02:00
hansdezwartandClaude Opus 5 559b6cd335 Fix icon toggles, add timing presets and a two-tap reset
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>
2026-08-11 11:18:02 +02:00
hansdezwart a1d46f5e32 First commit 2026-08-11 10:52:35 +02:00