17 Commits
Author SHA1 Message Date
hansdezwartandClaude Opus 5 6653541bb9 Release 1.7
versionCode 8. public_html/ changed, so the service worker cache goes to
bgclock-v23.

Confirmed on device from a debug APK: the tally reads as a tally in the bar at
arm's length, and from the far side of the board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:47:49 +02:00
hansdezwartandClaude Opus 5 7095e2b86e Draw the score button as a tally
The ruled card it had been reads as a document, a table or a form at the size
it actually renders — six strokes inside a 16x18 box at clamp(23px,6.4vw,32px)
— and nothing in it said score. A tally does, with no box around it to explain
itself, and it is the one score mark that survives being seen from the far side
of the board: upside down it is still a tally.

Three uprights and the slash, not four and the slash. At 23px a fourth upright
closes the gaps and the whole glyph turns into a picket fence; the slash is kept
inside the uprights' span for the same reason, since running it past them made
the icon read wider than the gear and speaker beside it.

Drawn against six alternatives in two passes, judged rendered in the real bar
at both ends of the size clamp rather than described. The passes that lost are
worth a line each, because the reasons were not visible in the geometry: two
stacks of checkers flips to say the other player is ahead when the far player
looks at it, which is worse than merely asymmetric; a bar chart is the stock
analytics glyph and says statistics rather than this match; and a tally inside a
card is smothered by the card, whatever the count.

Screenshots 1 and 5 are redone. Not 2 and 3, where the open sheet covers the bar
and the icon is not in shot at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:47:37 +02:00
hansdezwartandClaude Opus 5 5ad69c6ff2 Release 1.6
versionCode 7. public_html/ changed, so the service worker cache goes to
bgclock-v22.

Confirmed on device, from a debug APK over the top of 1.5: the settings kept
from 1.5 came back on the You row with the switch off; the gear stayed
reachable with the two bases set apart, which is the failure that would have
had no way out; each side ran on its own delay, including a player set to zero
starting on their reserve immediately; and a turn left running well past the
delay, force-quit and reopened, came back paused with the split still in force.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:55:33 +02:00
hansdezwartandClaude Opus 5 a34d6058a1 Set the time and the delay per player
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>
2026-08-16 11:55:20 +02:00
hansdezwartandClaude Opus 5 e207478e79 Release 1.5
versionCode 6. public_html/ changed, so the service worker cache goes to
bgclock-v21.

Not confirmed on device: the force-quit-and-reopen check from RELEASING.md
step 4 has not been run for this build. Nothing in it touches storage, timing
or the state machine — the change is a label, an inherited colour and a lit
button, all written from render() — but the check is still owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:42:35 +02:00
hansdezwartandClaude Opus 5 3f6fe5abf1 Say Paused on both halves while the clock is stopped
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>
2026-08-16 10:42:25 +02:00
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 3a7eb8c612 Save the match every second instead of waiting to be told
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>
2026-08-15 15:07:18 +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 ec1a65c89c Reset by holding the button, not by tapping it twice
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>
2026-08-15 14:51:06 +02:00
hansdezwartandClaude Opus 5 b31087223a Release 1.2
versionCode 3. public_html/ changed this time, so the service worker cache is
bumped to bgclock-v18 — without it an installed web user would keep serving
the old page from cache indefinitely.

Nothing to do in fdroiddata. The recipe landed with the 1.0 submission and
carries UpdateCheckMode: Tags and AutoUpdateMode: Version, so their bot finds
this tag, resolves it to a commit hash and writes the build entry itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:41:48 +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 ac144f2eb1 Release 1.1
versionCode 2. The one user-visible change since 1.0 is the navigation bar
staying on screen; public_html/ is untouched, so the service worker cache
keeps its name and no web user sees a different page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:13:55 +02:00
hansdezwartandClaude Opus 5 31fcaf8282 Rewrite the store descriptions around what the app does
Hans's edit. The long description is a feature list now rather than prose,
and both mention the scorekeeping, which the old text left out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:08:00 +02:00
hansdezwartandClaude Opus 5 b6d8f4e234 Correct the tagging rule: F-Droid pins hashes, not tag names
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>
2026-08-12 10:21:12 +02:00
hansdezwartandClaude Opus 5 862b029953 Keep the navigation bar; hide only the status bar
hideSystemBars() asked for systemBars(), which is the union of the two, so
the way out of the app was an invisible swipe you had to remember.
statusBars() leaves the navigation bar where it is: still fullscreen at the
top, but leaving is now an affordance you can see. On three-button
navigation it also owns the bottom strip outright, so those taps never
reach the panel at all — the lift-not-press rule still carries the gesture
case, where the pill is only drawn over the app.

The pre-Android-11 branch loses HIDE_NAVIGATION, LAYOUT_HIDE_NAVIGATION and
IMMERSIVE_STICKY along with it; immersive only governs a navigation bar
that is hidden, and this one isn't.

The layout needed nothing: .panel--bottom .inner already pads by
max(14px, env(safe-area-inset-bottom)), so the readout lifts clear of the
bar by itself. Confirmed on device.

Not tagged. This rides along with whatever else goes into 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:07:24 +02:00
hansdezwartandClaude Opus 5 a672268a8f Write down how to cut a release
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>
2026-08-11 21:44:27 +02:00
22 changed files with 913 additions and 152 deletions
+3
View File
@@ -6,3 +6,6 @@ android/local.properties
# editor leftovers
*.swp
*~
# python leftovers
__pycache__/
+33 -9
View File
@@ -6,6 +6,10 @@ Backgammon Galaxy control. Defaults to 3:00 + 12s.
Plain HTML, CSS and JavaScript. No frameworks, no fonts, no images, no network.
There's an Android package too, in `android/` — a WebView around the same
`public_html/`, so the two can't drift. It declares no permissions at all. See
[RELEASING.md](RELEASING.md) for how to cut a new version for F-Droid.
## Getting it onto your phone
Chrome will only offer a real install over HTTPS, so the file needs a host.
@@ -66,7 +70,10 @@ everything works except installation and the service worker.
stray thumb can't wipe a live match. It's absent on a fresh match, where there
is nothing yet to wipe.
- **Pause** freezes mid-turn and resumes exactly where it stopped, delay
included. Opening either sheet pauses automatically. The settings sheet leaves
included. Both halves read **Paused** underneath the delay bar while it is, in
the same ink as the clock above it, and the play button lights up — so a
stopped clock can't be mistaken for a running one from either side of the
board. Opening either sheet pauses automatically. The settings sheet leaves
it paused — everything in there resets the clocks anyway — while the score
sheet picks the turn back up when you close it, so glancing at the score
doesn't cost you a tap on play. It's absent whenever there's no clock running
@@ -89,7 +96,8 @@ normally restarted — use pause and reset for those.
Set **Match** in the settings to the number of points you're playing to — 1, 3,
5 and up to 29. The time you set is *per point*, so a 5-point match at 2:00 a
point puts 10:00 on each clock, and the settings sheet shows you that sum.
point puts 10:00 on each clock, and the settings sheet shows you that sum — one
sum, or one for each player if you've set their times apart.
Each half reads the state from that player's own side of the board:
@@ -129,17 +137,33 @@ score down to fit.
## Settings
Under **Delay** are three presets — **2m / 12s**, **3m / 12s** and **3m / 15s**.
Tapping one jumps both timers there and lights it up. Change the time or delay by
hand and the highlight goes out; land back on a preset's numbers and it comes
back on.
Tapping one puts both the time and the delay there and lights it up. Change
either by hand and the highlight goes out; land back on a preset's numbers and it
comes back on.
**Different time per player** — the switch under the Time steppers — gives each
player their own time *and* their own delay. Turn it on and a second row appears
under both, labelled **You** for the bottom half of the phone and **Them** for
the far end, the same way round as the score sheet. Them starts as a copy of You;
change either from there. Turn it back off and both players go back to whatever
You says, so You is always the row that survives — set the shared time first and
split it afterwards, not the other way round.
The presets are gone while that switch is on. A preset is one pair of numbers and
there are two players to give it to, so there is nothing sensible for it to mean.
A handicap match is the point of the switch: give the stronger player less time,
or take one player's delay down to zero and leave the other's alone.
| Setting | Range | Default |
| --- | --- | --- |
| Match | 1 – 29 points, odd | 1 |
| Time | 0:15 – 99:00 per point | 3:00 |
| Delay | 0 – 60 s | 12 s |
| Time | 0:15 – 99:00 per point, per player | 3:00 |
| Delay | 0 – 60 s, per player | 12 s |
| Different time per player | on / off | off |
| Colour | Sage, Brass, Slate, Teal, Plum | Sage |
| Sound | on / off | on |
Sound isn't in the sheet — it's the speaker button in the bar, reachable at any
time, mid-match included.
Brass is the colour in the chess.com app, if you want the exact original look.
Changing match, time or delay resets the clocks — the score is left alone, so you
@@ -150,7 +174,7 @@ survive closing the tab, force-quitting the installed app and rebooting the
phone — the app also asks for persistent storage so they aren't evicted when the
device runs low on space (Chrome honours that request; Safari ignores it).
Reopening restores the score, both clocks, the move counts, whose turn it is and
how much delay was left. The one thing it can't restore is time passing while
how much delay was left — each player's own delay, if you've set them apart. The one thing it can't restore is time passing while
the app was closed, so it comes back paused rather than charging anyone for the
gap: a crash shouldn't decide a game.
+165
View File
@@ -0,0 +1,165 @@
# Releasing a new version
The clock lives in two places: the web app in `public_html/`, which you host
yourself, and the Android app on F-Droid, which is built from a git tag.
**Everything F-Droid needs comes from a tag in this repository.** After the first
release was accepted there is nothing to do in F-Droid's own repo — their bot
watches the tags here and writes the build entry itself. Don't open a merge
request against `fdroiddata` for a new version.
Worked example below bumps 1.0 to 1.1. Substitute your own numbers.
## 1. Make the change
Both versions share one copy of the app. Edit `public_html/index.html` and the
Android build picks it up automatically — the Gradle build reads `public_html/`
directly rather than keeping a second copy, so the two can't drift.
If you changed anything visible, bump the service worker cache or installed web
users will keep the old page:
```
# public_html/sw.js
const CACHE = "bgclock-v18"; # any new value; it only has to differ
```
## 2. Bump the version
In `android/app/build.gradle.kts`:
```kotlin
versionCode = 2 // an integer, +1 every release, never reused
versionName = "1.1" // what people see
```
`versionCode` is what Android uses to decide one version is newer than another.
It must go up every single release, even for a one-character fix.
## 3. Write the changelog
A new file named after the **versionCode**, not the version name:
```
fastlane/metadata/android/en-US/changelogs/2.txt
```
Maximum 500 characters. This is what shows up in F-Droid's "What's New".
## 4. Build it and put it on your phone
```bash
export ANDROID_HOME="$HOME/Android/Sdk" # already in ~/.bashrc
cd android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
./gradlew --stop # the daemon holds ~500 MB otherwise
```
Play a real game before tagging. Worth checking specifically: start a match,
make some moves, force-quit from the task switcher and reopen — everything
should come back, paused. That is the check that proves the app's storage is
working, and it fails silently rather than loudly.
**Let the turn run at least 40 seconds before you quit.** This is the whole
check, and doing it any faster proves nothing: for the first 12 seconds of a
turn the delay absorbs everything and the reserve clock is *supposed* to sit
still, so a clock that has lost its place looks exactly like one that hasn't.
Quit well past the delay and the time must come back lower than it was at the
turn switch — and the delay counter must not be sitting at a full 12. That is
how a broken save hid from this check from 1.0 to 1.3.
## 5. Commit, tag, push
```bash
git add -A
git commit -m "…"
git tag -a v1.1 -m "Backgammon Clock 1.1"
git push
git push origin v1.1
```
Keep tags in the `v1.1` shape for consistency, but nothing depends on it:
`UpdateCheckMode: Tags` with no pattern matches every tag whatever it's called,
and F-Droid's bot resolves whichever tag it finds to a commit hash before it
writes the build entry.
Never move or delete a tag that has been pushed — F-Droid may already have built
it. If a release is wrong, bump the version and release again. (The recipe pins
a hash, so a moved tag can't retroactively change a version they already built;
it would just leave your repo disagreeing with what's on people's phones.)
**If you ever write a build entry by hand**, its `commit:` must be a full 40-char
commit hash — never a tag, never a branch. Tags are mutable, so a tag there means
the thing F-Droid builds and signs can change under them, and they reject it on
sight. This came up on the very first submission and is easy to get wrong,
because `fdroid lint` doesn't check it under `UpdateCheckMode: Tags` — the
pipeline goes green and a human catches it days later. Get the hash with:
```bash
git rev-parse v1.1^{commit} # ^{commit}, or an annotated tag gives you the
# tag object's hash instead of the commit's
```
This doesn't come up in a normal release: the bot writes the entry, and the bot
writes hashes.
## 6. Wait
Their bot notices the new tag, adds a build entry, and the build server picks it
up on its next cycle. Expect a day or two. Nothing to do.
## Deploying the web version
Separate from all of the above, and manual: upload the six files in
`public_html/` — `index.html`, `manifest.webmanifest`, `sw.js`, `icon-192.png`,
`icon-512.png`, `icon-maskable-512.png` — to the web host. `README.md` and
`RELEASING.md` stay out of it; they live in the repo root for that reason.
## If the screenshots need redoing
```bash
python3 tools/screenshots.py
```
That rewrites all five in
`fastlane/metadata/android/en-US/images/phoneScreenshots/` at 1170×2532. They
are rendered from `public_html/index.html` with headless Chromium rather than
taken on a phone, so anything that changes the look of the app is a reason to
re-run it.
**Near-exact, not exact.** Two runs of identical code differ by a few bytes in
shots 1 and 4: a 4×13 pixel sliver at the delay bar's fill edge, where the
`scaleX` boundary lands mid-pixel and the rasteriser rounds it differently from
one run to the next. Max channel difference 11, invisible. So a `git diff` after
re-running is not evidence that anything changed — compare the pixels before
believing it, and `git checkout` the files back if that sliver is all it is.
The script sets each scene by seeding `localStorage` before the app boots, so
the app renders its own saved state rather than having the DOM poked from
outside. Editing a scene means editing the `SCENES` table at the top.
Three traps, all of which fail quietly, and all of which the script already
handles — they are written down here because they cost an afternoon each:
- The capture lands whenever the virtual-time budget runs out, not when your
script finishes — so freeze the render loop (`requestAnimationFrame = () => 0`)
once the frame you want is on screen.
- CSS transitions don't advance under `--virtual-time-budget`, so a panel caught
mid-transition photographs in its *old* colour. Disable transitions in the
screenshot build.
- Snap-confined Chromium cannot write into hidden directories — not `~/.cache`,
not any dot-directory, not the private `/tmp` it is handed. It fails with a
bare "Permission denied" and no hint as to why. Both the HTML it reads and the
PNG it writes have to sit in a plainly-named directory.
## Reference
- App ID: `nl.hansdezwart.bgclock` — fixed forever; changing it makes a new app.
- F-Droid listing text: `fastlane/metadata/android/en-US/` (title ≤50 chars,
summary ≤80, description ≤4000, changelog ≤500). Only these HTML tags work in
the description: `b big blockquote br cite em i li ol small strike strong sub
sup tt u ul`.
- The original submission: https://gitlab.com/fdroid/fdroiddata/-/merge_requests/45478
- F-Droid signs the APK with their key, not yours. Reproducible builds were
declined at submission and can't be enabled later for this app ID.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "nl.hansdezwart.bgclock"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 8
versionName = "1.7"
}
compileOptions {
@@ -95,28 +95,41 @@ public class MainActivity extends Activity {
if (hasFocus) hideSystemBars();
}
/** The page calls requestFullscreen(), which a WebView ignores. This is that. */
/**
* The page calls requestFullscreen(), which a WebView ignores. This is that.
*
* Only the status bar goes. The navigation bar stays: it is how you leave the
* app, and leaving it visible makes that obvious rather than a remembered
* swipe. statusBars() rather than systemBars() — the latter is the union of
* the two.
*/
private void hideSystemBars() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
WindowInsetsController c = getWindow().getInsetsController();
if (c != null) {
c.hide(WindowInsets.Type.systemBars());
c.hide(WindowInsets.Type.statusBars());
c.setSystemBarsBehavior(
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
// no IMMERSIVE_STICKY or HIDE_NAVIGATION here: immersive only governs
// a hidden navigation bar, and this one stays
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
| View.SYSTEM_UI_FLAG_FULLSCREEN);
}
}
// forwarded so the page's visibilitychange fires — that is what calls
// saveGame() and nudges the audio context back after backgrounding
// Forwarded so the WebView stops its timers, animations and audio while the
// app is in the background, and picks them up again on return.
//
// This does NOT drive the page's visibilitychange — an earlier comment here
// claimed it did, and the saved match quietly depended on that being true.
// Document visibility follows the WebView's view and window visibility, not
// this call, so the page can be killed without ever hearing about it. The
// page no longer relies on being told: it writes the match to localStorage
// once a second while a clock is running.
@Override
protected void onPause() {
super.onPause();
@@ -0,0 +1,5 @@
The navigation bar at the bottom of the screen now stays visible while you
play, so switching to another app is easy. Only the status bar is hidden.
The description in F-Droid has been rewritten to spell out what the clock can
actually do.
@@ -0,0 +1 @@
Improved the contrast to be compliant with accessibility guidelines.
@@ -0,0 +1,4 @@
Resetting the clock is now a press and hold rather than two taps. The ring
around the button fills while you hold it, and springs back if you let go
early, so the button explains itself. A short sound confirms the reset when
sound is on.
@@ -0,0 +1,4 @@
Fixes a match not coming back correctly after the app is force-quit. The clock
returned to where it stood at the last turn change, losing the time spent on
the turn that was interrupted. The match is now written down once a second
while a clock is running, so at most a second can be lost.
@@ -0,0 +1 @@
Added a 'Paused' indicator when the clock is paused.
@@ -0,0 +1 @@
Rationalised the settings page and added the option to set a different time and delay per player.
@@ -0,0 +1 @@
Change the score icon to a tally.
@@ -1,21 +1,21 @@
A backgammon clock for two players sharing one phone between them. Reserve time plus a fixed number of free seconds each turn that never carry over. Defaults to 3:00 with a 12 second delay.
A backgammon clock and scorekeeper for two players sharing one phone between them.
It asks for no permissions at all. There is no network access, no analytics, no accounts, no ads, and nothing to configure before you can play. The whole clock is a single HTML file inside the app.
The app asks for no permissions at all. There is no network access, no analytics, no accounts, no ads, and nothing to configure before you can use it. The whole clock is a single HTML file inside the app.
Tap your own half to end your turn and start your opponent's clock.
<b>Features</b>
<b>Matches</b>
- Large area with the remaining total time/delay time for this move. Tap on your half to end your turn and start your opponent's clock.
- Set the number of points to play for (1, 3, 5, 7, …, 29).
- Set the reserve time per point (e.g. 2 minutes or 3 minutes), in 15-second increments.
- Set the delay (free seconds every turn which don't carry over to the next turn), in 1 second increments.
- Choose 1 of 5 colour schemes.
- Turn sounds (for the turn change, when the delay runs out, and when the time runs out) on or off.
- Pause/play the clocks.
- Number of moves for this game and current score (when playing a multi-point match) are always shown.
- Once a game is done, click the score icon in the middle to set the score. The clocks stay at their current time.
- Adjust the clocks from the score sheet, if necessary.
- Press the reset button twice (within 2 seconds, so you can't press it accidentally) to reset the clock and scores and start a new match.
- Keeps running in the background, returns to the last known clock timings and scores after a crash or forced quit.
- The screen stays awake while you play.
Set the match length to any odd number of points up to 29. The time you set is per point, so a 5-point match at 2:00 a point puts 10:00 on each clock. Each half of the screen reads the score from that player's own side of the board, so both players see their own score on the top line.
At one point there is no match to keep score of, and the score lines disappear entirely — you get a plain single-game clock with a move count.
<b>It survives being closed</b>
Settings and the match in progress are kept on the device: the score, both clocks, the move counts, whose turn it is and how much delay was left. Force-quit it mid-match and it all comes back. The one thing it can't restore is time that passed while the app was closed, so it returns paused rather than charging anyone for the gap — a crash shouldn't decide a game.
Pause freezes mid-turn and resumes exactly where it stopped, delay included. Reset takes two taps, so one stray thumb can't wipe a live match.
Five colour schemes, three sounds generated on the fly, and the screen stays awake while you play.
Design is by Hans de Zwart, partially inspired by the Chess.com clock. Code is by Claude Opus.
Functional design is by Hans de Zwart, partially inspired by the open sourced Chess.com clock. Code is by Claude Opus.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 76 KiB

@@ -1 +1 @@
A flexible two-player backgammon match clock with a visible delay.
A flexible two-player backgammon match clock and scorekeeper.
+471 -114
View File
@@ -14,16 +14,18 @@
<link rel="apple-touch-icon" href="icon-192.png">
<style>
:root{
--accent:#7E9A79;
--accent:#7C9777;
--accent-ink:#FFFFFF;
--accent-mute:rgba(0,0,0,.34);
--accent-mute:rgba(0,0,0,.66); /* replaced per theme by applyTheme() */
--track:rgba(0,0,0,.34); /* the delay bar, not text — see .track */
--idle:#9A9A9A;
--idle-ink:#2E2E2E;
--idle-mute:rgba(0,0,0,.36);
--idle-mute:rgba(0,0,0,.63);
--bar:#332E2B;
--icon:#8E8880;
--icon-hot:#DFD9D1;
--flag:#A6564E;
--flag-mute:rgba(255,255,255,.82);
--sheet:#232120;
--sheet-line:rgba(255,255,255,.09);
--sheet-ink:#EDE9E4;
@@ -90,7 +92,11 @@
lines up while both measure exactly the same */
gap:3px 8px;
order:-1;
font-size:clamp(13px,3.7vw,20px);
/* the 19px floor is load-bearing, not taste: WCAG's large-text threshold is
14pt bold = 18.66px, and below it these labels need 4.5:1 rather than 3:1
— which no ink still readable as "muted" can reach on a mid-tone accent.
Lower this and the contrast stops being conformant, silently. */
font-size:clamp(19px,3.9vw,24px);
font-weight:700;
letter-spacing:.01em;
color:var(--idle-mute);
@@ -103,9 +109,8 @@
The guard drops the space when there's no score line above to separate from. */
.line:not([hidden]) + .line--moves > *{margin-top:7px}
.panel[data-state="active"] .head,
.panel[data-state="flagged"] .head{color:var(--accent-mute)}
.panel[data-state="flagged"] .head{color:rgba(255,255,255,.55)}
.panel[data-state="active"] .head{color:var(--accent-mute)}
.panel[data-state="flagged"] .head{color:var(--flag-mute)}
.stack{
flex:1;
@@ -151,7 +156,9 @@
width:clamp(72px,20vw,140px);
height:clamp(4px,1.1vw,7px);
border-radius:99px;
background:var(--accent-mute);
/* its own token: --accent-mute is text ink now and far darker than this bar
wants, and the two only ever agreed by accident */
background:var(--track);
opacity:.45;
overflow:hidden;
}
@@ -162,11 +169,62 @@
transform-origin:left center;
}
.dnum{
font-size:clamp(15px,4.4vw,26px);
font-size:clamp(19px,4.4vw,26px); /* 19px for the same reason as .head */
font-weight:700;
min-width:1.6em;
/* Exactly two digits — the delay caps at 60s, so it never needs a third —
and right-aligned, which does two things at once: the bar and the number
together sit dead centre of the panel whatever the count, and the digit
that changes every second stays put instead of sliding left when the
count drops out of double figures. The gap to the bar opens up by one
digit instead. 2ch is the digit advance, so with tabular figures this is
the exact width and not an approximation of it. */
min-width:2ch;
text-align:right;
}
/* ---------- paused ----------
A stopped clock used to look exactly like a running one: the panel keeps its
accent, and the delay bar simply freezes mid-drain. The only tell was the
play glyph in the bar — small, grey, and upside down for one of the two
players. So both halves say it in words.
Hung off the delay row rather than added to .stack, so the big clock does
not move a pixel when the clock stops — the same promise .btn--vacant makes
in the bar, and the reason .delay fades rather than hides. It also cannot
live *inside* .delay: 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. */
.foot{position:relative}
.paused{
position:absolute;
top:100%;
left:50%;
/* the bar and its number sit dead centre of the panel (see .dnum), so
centring on that row centres on the panel, whatever the count reads */
transform:translateX(-50%);
/* a clear step below the bar rather than .stack's gap: near enough to belong
to the delay row, far enough not to crowd it */
margin-top:clamp(21px,5.8vw,41px);
white-space:nowrap;
line-height:1;
/* Wider than the delay bar, narrower than the clock — the band is tighter
than it sounds, since "3:00" is only about 2.2x the bar. The crossovers
are .track's own (20vw between 360 and 700px wide), so the word holds its
proportion to the bar at every size instead of drifting at the ends.
Part of the width comes from the tracking rather than the glyph size:
spaced letters carry "held" without shouting over the clock. */
font-size:clamp(34px,9.4vw,66px);
font-weight:700;
letter-spacing:.08em;
/* no colour of its own: it inherits the panel's, which is what the clock and
the delay count are already wearing. Each theme picks its own --accent-ink
(white on most of them), so the word follows the palette without a rule
per theme, and the paused half never has two different inks on it. */
opacity:0;
transition:opacity .16s ease;
}
.paused.on{opacity:1}
/* ---------- control bar ---------- */
.bar{
background:var(--bar);
@@ -184,6 +242,9 @@
cursor:pointer;
}
.btn:active{color:var(--icon-hot)}
/* the way back from a paused clock, lit like a held Reset: the bar is the only
thing either player can act on while the clocks are stopped */
#playpause.lit{color:var(--icon-hot)}
.btn[aria-pressed="false"]{color:#5E5954}
.btn svg{width:clamp(23px,6.4vw,32px);height:auto;display:block}
.btn svg[hidden]{display:none}
@@ -194,26 +255,73 @@
.btn--vacant{visibility:hidden}
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
/* Reset confirms two different ways, because the two input methods can't share
one. A finger holds the button and the ring fills under it; lifting early
springs the ring back, which is the only way to say "keep going" without
words — and the bar carries no words by design, since it is shared by two
players sitting opposite each other. Keyboard and assistive activation
can't hold, so they keep the older two-presses-within-2s, where the ring
drains to show the window closing. */
#reset{position:relative}
#reset.armed{color:var(--icon-hot)}
#reset.armed,#reset.holding{color:var(--icon-hot)}
#reset .ring{
position:absolute;
top:50%;left:50%;
width:clamp(36px,9.6vw,48px);height:clamp(36px,9.6vw,48px);
/* As big as the bar will take, so a thumb can't cover it: the button is the
bar's full height, leaving 4px of dark above and below. width:auto is
load-bearing — .btn svg sets a width for the icons, and inheriting it here
would give the ring that width against this height and draw an ellipse.
It ends up a few px wider than the button's own slot, which is only ever
empty space; the neighbouring glyph stays a good 12px clear. */
height:calc(100% - 8px);
width:auto;
aspect-ratio:1;
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
display:none;
opacity:0;
/* Hiding waits out the spring-back, then fades. display:none here would cut
the retreat off at the instant the finger lifts, and that retreat is the
whole reason the gesture teaches itself. The 120ms is a real duration on
purpose: a 0s transition with a delay is allowed to be treated as no
transition at all, which would take the delay with it. */
transition:opacity 120ms linear 180ms;
pointer-events:none;
}
#reset.armed .ring{display:block}
/* appearing is instant — no duration, no delay */
#reset.armed .ring,#reset.holding .ring{opacity:1;transition:none}
#reset .ring circle{
fill:none;
stroke:currentColor; /* the same flash the buttons use */
stroke-width:2.6;
/* user units, so it scales with the ring: 2 of 40 lands between 2.5 and
3.4 real px across phone sizes, a shade heavier than the icon strokes
because this one has to read past a thumb */
stroke-width:2;
}
/* the whole circle, dim, there from the moment the finger lands: it shows how
far there is to go, which an arc growing out of nothing cannot */
#reset .ring-track{stroke:var(--icon)}
/* and the bright arc that covers it, from 12 o'clock clockwise */
#reset .ring-fill{
stroke:var(--icon-hot);
stroke-linecap:round;
stroke-dasharray:113.1; /* 2πr, r=18 */
animation:unwind 2s linear forwards;
stroke-dashoffset:113.1; /* empty */
/* The spring back. 180ms is the ceiling, not the figure: reversing a
transition part-way shortens it in proportion, so letting go at 41% takes
74ms, and the arc never spends longer retreating than it spent filling.
The dim track outlasts it either way — the ring holds for 180ms and then
fades, so even a stray tap shows a whole circle appear and go, which is
what says there was something to finish. */
transition:stroke-dashoffset 180ms ease-out;
}
#reset.holding .ring-fill{
stroke-dashoffset:0;
transition-duration:975ms; /* keep in step with HOLD_MS */
transition-timing-function:linear;
}
/* the keyboard path: a full ring draining over the 2s window. An animation
beats a transition on the same property, which is fine here only because
.armed and .holding are never set at once. */
#reset.armed .ring-fill{animation:unwind 2s linear forwards}
@keyframes unwind{
from{stroke-dashoffset:0}
to{stroke-dashoffset:113.1}
@@ -261,8 +369,33 @@
border-top:1px solid var(--sheet-line);
}
.row:first-of-type{border-top:0}
.row[hidden]{display:none} /* .row's own display would otherwise win */
.row h2{font-size:15px;font-weight:600;letter-spacing:.01em}
.row p{font-size:12.5px;color:var(--sheet-dim);margin-top:3px;line-height:1.35}
/* the Time copy puts its two sums on a line of their own when the players are
set apart; everything else here has no newline in it and is unaffected */
#p-time{white-space:pre-line}
/* Time and Delay each hold one stepper, or two when the players are set
separately. The second row is added rather than the first relabelled, so
the row a player already knows keeps its place and its numbers. */
.sides{display:flex;flex-direction:column;gap:10px}
.side{display:flex;align-items:center;justify-content:flex-end;gap:10px}
.side[hidden]{display:none} /* .side's own display would otherwise win */
/* pushed left against the stepper's right edge, so both rows' steppers line
up whatever the two labels measure */
.who{font-size:15px;font-weight:600;color:var(--sheet-dim);margin-right:auto}
/* nobody to tell apart while there is one row, and the label would only be
one more word to read */
.sheet:not(.split) .who{display:none}
/* a labelled stepper pair can't share a line with the heading at any width;
scoped to .split so the shared sheet keeps exactly the layout it had */
.sheet.split .row--wide,.sheet.split .row--delay{flex-direction:column;align-items:stretch}
/* the switch sits above the two rows it governs, so it reads as the thing
that decides their shape rather than as one more Time control */
.onoff-ctl{display:flex;align-items:center;gap:10px}
.onoff-ctl span{font-size:13px;color:var(--sheet-dim)}
/* a minutes+seconds stepper is 302px wide, so it can't share a line with a
label on any phone — stack it under one instead of overflowing the sheet */
@@ -347,6 +480,13 @@
@media (prefers-reduced-motion:reduce){
*{transition-duration:.01ms!important}
/* ...except the reset ring, which reports how far through the hold you are.
Collapsing it would fill the ring the instant you touched the button and
tell you it was done 650ms before it was. This one is state, not
decoration. Higher specificity than the * above, so it wins despite both
being !important. */
#reset .ring-fill{transition-duration:180ms!important}
#reset.holding .ring-fill{transition-duration:975ms!important}
}
</style>
</head>
@@ -363,17 +503,24 @@
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
<div class="foot">
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
<div class="paused" data-paused>Paused</div>
</div>
</div>
</div>
</section>
<div class="bar">
<button class="btn" id="reset" aria-label="Reset clock">
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
<button class="btn" id="reset" aria-label="Reset clock, hold to confirm">
<!-- ring-track/ring-fill, not track/fill: those two are the delay bar's -->
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true">
<circle class="ring-track" cx="20" cy="20" r="18"/>
<circle class="ring-fill" cx="20" cy="20" r="18"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
</svg>
@@ -389,10 +536,13 @@
<span class="gap"></span>
<button class="btn" id="score" aria-label="Score" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="3" width="16" height="18" rx="2.6"/>
<path d="M12 7.5v10"/>
<path d="M7.6 10.6h2.6"/><path d="M13.8 10.6h2.6"/>
<path d="M7.6 14.6h2.6"/><path d="M13.8 14.6h2.6"/>
<!-- a tally: three uprights and the fifth-mark slash. Three rather than
four, and the slash kept inside their span, because at 23px a fourth
upright closes the gaps and the whole thing reads as a picket fence.
It is the one score mark that needs no box around it to explain
itself, and it stays a tally seen from the far side of the board. -->
<path d="M6.5 6v12"/><path d="M12 6v12"/><path d="M17.5 6v12"/>
<path d="M4 18.5L20 5.5"/>
</svg>
</button>
<span class="gap"></span>
@@ -425,9 +575,12 @@
<div class="stack">
<div class="time" data-time>3:00</div>
<div class="out" data-out hidden>Out of<br>time</div>
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
<div class="foot">
<div class="delay" data-delay>
<span class="track"><i class="fill" data-fill></i></span>
<span class="dnum" data-dnum>15</span>
</div>
<div class="paused" data-paused>Paused</div>
</div>
</div>
</div>
@@ -451,34 +604,75 @@
</div>
</div>
<div class="row">
<div>
<h2>Different time per player</h2>
<p>Sets the delay separately too.</p>
</div>
<div class="onoff-ctl">
<span>Off</span>
<button class="toggle" id="t-split" role="switch" aria-checked="false" aria-label="Different time per player"></button>
<span>On</span>
</div>
</div>
<div class="row row--wide">
<div>
<h2>Time</h2>
<p id="p-time">Reserve time per point.</p>
</div>
<div class="stepper">
<button class="step" data-time-adj="-60">–</button>
<span class="val"><span id="v-min">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60">+</button>
<button class="step" data-time-adj="-15" style="margin-left:8px">–</button>
<span class="val"><span id="v-sec">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15">+</button>
<div class="sides">
<div class="side">
<span class="who">You</span>
<div class="stepper">
<button class="step" data-time-adj="-60" data-who="1">–</button>
<span class="val"><span data-v="min" data-who="1">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60" data-who="1">+</button>
<button class="step" data-time-adj="-15" data-who="1" style="margin-left:8px">–</button>
<span class="val"><span data-v="sec" data-who="1">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15" data-who="1">+</button>
</div>
</div>
<div class="side" data-them-row hidden>
<span class="who">Them</span>
<div class="stepper">
<button class="step" data-time-adj="-60" data-who="0">–</button>
<span class="val"><span data-v="min" data-who="0">3</span><span class="unit">min</span></span>
<button class="step" data-time-adj="60" data-who="0">+</button>
<button class="step" data-time-adj="-15" data-who="0" style="margin-left:8px">–</button>
<span class="val"><span data-v="sec" data-who="0">00</span><span class="unit">sec</span></span>
<button class="step" data-time-adj="15" data-who="0">+</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="row row--delay">
<div>
<h2>Delay</h2>
<p>Free seconds each turn. Never carries over.</p>
</div>
<div class="stepper">
<button class="step" data-delay-adj="-1">–</button>
<span class="val"><span id="v-delay">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1">+</button>
<div class="sides">
<div class="side">
<span class="who">You</span>
<div class="stepper">
<button class="step" data-delay-adj="-1" data-who="1">–</button>
<span class="val"><span data-v="delay" data-who="1">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1" data-who="1">+</button>
</div>
</div>
<div class="side" data-them-row hidden>
<span class="who">Them</span>
<div class="stepper">
<button class="step" data-delay-adj="-1" data-who="0">–</button>
<span class="val"><span data-v="delay" data-who="0">15</span><span class="unit">sec</span></span>
<button class="step" data-delay-adj="1" data-who="0">+</button>
</div>
</div>
</div>
</div>
<div class="row row--presets">
<div class="row row--presets" id="row-presets">
<div>
<h2>Preset time limits</h2>
<p>Quickly pick a setting.</p>
@@ -498,16 +692,7 @@
<div class="swatches" id="swatches" role="radiogroup" aria-label="Colour"></div>
</div>
<div class="row">
<div>
<h2>Sound</h2>
<p>Turn change, delay expiry, time out.</p>
</div>
<button class="toggle" id="t-sound" role="switch" aria-checked="true" aria-label="Sound"></button>
</div>
<button class="done" id="done">Done</button>
<p class="hint">Changing match, time or delay resets the clocks. The score is left alone.</p>
</div>
<div class="sheet" id="scoresheet" role="dialog" aria-modal="true" aria-label="Score">
@@ -566,8 +751,7 @@
</div>
<button class="done" id="score-done">Done</button>
<p class="hint">Changing the score starts a new game: moves back to zero, turn back to
the first tap. The clocks keep their times.</p>
<p class="hint" id="p-score-hint"></p>
</div>
<script>
@@ -584,32 +768,59 @@
// ask the browser not to evict us when the device is low on space
try{ if(navigator.storage && navigator.storage.persist) navigator.storage.persist(); }catch(e){}
/* mute is the alpha the head labels need over that accent to clear 4:1. It
can't be one shared value: the accents differ in lightness by more than the
muting does, and a single alpha either fails on plum or blackens brass.
sage and brass are a touch darker than they look elsewhere — white on them
has to clear 3:1 for the clock digits, and the old brass managed 2.46. */
var THEMES = [
{ id:"sage", accent:"#7E9A79", ink:"#FFFFFF" },
{ id:"brass", accent:"#C3A153", ink:"#FFFFFF" },
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF" },
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF" },
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF" }
{ id:"sage", accent:"#7C9777", ink:"#FFFFFF", mute:.66 },
{ id:"brass", accent:"#AA8C47", ink:"#FFFFFF", mute:.66 },
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF", mute:.72 },
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF", mute:.71 },
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF", mute:.77 }
];
/* The limits the steppers enforce, kept here because the load path needs them
too: bg.base.them and bg.delay.them can arrive from a half-upgraded install,
and a corrupt reserve makes a clock that can never flag. */
var LIMIT = { base: [15000, 99 * 60000], delay: [0, 60000] };
function clampTimer(which, ms){
var l = LIMIT[which];
return isFinite(ms) ? Math.min(l[1], Math.max(l[0], ms)) : l[0];
}
/* base and delay are per side, indexed like st.reserve and the panels: 1 is
the bottom half ("you"), 0 the far end ("them"), the same way round as the
score sheet. They are arrays whether or not the setting is split — split is
a fact about the sheet, not about the clock, so nothing downstream of here
has to ask. Shared is simply the state where the two entries are equal.
bg.base and bg.delay keep meaning what they always did, which is side 1, so
an older build reading this storage still finds its own settings, and an
install that predates the split lands symmetric. */
var split = store.get("bg.split", "0") === "1";
var yBase = clampTimer("base", store.num("bg.base", 180000));
var yDelay = clampTimer("delay", store.num("bg.delay", 12000));
var cfg = {
base: store.num("bg.base", 180000), // reserve per point
delay: store.num("bg.delay", 12000),
base: [split ? clampTimer("base", store.num("bg.base.them", yBase)) : yBase, yBase],
delay: [split ? clampTimer("delay", store.num("bg.delay.them", yDelay)) : yDelay, yDelay],
split: split,
points: store.num("bg.points", 1), // match length, odd, 1..29
theme: store.get("bg.theme", "sage"),
sound: store.get("bg.sound", "1") === "1"
};
if(!(cfg.points >= 1 && cfg.points <= 29 && cfg.points % 2 === 1)) cfg.points = 1;
// a match to N points is worth N times the per-point reserve
function matchTime(){ return cfg.base * cfg.points; }
// a match to N points is worth N times that side's per-point reserve
function matchTime(i){ return cfg.base[i] * cfg.points; }
/* ============ state ============ */
var IDLE = 0, RUN = 1, PAUSE = 2, FLAG = 3;
var st = {
phase: IDLE,
active: -1,
reserve: [matchTime(), matchTime()], // reserve banked at the start of the current turn
reserve: [matchTime(0), matchTime(1)], // reserve banked at the start of the current turn
score: [0, 0], // match points, by side
moves: [0, 0],
turnAt: 0, // performance.now() when the turn began
@@ -631,7 +842,8 @@
scoreLines: [].slice.call(p.querySelectorAll(".line:not(.line--moves)")),
delay: p.querySelector("[data-delay]"),
fill: p.querySelector("[data-fill]"),
dnum: p.querySelector("[data-dnum]")
dnum: p.querySelector("[data-dnum]"),
paused: p.querySelector("[data-paused]")
};
});
var btnPlay = document.getElementById("playpause");
@@ -697,6 +909,15 @@
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
tone(440, 0.32, 0.55, 0.26, "triangle", true);
}
// the flag's mirror: the same triad rising, and quicker — a fresh start.
// Deliberately not another rising fifth; sndSwap owns that shape and plays on
// every single handover, so nothing else may sound like it.
function sndReset(){
if(!cfg.sound) return;
tone(523.25, 0.00, 0.16, 0.20, "triangle", false);
tone(659.25, 0.07, 0.16, 0.20, "triangle", false);
tone(783.99, 0.14, 0.40, 0.22, "triangle", true);
}
/* ============ helpers ============ */
function elapsed(){
@@ -721,7 +942,7 @@
}
// how much of this turn has already come off the reserve (0 while the delay runs)
function consumed(i){
return isLive(i) ? Math.max(0, elapsed() - cfg.delay) : 0;
return isLive(i) ? Math.max(0, elapsed() - cfg.delay[i]) : 0;
}
// the number the panel is showing, which is what the score sheet edits
function shownTime(i){
@@ -737,7 +958,7 @@
return st.phase === IDLE && st.active === -1 &&
st.moves[0] === 0 && st.moves[1] === 0 &&
st.score[0] === 0 && st.score[1] === 0 &&
st.reserve[0] === matchTime() && st.reserve[1] === matchTime();
st.reserve[0] === matchTime(0) && st.reserve[1] === matchTime(1);
}
/* ---- write only on change ----
@@ -769,10 +990,10 @@
var main = st.reserve[i], dLeft = 0;
if(live){
if(e < cfg.delay){
dLeft = cfg.delay - e;
if(e < cfg.delay[i]){
dLeft = cfg.delay[i] - e;
}else{
main = st.reserve[i] - (e - cfg.delay);
main = st.reserve[i] - (e - cfg.delay[i]);
if(!st.ticked && st.phase === RUN){ st.ticked = true; sndTick(); }
}
}
@@ -791,14 +1012,19 @@
setText(el[i].them, st.score[1 - i]);
setText(el[i].target, cfg.points);
var showDelay = live && cfg.delay > 0 && dLeft > 0;
var showDelay = live && cfg.delay[i] > 0 && dLeft > 0;
el[i].delay.classList.toggle("on", showDelay); // no-op when already right
if(showDelay){
// the bar really does move every frame, but transform is compositor-only
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay).toFixed(4) + ")");
setStyle(el[i].fill, "transform", "scaleX(" + (dLeft / cfg.delay[i]).toFixed(4) + ")");
setText(el[i].dnum, Math.ceil(dLeft / 1000));
}
// both halves, not just the one whose turn it is: the waiting player's
// clock is stopped too, and they are the one who can't read the bar's
// play glyph the right way up
el[i].paused.classList.toggle("on", st.phase === PAUSE);
setAttr(el[i].root, "data-state",
(st.phase === FLAG && i === st.active) ? "flagged"
: (i === st.active && st.phase !== IDLE) ? "active" : "idle");
@@ -814,6 +1040,7 @@
// nothing running to pause: before a game's first tap, and after a flag,
// where togglePause is inert anyway — the game is over, Reset is the way out
btnPlay.classList.toggle("btn--vacant", st.phase === IDLE || st.phase === FLAG);
btnPlay.classList.toggle("lit", st.phase === PAUSE);
}
function loop(){ render(); requestAnimationFrame(loop); }
@@ -850,7 +1077,7 @@
// the same instant charges the outgoing turn and starts the incoming one,
// so nothing falls between the two and goes to neither player
var e = now - st.turnAt;
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay));
st.reserve[side] = Math.max(0, st.reserve[side] - Math.max(0, e - cfg.delay[side]));
st.moves[side]++;
st.active = 1 - side;
st.turnAt = now;
@@ -905,16 +1132,19 @@
// clocks back to full for the match length; the score is not this function's business
function resetClocks(){
disarm();
st.reserve = [matchTime(), matchTime()];
cancelHold();
st.reserve = [matchTime(0), matchTime(1)];
newGame();
saveGame();
}
// the Reset button — a whole new match
// the Reset button — a whole new match. The sound lives here rather than in
// resetClocks(), which the settings sheet also calls: changing the match
// length shouldn't sound like a reset.
function resetAll(){
st.score = [0, 0];
resetClocks();
sndReset();
}
function clampScore(){
@@ -957,30 +1187,94 @@
st.active = g.active;
st.phase = g.phase === RUN ? PAUSE : g.phase;
st.held = st.phase === PAUSE ? g.held : 0;
st.ticked = st.held > cfg.delay; // don't re-tick a delay that already expired
// the delay that matters is the one belonging to whoever's turn it was;
// active is legitimately -1 here, which has no delay to compare against
st.ticked = st.active >= 0 && st.held > cfg.delay[st.active];
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
}
// the only hooks iOS reliably fires before killing an app
// The hooks iOS fires before killing an app. Android's WebView is the problem
// child: neither of these is guaranteed there, whatever MainActivity's
// onPause() forwarding suggests, and when they don't fire the newest save is
// the one tap() wrote — which carries held ≈ 0, because tap() sets turnAt and
// saves in the same breath. Reopening then hands back the entire turn, and
// the bug hides in plain sight: quit inside the delay window and a clock that
// correctly hasn't moved looks exactly like a clock that lost its place.
document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "hidden") saveGame();
});
window.addEventListener("pagehide", saveGame);
// a different signal from visibilitychange, and it does fire when Android's
// task switcher opens. saveGame() is idempotent, so an extra call is free
window.addEventListener("blur", saveGame);
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
var armT = null;
function disarm(){
// and the one that doesn't depend on being told anything: a second is the
// most a force-quit can now cost, which is well inside the delay nobody is
// charged for. A timer rather than a hook in loop(), because rAF stops when
// the page is hidden — exactly when this matters most.
setInterval(function(){ if(st.phase === RUN) saveGame(); }, 1000);
/* Reset is a hold, not a tap. It used to be two taps inside 2s, and a test
user found the button, tapped it once and gave up — which is exactly what
the old feedback invited, since a draining ring reads as "wait", not "press
me again". There is no wordless way to say "do that again"; there is a very
well-worn one for "keep holding", and letting go early springs the ring
back, which teaches the gesture without a word in the bar.
A stray 650ms hold is also far less likely than two stray taps in 2s, so
the live game it was guarding is better guarded than before. */
var HOLD_MS = 975; // keep in step with the .holding transition
var SLOP = 12; // px a press may drift and still count; also the panels'
var holdT = null, armT = null, holdDown = null;
function cancelHold(){
if(holdT){ clearTimeout(holdT); holdT = null; }
if(armT){ clearTimeout(armT); armT = null; }
holdDown = null;
btnReset.classList.remove("holding");
btnReset.classList.remove("armed");
btnReset.setAttribute("aria-label", "Reset clock");
btnReset.setAttribute("aria-label", "Reset clock, hold to confirm");
}
btnReset.addEventListener("pointerdown", function(ev){
ev.preventDefault();
audio(); // unlocking on the press is a valid gesture, and earlier is better
cancelHold();
holdDown = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
// so the lift still lands here if the finger drifts off the button
try{ btnReset.setPointerCapture(ev.pointerId); }catch(e){}
btnReset.classList.add("holding");
holdT = setTimeout(function(){
cancelHold(); // before, not after: resetAll() hides this button
resetAll();
}, HOLD_MS);
});
btnReset.addEventListener("pointermove", function(ev){
var d = holdDown;
if(!d || ev.pointerId !== d.id) return;
// a finger that has travelled this far was on its way somewhere else
if(Math.abs(ev.clientX - d.x) > SLOP || Math.abs(ev.clientY - d.y) > SLOP) cancelHold();
});
function endHold(ev){
if(holdDown && ev.pointerId !== holdDown.id) return;
cancelHold(); // the spring back is the whole lesson
}
btnReset.addEventListener("pointerup", endHold);
btnReset.addEventListener("pointercancel", endHold);
/* The keyboard and switch path, which can't hold: two presses inside 2s, as
the whole button used to work. detail is 0 only for activation that didn't
come from a pointer — touch and mouse were dealt with above, and the click
trailing their press must not arm anything. */
function armReset(){
if(armT){ disarm(); resetAll(); return; } // second tap inside the window
if(armT){ cancelHold(); resetAll(); return; } // second press inside the window
btnReset.classList.remove("armed");
void btnReset.offsetWidth; // restart the drain animation
btnReset.classList.add("armed");
btnReset.setAttribute("aria-label", "Tap again to reset");
armT = setTimeout(disarm, 2000);
btnReset.setAttribute("aria-label", "Press again to reset");
armT = setTimeout(cancelHold, 2000);
}
/* ============ theme ============ */
@@ -988,6 +1282,7 @@
var t = THEMES.filter(function(x){ return x.id === cfg.theme; })[0] || THEMES[0];
document.documentElement.style.setProperty("--accent", t.accent);
document.documentElement.style.setProperty("--accent-ink", t.ink);
document.documentElement.style.setProperty("--accent-mute", "rgba(0,0,0," + t.mute + ")");
[].forEach.call(document.querySelectorAll(".sw"), function(s){
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
});
@@ -1025,43 +1320,103 @@
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}
// the settings readouts, by side — the score sheet already owns the obvious
// v-you-min / v-them-min ids, and a side index is what data- is for here
function readout(name, who){
return document.querySelector('[data-v="' + name + '"][data-who="' + who + '"]');
}
var vals = [0, 1].map(function(who){
return { min: readout("min", who), sec: readout("sec", who), delay: readout("delay", who) };
});
// "5 × 3:00 = 15:00" for one side
function sum(i){
return cfg.points + " × " + clock(cfg.base[i]) + " = " + clock(matchTime(i));
}
function paintSettings(){
var s = Math.round(cfg.base / 1000);
document.getElementById("v-min").textContent = Math.floor(s / 60);
document.getElementById("v-sec").textContent = ("0" + (s % 60)).slice(-2);
document.getElementById("v-delay").textContent = Math.round(cfg.delay / 1000);
for(var i = 0; i < 2; i++){
var s = Math.round(cfg.base[i] / 1000);
vals[i].min.textContent = Math.floor(s / 60);
vals[i].sec.textContent = ("0" + (s % 60)).slice(-2);
vals[i].delay.textContent = Math.round(cfg.delay[i] / 1000);
}
document.getElementById("v-points").textContent = cfg.points;
document.getElementById("p-time").textContent = cfg.points === 1
? "Reserve time per point."
: "Reserve time per point. " + cfg.points + " × " + clock(cfg.base) +
" = " + clock(matchTime()) + " each.";
/* At a single point the match total is the per-point time, so there is no
sum to introduce and the colon would dangle. Split, the two sums are put
on their own line — together they are too long to sit after the label
without breaking mid-sum, which reads as one number split in half.
The newline needs white-space:pre-line on #p-time. */
document.getElementById("p-time").textContent =
cfg.points === 1 ? "Reserve time per point."
: cfg.split ? "Reserve time per point:\n" + sum(1) + " you, " + sum(0) + " them."
: "Reserve time per point: " + sum(1) + " each.";
paintSplit();
paintMatchChrome();
document.getElementById("t-sound").setAttribute("aria-checked", cfg.sound ? "true" : "false");
btnSound.setAttribute("aria-pressed", cfg.sound ? "true" : "false");
showIcon(btnSound, "on", cfg.sound);
showIcon(btnSound, "off", !cfg.sound);
paintPresets();
}
/* The presets can only mean one thing for both players, so they go while the
two sides can disagree. Everything else here is the sheet rearranging
itself; the clock never asks whether the setting is split. */
function paintSplit(){
sheet.classList.toggle("split", cfg.split);
document.getElementById("t-split").setAttribute("aria-checked", cfg.split ? "true" : "false");
[].forEach.call(document.querySelectorAll("[data-them-row]"), function(n){
if(cfg.split) n.removeAttribute("hidden"); else n.setAttribute("hidden", "");
});
document.getElementById("row-presets").hidden = cfg.split;
}
/* highlight is derived from cfg, so it clears when you step away from a
preset and comes back by itself when you step onto one */
preset and comes back by itself when you step onto one. Side 1 is enough:
the row is only on show while both sides hold the same numbers. */
function paintPresets(){
[].forEach.call(document.querySelectorAll(".preset"), function(b){
var on = (+b.dataset.base === cfg.base && +b.dataset.presetDelay === cfg.delay);
var on = (+b.dataset.base === cfg.base[1] && +b.dataset.presetDelay === cfg.delay[1]);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
/* the one writer for both timers. Shared mode is kept as an equality rather
than a special case: write one side, write the other. */
function setTimer(which, who, ms){
cfg[which][who] = clampTimer(which, ms);
if(!cfg.split) cfg[which][1 - who] = cfg[which][who];
saveTimers();
paintSettings(); resetClocks();
}
function saveTimers(){
store.set("bg.base", String(cfg.base[1]));
store.set("bg.delay", String(cfg.delay[1]));
store.set("bg.base.them", String(cfg.base[0]));
store.set("bg.delay.them", String(cfg.delay[0]));
}
[].forEach.call(document.querySelectorAll(".preset"), function(b){
b.addEventListener("click", function(){
cfg.base = parseInt(b.dataset.base, 10);
cfg.delay = parseInt(b.dataset.presetDelay, 10);
store.set("bg.base", String(cfg.base));
store.set("bg.delay", String(cfg.delay));
// only reachable while shared, so both sides take the preset
var base = parseInt(b.dataset.base, 10), d = parseInt(b.dataset.presetDelay, 10);
cfg.base = [base, base];
cfg.delay = [d, d];
saveTimers();
paintSettings(); resetClocks();
});
});
document.getElementById("t-split").addEventListener("click", function(){
cfg.split = !cfg.split;
// the same line both ways: Them copies You going in, You wins coming out
cfg.base[0] = cfg.base[1];
cfg.delay[0] = cfg.delay[1];
store.set("bg.split", cfg.split ? "1" : "0");
saveTimers();
paintSettings(); resetClocks();
});
[].forEach.call(document.querySelectorAll("[data-points-adj]"), function(b){
b.addEventListener("click", function(){
// stepping by 2 from 1 keeps it odd without any parity juggling
@@ -1075,18 +1430,14 @@
[].forEach.call(document.querySelectorAll("[data-time-adj]"), function(b){
b.addEventListener("click", function(){
var next = cfg.base + parseInt(b.dataset.timeAdj, 10) * 1000;
cfg.base = Math.min(99 * 60000, Math.max(15000, next));
store.set("bg.base", String(cfg.base));
paintSettings(); resetClocks();
var who = parseInt(b.dataset.who, 10);
setTimer("base", who, cfg.base[who] + parseInt(b.dataset.timeAdj, 10) * 1000);
});
});
[].forEach.call(document.querySelectorAll("[data-delay-adj]"), function(b){
b.addEventListener("click", function(){
var next = cfg.delay + parseInt(b.dataset.delayAdj, 10) * 1000;
cfg.delay = Math.min(60000, Math.max(0, next));
store.set("bg.delay", String(cfg.delay));
paintSettings(); resetClocks();
var who = parseInt(b.dataset.who, 10);
setTimer("delay", who, cfg.delay[who] + parseInt(b.dataset.delayAdj, 10) * 1000);
});
});
@@ -1096,7 +1447,6 @@
paintSettings();
if(on){ audio(); sndTick(); }
}
document.getElementById("t-sound").addEventListener("click", function(){ setSound(!cfg.sound); });
btnSound.addEventListener("click", function(){ setSound(!cfg.sound); });
/* ============ sheets ============ */
@@ -1114,7 +1464,7 @@
// can reach the timer controls once the match is under way
if(!matchPristine()) return;
pauseNow(); // nobody's clock runs while a sheet is open
disarm();
cancelHold();
paintSettings();
openSheet(sheet);
});
@@ -1147,6 +1497,10 @@
document.getElementById("v-" + p[1] + "-min").textContent = Math.floor(s / 60);
document.getElementById("v-" + p[1] + "-sec").textContent = ("0" + (s % 60)).slice(-2);
});
// the sheet only opens on a match of 3 points or more, so there is always a
// target worth naming
document.getElementById("p-score-hint").textContent =
"Changing the score will start a new game within this match to " + cfg.points + ".";
}
function openScore(){
@@ -1154,7 +1508,7 @@
// set themselves survives
resumeOnClose = (st.phase === RUN);
pauseNow();
disarm();
cancelHold();
// seed from what the panels are showing, not from the banked reserve — for
// the interrupted turn those differ by the delay already spent
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
@@ -1209,7 +1563,10 @@
closeScore();
});
btnReset.addEventListener("click", armReset);
btnReset.addEventListener("click", function(ev){
if(ev.detail !== 0) return; // the tail of a press we already handled
armReset();
});
btnPlay.addEventListener("click", togglePause);
/* ============ panel taps ============ */
@@ -1217,8 +1574,8 @@
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
// bottom panel owns that edge, and starting a swipe home is a finger landing.
// A swipe travels past SLOP, or the system claims it and we get pointercancel
// instead of a lift. Either way there's no turn change.
var SLOP = 12; // px of travel a press may drift and still count
// instead of a lift. Either way there's no turn change. (SLOP is declared
// with the reset hold, which measures drift the same way.)
var wantFullscreen = true;
panels.forEach(function(p){
var down = null;
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v17";
const CACHE = "bgclock-v23";
const FILES = [
"./",
"./index.html",
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Regenerate the five F-Droid phone screenshots from public_html/index.html.
python3 tools/screenshots.py
They are rendered rather than photographed, so they can be redone whenever the
UI changes. Output is 1170x2532 — 390x844 CSS pixels at device scale 3, an
iPhone-sized frame that F-Droid is happy with.
Near-exact, not exact: two runs of identical code differ by a 4x13 pixel sliver
at the delay bar's fill edge, where the scaleX boundary lands mid-pixel and the
rasteriser rounds it differently. Invisible, but it means a non-empty git diff
after re-running proves nothing on its own.
Three things bite, and all three fail quietly rather than loudly:
1. The capture lands when Chromium's virtual-time budget runs out, not when
this script finishes. So the page is frozen deliberately — the app's render
loop is `render(); requestAnimationFrame(loop)`, and replacing
requestAnimationFrame after the first synchronous render pins the frame.
2. CSS transitions don't advance under --virtual-time-budget. A panel caught
mid-transition photographs in its *old* colour, so transitions are disabled
outright in the copy being shot.
3. Snap-confined Chromium cannot write into hidden directories — not ~/.cache,
not a dot-directory anywhere, and not the private /tmp it gets given. It
fails with a bare "Permission denied" and no hint. Both the HTML it reads
and the PNG it writes therefore live in a plain directory in the repo.
The scenes are set up by seeding localStorage before the app boots, so the app
renders its own state from its own save format rather than having the DOM
poked from outside. Only the final clicks (open a sheet, start the clock) are
driven through the real buttons.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "public_html" / "index.html"
OUT = ROOT / "fastlane" / "metadata" / "android" / "en-US" / "images" / "phoneScreenshots"
WORK = ROOT / "build-screenshots" # plain name: see note 3 above
WIDTH, HEIGHT, SCALE = 390, 844, 3 # -> 1170x2532
CHROMIUM = ("chromium-browser", "chromium", "google-chrome", "google-chrome-stable")
# st.phase in the app; a running clock is saved as PAUSE, because time that
# passes while the app is dead can't be charged to anyone
IDLE, RUN, PAUSE, FLAG = 0, 1, 2, 3
MIN = 60_000
# A five-point match, mid-game, from the bottom player's side. Panel i shows
# score[i] as "You" and score[1-i] as "Them", so one array serves both ends.
MATCH = dict(
cfg=dict(points=5, base=3 * MIN, delay=12_000),
game=dict(score=[1, 2], reserve=[14 * MIN, 13 * MIN + 15_000],
moves=[7, 6], active=1, held=5_000, phase=PAUSE),
)
SCENES = [
# 1 — the clock itself, mid-match, delay running down on the bottom player
dict(name="1", click="playpause", **MATCH),
# 2 — the score sheet, where a finished game is written down
dict(name="2", click="score", **MATCH),
# 3 — the settings sheet. It only opens on a pristine match (the app refuses
# once a game is under way), so this one starts from a fresh state and
# seeds no saved game at all.
dict(name="3", click="settings",
cfg=dict(points=5, base=3 * MIN, delay=12_000), game=None),
# 4 — a single game: no match score, so the score lines aren't there
dict(name="4", click="playpause",
cfg=dict(points=1, base=3 * MIN, delay=12_000),
game=dict(score=[0, 0], reserve=[3 * MIN, 3 * MIN],
moves=[4, 3], active=1, held=5_000, phase=PAUSE)),
# 5 — a flagged clock: the top player's time is gone
dict(name="5", click=None,
cfg=dict(points=5, base=3 * MIN, delay=12_000),
game=dict(score=[1, 2], reserve=[0, 42_000],
moves=[7, 6], active=0, held=0, phase=FLAG)),
]
# Runs in <head>, before the app's own script, so the app boots into this state
# and renders it itself.
SEED = """
<script>
(function(){
try{ localStorage.clear(); }catch(e){}
var seed = %s;
for(var k in seed) localStorage.setItem(k, seed[k]);
})();
</script>
"""
# Runs after the app's script, which ends with render(); loop().
FREEZE = """
<style>
/* transitions don't advance under --virtual-time-budget, so a panel caught
mid-transition would photograph in its old colour */
*, *::before, *::after{transition:none !important; animation:none !important}
</style>
<script>
(function(){
// the first render has already happened synchronously; pin it
window.requestAnimationFrame = function(){ return 0; };
var click = %s;
if(click){
var b = document.getElementById(click);
if(!b) throw new Error("no such button: " + click);
b.click();
}
document.title = "shot-ready";
})();
</script>
"""
def find_chromium():
for name in CHROMIUM:
path = shutil.which(name)
if path:
return path
sys.exit("no chromium found; tried: " + ", ".join(CHROMIUM))
def build_page(scene, source):
"""A copy of the app with the scene seeded and the render loop pinned."""
seed = {
"bg.points": str(scene["cfg"]["points"]),
"bg.base": str(scene["cfg"]["base"]),
"bg.delay": str(scene["cfg"]["delay"]),
"bg.theme": scene["cfg"].get("theme", "sage"),
"bg.sound": "1",
}
if scene["game"] is not None:
seed["bg.game"] = json.dumps(dict(v=1, **scene["game"]))
html = source.replace("</head>", SEED % json.dumps(seed) + "</head>", 1)
html = html.replace("</body>", FREEZE % json.dumps(scene["click"]) + "</body>", 1)
return html
def main():
chromium = find_chromium()
source = SOURCE.read_text()
WORK.mkdir(exist_ok=True)
try:
for scene in SCENES:
page = WORK / ("scene-%s.html" % scene["name"])
page.write_text(build_page(scene, source))
target = OUT / ("%s.png" % scene["name"])
shot = WORK / target.name
subprocess.run([
chromium,
"--headless", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
"--window-size=%d,%d" % (WIDTH, HEIGHT),
"--force-device-scale-factor=%d" % SCALE,
"--virtual-time-budget=4000",
"--screenshot=%s" % shot,
page.as_uri(),
], check=True, capture_output=True)
if not shot.exists():
sys.exit("chromium wrote nothing for scene %s" % scene["name"])
shutil.move(str(shot), str(target))
print("%s %d bytes" % (target.relative_to(ROOT), target.stat().st_size))
finally:
shutil.rmtree(WORK, ignore_errors=True)
if __name__ == "__main__":
main()