6 Commits
Author SHA1 Message Date
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
15 changed files with 437 additions and 49 deletions
+3
View File
@@ -6,3 +6,6 @@ android/local.properties
# editor leftovers # editor leftovers
*.swp *.swp
*~ *~
# python leftovers
__pycache__/
+35 -4
View File
@@ -61,6 +61,14 @@ 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 should come back, paused. That is the check that proves the app's storage is
working, and it fails silently rather than loudly. 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 ## 5. Commit, tag, push
```bash ```bash
@@ -110,10 +118,29 @@ Separate from all of the above, and manual: upload the six files in
## If the screenshots need redoing ## If the screenshots need redoing
`fastlane/metadata/android/en-US/images/phoneScreenshots/` holds five shots at ```bash
1170×2532. They are generated from `public_html/index.html` itself with headless python3 tools/screenshots.py
Chromium, not taken on a phone, so they can be regenerated exactly. Two traps if ```
you do it by hand:
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 - The capture lands whenever the virtual-time budget runs out, not when your
script finishes — so freeze the render loop (`requestAnimationFrame = () => 0`) script finishes — so freeze the render loop (`requestAnimationFrame = () => 0`)
@@ -121,6 +148,10 @@ you do it by hand:
- CSS transitions don't advance under `--virtual-time-budget`, so a panel caught - CSS transitions don't advance under `--virtual-time-budget`, so a panel caught
mid-transition photographs in its *old* colour. Disable transitions in the mid-transition photographs in its *old* colour. Disable transitions in the
screenshot build. 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 ## Reference
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "nl.hansdezwart.bgclock" applicationId = "nl.hansdezwart.bgclock"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 5
versionName = "1.1" versionName = "1.4"
} }
compileOptions { compileOptions {
@@ -121,8 +121,15 @@ public class MainActivity extends Activity {
} }
} }
// 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 @@
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.
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

+196 -40
View File
@@ -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 -1
View File
@@ -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",
+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()