7 Commits
Author SHA1 Message Date
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
17 changed files with 409 additions and 42 deletions
+3
View File
@@ -6,3 +6,6 @@ android/local.properties
# editor leftovers
*.swp
*~
# python leftovers
__pycache__/
+4
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.
+150
View File
@@ -0,0 +1,150 @@
# 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.
## 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, not taken on
a phone, so they reproduce exactly — anything that changes the look of the app
is a reason to re-run it.
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 = 3
versionName = "1.2"
}
compileOptions {
@@ -95,23 +95,29 @@ 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);
}
}
@@ -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.
@@ -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: 178 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: 77 KiB

@@ -1 +1 @@
A flexible two-player backgammon match clock with a visible delay.
A flexible two-player backgammon match clock and scorekeeper.
+36 -15
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,9 +169,17 @@
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;
}
/* ---------- control bar ---------- */
@@ -584,12 +599,17 @@
// 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 }
];
var cfg = {
@@ -988,6 +1008,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");
});
+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-v18";
const FILES = [
"./",
"./index.html",
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Regenerate the five F-Droid phone screenshots from public_html/index.html.
python3 tools/screenshots.py
They are rendered, not photographed, so they reproduce exactly and 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.
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()