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>
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>
Six months from now none of this will be obvious: which of the two version
numbers has to go up, that the changelog file is named after the
versionCode rather than the version name, and that the tag has to keep its
v prefix — checkupdates takes the tag name verbatim as the commit to
build, so a tag named 1.1 would quietly break the automation.
Also records the two things that make the screenshots reproducible, both
of which cost time to find: a headless capture lands whenever the
virtual-time budget runs out rather than when the script finishes, and
CSS transitions don't advance under it, so a panel caught mid-transition
photographs in its old colour.
States the part that is easy to get wrong in the other direction too:
after the first submission there is nothing to do in fdroiddata. The bot
watches the tags here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
"Do whatever you like with it" isn't an SPDX identifier, and without one
F-Droid won't take the app at all. Same intent, now in a form a machine
can read.
The listing lives in fastlane/metadata/android/en-US/ so F-Droid reads it
out of the repo and it stays in version control: title, a 65-character
summary, a description adapted from the README that leads with the empty
permission list, a changelog and four screenshots.
The screenshots are generated from the same HTML the app runs, at
1170x2532. The harness freezes the render loop once the wanted frame is
painted — a headless capture otherwise lands whenever the virtual clock
runs out, by which time the delay bar has always finished draining, and
the delay is the thing worth showing. Shot one is a 5-point match at 2-1
with the bar half spent; the others are the score sheet, the settings
sheet from a pristine match (the gear is gone once a match is running, so
that is the only honest way to show it), and a 1-point game with the
score chrome absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>