A force-quit deep into a turn came back at the clock's last-turn-switch value,
losing the whole interrupted turn. The tell was the delay counter returning at
a full 12: the newest saved state was the one tap() writes, and tap() sets
turnAt and calls saveGame() in the same breath, so it carries held ~ 0.
Restoring it reproduces the moment of the turn switch exactly.
The two triggers meant to save at quit time — visibilitychange and pagehide —
do not fire in Android's WebView. MainActivity's onPause() forwards to
web.onPause() under a comment claiming that is what makes visibilitychange
fire. It isn't: document visibility follows the WebView's view and window
visibility, not that call. The forwarding is still right for stopping timers
and audio, but the saved match should never have depended on it.
This is not new. It has almost certainly been broken since 1.0, and the release
check could not catch it, because for the first 12 seconds of a turn the delay
absorbs everything and render() never touches st.reserve. A clock that has lost
its place is indistinguishable from one that is correctly sitting still, so
every force-quit test that quit soon after a turn change passed. RELEASING.md
now says to let the turn run 40 seconds first, which is the only version of
that check worth running.
So the page stops waiting to be told. A timer writes the match once a second
while a clock is running — a timer rather than a hook in loop(), because
requestAnimationFrame stops when the page is hidden, which is exactly when this
matters. blur joins the listeners too: a different signal, and one that does
fire when the task switcher opens. At most a second can now be lost, which is
well inside the delay nobody is charged for anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A test user found the reset button, tapped it once and gave up. That is what
the old feedback invited: a first tap brightened the icon and started a ring
draining over 2s, and a draining ring reads as "wait" or "loading", not "press
me again". The one thing that would have said otherwise — a word — is not
available here, because the bar is shared by two players sitting opposite each
other and every word in this app lives inside a panel that rotates to face one
of them.
"Tap this twice" has no wordless vocabulary. "Keep holding" has a very
well-worn one, so reset is now a 975ms hold: a dim track ring appears whole the
moment the finger lands, a bright arc fills over it from 12 o'clock, and
letting go early makes the arc retreat. The retreat is the instruction. A stray
hold is also far less likely than two stray taps inside 2s, so the live game
this was guarding is guarded better than before.
Three things that look incidental and are not:
- Hiding the ring is an opacity that waits out the retreat, not display:none,
which cut the retreat off at the instant of release — invisible, and it was
the whole point. The fade has a real duration because a 0s transition with a
delay may be treated as no transition at all, taking the delay with it.
- width:auto on the ring: .btn svg sets a width for the bar icons, and
inheriting it against the ring's new height draws an ellipse.
- The ring's transition is exempted from the blanket prefers-reduced-motion
rule. Collapsing it would fill the ring the instant you touched the button
and claim the reset was done 975ms before it was. It reports state.
Keyboard, switch and assistive activation cannot hold, and hold-only would have
left those users unable to reset at all, one release after an accessibility
pass. Clicks arriving with detail 0 — no pointer behind them — keep the old
two-presses-within-2s and the draining ring.
Success now sounds: the flag's falling triad, rising instead. Deliberately not
another rising fifth, which sndSwap owns and plays on every handover.
Also corrects a claim in RELEASING.md: the screenshots reproduce near-exactly,
not exactly. Two runs of identical code differ by a 4x13 pixel sliver where the
delay bar's fill edge lands mid-pixel, so a non-empty git diff after re-running
proves nothing on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The v prefix was never the constraint. checkupdates resolves whatever ref
it finds to a commit hash before writing the build entry, and Tags mode
with no pattern matches every tag regardless of name.
The rule that does bite: a hand-written build entry must pin a full commit
hash. The 1.0 submission used the tag and a reviewer sent it back. Worth
writing down because fdroid lint only checks this under RepoManifest, so
the pipeline passes with a tag sitting in commit:.
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>