Commit Graph
19 Commits
Author SHA1 Message Date
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 64bc4a9a34 Stop the Time stepper overflowing the sheet on a phone
The minutes+seconds stepper is 302px wide. With a label beside it the Time
row needs about 422px of viewport, so it has been running off the right edge
of the settings sheet on every phone narrower than that — measured 19px off
a 390pt iPhone, 34px off a 375pt SE and 89px at 320. The "00 SEC" value and
its + button were the part that got cut.

Below 420px the row now stacks: label on one line, stepper right-aligned
underneath with slightly narrower value columns. That needs 262px against
the 276px available at 320px. Wider screens keep the inline layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:39:14 +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 0274e10d4c Add a 3m / 12s preset between the other two
Three buttons left 84.7px each at a 320px viewport against 81.2px of
intrinsic content, so the horizontal padding drops to 6px and the label is
nowrap — about 11px of slack instead of 3.5px.

paintPresets() needed no change: it reads .preset from the DOM and matches
on both base and delay, which the new preset makes load-bearing since two
now share a delay and two share a base. Stepping the delay down from
3m / 15s correctly lights 3m / 12s rather than staying dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:46:53 +02:00
hansdezwartandClaude Opus 5 f68a88f3e8 Centre the reset glyph in its viewBox
The armed ring looked a couple of pixels low. The ring was already exactly
centred on the button — measured 0.00px off — but the glyph wasn't: its arc
is a circle centred at (12,11) in a 24-unit viewBox, so the ring and the
glyph's circle were drawn as two almost-concentric circles about 1.8px
apart, which reads as misalignment much louder than a single shape would.

Shifting the glyph down one unit puts its circle at (12,12), concentric with
the ring to 0.00px. It also fixes the icon sitting high next to its
neighbours — reset was the only one in the bar whose ink was off centre
(dy=-1.32 against 0.00 for the gear and speaker).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:46:45 +02:00
hansdezwartandClaude Opus 5 0b96eb824b Make the armed reset a centred, unwinding ring in the button flash colour
The old confirmation ring was a scaling, fading border. It sat off-centre —
the ::before had no top/left, so it landed on its static position in the
grid rather than over the icon — and it read as a shrinking circle rather
than time running out.

It's now an SVG ring animated on stroke-dashoffset (0 to 113.1, the
circumference at r=18) and rotated -90deg to start at 12 o'clock, so it
unwinds like a progress ring in reverse over the same 2s as the timeout.
Explicitly centred on the button.

The stroke is currentColor against #reset.armed{color:var(--icon-hot)},
so it uses the pale flash every button already shows on :active instead of
the theme accent, and no longer changes with the colour setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:31:06 +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