Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f1b246679 | ||
|
|
3a7eb8c612 | ||
|
|
bf3f565dd0 | ||
|
|
ec1a65c89c | ||
|
|
b31087223a | ||
|
|
404fdb5538 | ||
|
|
ac144f2eb1 | ||
|
|
31fcaf8282 | ||
|
|
b6d8f4e234 | ||
|
|
862b029953 | ||
|
|
a672268a8f |
@@ -6,3 +6,6 @@ android/local.properties
|
|||||||
# editor leftovers
|
# editor leftovers
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# python leftovers
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -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.
|
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
|
## Getting it onto your phone
|
||||||
|
|
||||||
Chrome will only offer a real install over HTTPS, so the file needs a host.
|
Chrome will only offer a real install over HTTPS, so the file needs a host.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "nl.hansdezwart.bgclock"
|
applicationId = "nl.hansdezwart.bgclock"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 5
|
||||||
versionName = "1.0"
|
versionName = "1.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -95,28 +95,41 @@ public class MainActivity extends Activity {
|
|||||||
if (hasFocus) hideSystemBars();
|
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() {
|
private void hideSystemBars() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
WindowInsetsController c = getWindow().getInsetsController();
|
WindowInsetsController c = getWindow().getInsetsController();
|
||||||
if (c != null) {
|
if (c != null) {
|
||||||
c.hide(WindowInsets.Type.systemBars());
|
c.hide(WindowInsets.Type.statusBars());
|
||||||
c.setSystemBarsBehavior(
|
c.setSystemBarsBehavior(
|
||||||
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// no IMMERSIVE_STICKY or HIDE_NAVIGATION here: immersive only governs
|
||||||
|
// a hidden navigation bar, and this one stays
|
||||||
getWindow().getDecorView().setSystemUiVisibility(
|
getWindow().getDecorView().setSystemUiVisibility(
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
| View.SYSTEM_UI_FLAG_FULLSCREEN);
|
||||||
| View.SYSTEM_UI_FLAG_FULLSCREEN
|
|
||||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// forwarded so the page's visibilitychange fires — that is what calls
|
// Forwarded so the WebView stops its timers, animations and audio while the
|
||||||
// saveGame() and nudges the audio context back after backgrounding
|
// 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
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
super.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.
|
||||||
@@ -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.
|
Functional design is by Hans de Zwart, partially inspired by the open sourced Chess.com clock. Code is by Claude Opus.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 56 KiB |
|
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.
|
||||||
|
|||||||
@@ -14,16 +14,18 @@
|
|||||||
<link rel="apple-touch-icon" href="icon-192.png">
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--accent:#7E9A79;
|
--accent:#7C9777;
|
||||||
--accent-ink:#FFFFFF;
|
--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:#9A9A9A;
|
||||||
--idle-ink:#2E2E2E;
|
--idle-ink:#2E2E2E;
|
||||||
--idle-mute:rgba(0,0,0,.36);
|
--idle-mute:rgba(0,0,0,.63);
|
||||||
--bar:#332E2B;
|
--bar:#332E2B;
|
||||||
--icon:#8E8880;
|
--icon:#8E8880;
|
||||||
--icon-hot:#DFD9D1;
|
--icon-hot:#DFD9D1;
|
||||||
--flag:#A6564E;
|
--flag:#A6564E;
|
||||||
|
--flag-mute:rgba(255,255,255,.82);
|
||||||
--sheet:#232120;
|
--sheet:#232120;
|
||||||
--sheet-line:rgba(255,255,255,.09);
|
--sheet-line:rgba(255,255,255,.09);
|
||||||
--sheet-ink:#EDE9E4;
|
--sheet-ink:#EDE9E4;
|
||||||
@@ -90,7 +92,11 @@
|
|||||||
lines up while both measure exactly the same */
|
lines up while both measure exactly the same */
|
||||||
gap:3px 8px;
|
gap:3px 8px;
|
||||||
order:-1;
|
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;
|
font-weight:700;
|
||||||
letter-spacing:.01em;
|
letter-spacing:.01em;
|
||||||
color:var(--idle-mute);
|
color:var(--idle-mute);
|
||||||
@@ -103,9 +109,8 @@
|
|||||||
The guard drops the space when there's no score line above to separate from. */
|
The guard drops the space when there's no score line above to separate from. */
|
||||||
.line:not([hidden]) + .line--moves > *{margin-top:7px}
|
.line:not([hidden]) + .line--moves > *{margin-top:7px}
|
||||||
|
|
||||||
.panel[data-state="active"] .head,
|
.panel[data-state="active"] .head{color:var(--accent-mute)}
|
||||||
.panel[data-state="flagged"] .head{color:var(--accent-mute)}
|
.panel[data-state="flagged"] .head{color:var(--flag-mute)}
|
||||||
.panel[data-state="flagged"] .head{color:rgba(255,255,255,.55)}
|
|
||||||
|
|
||||||
.stack{
|
.stack{
|
||||||
flex:1;
|
flex:1;
|
||||||
@@ -151,7 +156,9 @@
|
|||||||
width:clamp(72px,20vw,140px);
|
width:clamp(72px,20vw,140px);
|
||||||
height:clamp(4px,1.1vw,7px);
|
height:clamp(4px,1.1vw,7px);
|
||||||
border-radius:99px;
|
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;
|
opacity:.45;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
}
|
}
|
||||||
@@ -162,9 +169,17 @@
|
|||||||
transform-origin:left center;
|
transform-origin:left center;
|
||||||
}
|
}
|
||||||
.dnum{
|
.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;
|
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 ---------- */
|
/* ---------- control bar ---------- */
|
||||||
@@ -194,26 +209,73 @@
|
|||||||
.btn--vacant{visibility:hidden}
|
.btn--vacant{visibility:hidden}
|
||||||
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
.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{position:relative}
|
||||||
#reset.armed{color:var(--icon-hot)}
|
#reset.armed,#reset.holding{color:var(--icon-hot)}
|
||||||
#reset .ring{
|
#reset .ring{
|
||||||
position:absolute;
|
position:absolute;
|
||||||
top:50%;left:50%;
|
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 */
|
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;
|
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{
|
#reset .ring circle{
|
||||||
fill:none;
|
fill:none;
|
||||||
stroke:currentColor; /* the same flash the buttons use */
|
/* user units, so it scales with the ring: 2 of 40 lands between 2.5 and
|
||||||
stroke-width:2.6;
|
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-linecap:round;
|
||||||
stroke-dasharray:113.1; /* 2πr, r=18 */
|
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{
|
@keyframes unwind{
|
||||||
from{stroke-dashoffset:0}
|
from{stroke-dashoffset:0}
|
||||||
to{stroke-dashoffset:113.1}
|
to{stroke-dashoffset:113.1}
|
||||||
@@ -347,6 +409,13 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion:reduce){
|
@media (prefers-reduced-motion:reduce){
|
||||||
*{transition-duration:.01ms!important}
|
*{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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -372,8 +441,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<button class="btn" id="reset" aria-label="Reset clock">
|
<button class="btn" id="reset" aria-label="Reset clock, hold to confirm">
|
||||||
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
|
<!-- 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">
|
<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"/>
|
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -584,12 +657,17 @@
|
|||||||
// ask the browser not to evict us when the device is low on space
|
// 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){}
|
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 = [
|
var THEMES = [
|
||||||
{ id:"sage", accent:"#7E9A79", ink:"#FFFFFF" },
|
{ id:"sage", accent:"#7C9777", ink:"#FFFFFF", mute:.66 },
|
||||||
{ id:"brass", accent:"#C3A153", ink:"#FFFFFF" },
|
{ id:"brass", accent:"#AA8C47", ink:"#FFFFFF", mute:.66 },
|
||||||
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF" },
|
{ id:"slate", accent:"#6E8598", ink:"#FFFFFF", mute:.72 },
|
||||||
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF" },
|
{ id:"teal", accent:"#5E8D87", ink:"#FFFFFF", mute:.71 },
|
||||||
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF" }
|
{ id:"plum", accent:"#8A7396", ink:"#FFFFFF", mute:.77 }
|
||||||
];
|
];
|
||||||
|
|
||||||
var cfg = {
|
var cfg = {
|
||||||
@@ -697,6 +775,15 @@
|
|||||||
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
|
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
|
||||||
tone(440, 0.32, 0.55, 0.26, "triangle", true);
|
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 ============ */
|
/* ============ helpers ============ */
|
||||||
function elapsed(){
|
function elapsed(){
|
||||||
@@ -905,16 +992,19 @@
|
|||||||
|
|
||||||
// clocks back to full for the match length; the score is not this function's business
|
// clocks back to full for the match length; the score is not this function's business
|
||||||
function resetClocks(){
|
function resetClocks(){
|
||||||
disarm();
|
cancelHold();
|
||||||
st.reserve = [matchTime(), matchTime()];
|
st.reserve = [matchTime(), matchTime()];
|
||||||
newGame();
|
newGame();
|
||||||
saveGame();
|
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(){
|
function resetAll(){
|
||||||
st.score = [0, 0];
|
st.score = [0, 0];
|
||||||
resetClocks();
|
resetClocks();
|
||||||
|
sndReset();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampScore(){
|
function clampScore(){
|
||||||
@@ -961,26 +1051,88 @@
|
|||||||
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
|
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(){
|
document.addEventListener("visibilitychange", function(){
|
||||||
if(document.visibilityState === "hidden") saveGame();
|
if(document.visibilityState === "hidden") saveGame();
|
||||||
});
|
});
|
||||||
window.addEventListener("pagehide", 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 */
|
// and the one that doesn't depend on being told anything: a second is the
|
||||||
var armT = null;
|
// most a force-quit can now cost, which is well inside the delay nobody is
|
||||||
function disarm(){
|
// 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; }
|
if(armT){ clearTimeout(armT); armT = null; }
|
||||||
|
holdDown = null;
|
||||||
|
btnReset.classList.remove("holding");
|
||||||
btnReset.classList.remove("armed");
|
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(){
|
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");
|
btnReset.classList.remove("armed");
|
||||||
void btnReset.offsetWidth; // restart the drain animation
|
void btnReset.offsetWidth; // restart the drain animation
|
||||||
btnReset.classList.add("armed");
|
btnReset.classList.add("armed");
|
||||||
btnReset.setAttribute("aria-label", "Tap again to reset");
|
btnReset.setAttribute("aria-label", "Press again to reset");
|
||||||
armT = setTimeout(disarm, 2000);
|
armT = setTimeout(cancelHold, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ theme ============ */
|
/* ============ theme ============ */
|
||||||
@@ -988,6 +1140,7 @@
|
|||||||
var t = THEMES.filter(function(x){ return x.id === cfg.theme; })[0] || THEMES[0];
|
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", t.accent);
|
||||||
document.documentElement.style.setProperty("--accent-ink", t.ink);
|
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){
|
[].forEach.call(document.querySelectorAll(".sw"), function(s){
|
||||||
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
|
s.setAttribute("aria-checked", s.dataset.id === cfg.theme ? "true" : "false");
|
||||||
});
|
});
|
||||||
@@ -1114,7 +1267,7 @@
|
|||||||
// can reach the timer controls once the match is under way
|
// can reach the timer controls once the match is under way
|
||||||
if(!matchPristine()) return;
|
if(!matchPristine()) return;
|
||||||
pauseNow(); // nobody's clock runs while a sheet is open
|
pauseNow(); // nobody's clock runs while a sheet is open
|
||||||
disarm();
|
cancelHold();
|
||||||
paintSettings();
|
paintSettings();
|
||||||
openSheet(sheet);
|
openSheet(sheet);
|
||||||
});
|
});
|
||||||
@@ -1154,7 +1307,7 @@
|
|||||||
// set themselves survives
|
// set themselves survives
|
||||||
resumeOnClose = (st.phase === RUN);
|
resumeOnClose = (st.phase === RUN);
|
||||||
pauseNow();
|
pauseNow();
|
||||||
disarm();
|
cancelHold();
|
||||||
// seed from what the panels are showing, not from the banked reserve — for
|
// seed from what the panels are showing, not from the banked reserve — for
|
||||||
// the interrupted turn those differ by the delay already spent
|
// the interrupted turn those differ by the delay already spent
|
||||||
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
||||||
@@ -1209,7 +1362,10 @@
|
|||||||
closeScore();
|
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);
|
btnPlay.addEventListener("click", togglePause);
|
||||||
|
|
||||||
/* ============ panel taps ============ */
|
/* ============ panel taps ============ */
|
||||||
@@ -1217,8 +1373,8 @@
|
|||||||
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
// 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.
|
// 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
|
// 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.
|
// instead of a lift. Either way there's no turn change. (SLOP is declared
|
||||||
var SLOP = 12; // px of travel a press may drift and still count
|
// with the reset hold, which measures drift the same way.)
|
||||||
var wantFullscreen = true;
|
var wantFullscreen = true;
|
||||||
panels.forEach(function(p){
|
panels.forEach(function(p){
|
||||||
var down = null;
|
var down = null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Cache-first: once installed the clock never touches the network again. */
|
/* Cache-first: once installed the clock never touches the network again. */
|
||||||
const CACHE = "bgclock-v17";
|
const CACHE = "bgclock-v20";
|
||||||
const FILES = [
|
const FILES = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|||||||
@@ -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()
|
||||||