Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f1b246679 | ||
|
|
3a7eb8c612 | ||
|
|
bf3f565dd0 | ||
|
|
ec1a65c89c | ||
|
|
b31087223a | ||
|
|
404fdb5538 |
@@ -6,3 +6,6 @@ android/local.properties
|
||||
# editor leftovers
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# python leftovers
|
||||
__pycache__/
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -110,10 +118,29 @@ Separate from all of the above, and manual: upload the six files in
|
||||
|
||||
## If the screenshots need redoing
|
||||
|
||||
`fastlane/metadata/android/en-US/images/phoneScreenshots/` holds five shots at
|
||||
1170×2532. They are generated from `public_html/index.html` itself with headless
|
||||
Chromium, not taken on a phone, so they can be regenerated exactly. Two traps if
|
||||
you do it by hand:
|
||||
```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`)
|
||||
@@ -121,6 +148,10 @@ you do it by hand:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "nl.hansdezwart.bgclock"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "1.1"
|
||||
versionCode = 5
|
||||
versionName = "1.4"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
|
||||
@@ -121,8 +121,15 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
// forwarded so the page's visibilitychange fires — that is what calls
|
||||
// saveGame() and nudges the audio context back after backgrounding
|
||||
// Forwarded so the WebView stops its timers, animations and audio while the
|
||||
// app is in the background, and picks them up again on return.
|
||||
//
|
||||
// This does NOT drive the page's visibilitychange — an earlier comment here
|
||||
// claimed it did, and the saved match quietly depended on that being true.
|
||||
// Document visibility follows the WebView's view and window visibility, not
|
||||
// this call, so the page can be killed without ever hearing about it. The
|
||||
// page no longer relies on being told: it writes the match to localStorage
|
||||
// once a second while a clock is running.
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
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.
|
||||
|
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 |
@@ -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 ---------- */
|
||||
@@ -194,26 +209,73 @@
|
||||
.btn--vacant{visibility:hidden}
|
||||
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
||||
|
||||
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
|
||||
/* Reset confirms two different ways, because the two input methods can't share
|
||||
one. A finger holds the button and the ring fills under it; lifting early
|
||||
springs the ring back, which is the only way to say "keep going" without
|
||||
words — and the bar carries no words by design, since it is shared by two
|
||||
players sitting opposite each other. Keyboard and assistive activation
|
||||
can't hold, so they keep the older two-presses-within-2s, where the ring
|
||||
drains to show the window closing. */
|
||||
#reset{position:relative}
|
||||
#reset.armed{color:var(--icon-hot)}
|
||||
#reset.armed,#reset.holding{color:var(--icon-hot)}
|
||||
#reset .ring{
|
||||
position:absolute;
|
||||
top:50%;left:50%;
|
||||
width:clamp(36px,9.6vw,48px);height:clamp(36px,9.6vw,48px);
|
||||
/* As big as the bar will take, so a thumb can't cover it: the button is the
|
||||
bar's full height, leaving 4px of dark above and below. width:auto is
|
||||
load-bearing — .btn svg sets a width for the icons, and inheriting it here
|
||||
would give the ring that width against this height and draw an ellipse.
|
||||
It ends up a few px wider than the button's own slot, which is only ever
|
||||
empty space; the neighbouring glyph stays a good 12px clear. */
|
||||
height:calc(100% - 8px);
|
||||
width:auto;
|
||||
aspect-ratio:1;
|
||||
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
|
||||
display:none;
|
||||
opacity:0;
|
||||
/* Hiding waits out the spring-back, then fades. display:none here would cut
|
||||
the retreat off at the instant the finger lifts, and that retreat is the
|
||||
whole reason the gesture teaches itself. The 120ms is a real duration on
|
||||
purpose: a 0s transition with a delay is allowed to be treated as no
|
||||
transition at all, which would take the delay with it. */
|
||||
transition:opacity 120ms linear 180ms;
|
||||
pointer-events:none;
|
||||
}
|
||||
#reset.armed .ring{display:block}
|
||||
/* appearing is instant — no duration, no delay */
|
||||
#reset.armed .ring,#reset.holding .ring{opacity:1;transition:none}
|
||||
|
||||
#reset .ring circle{
|
||||
fill:none;
|
||||
stroke:currentColor; /* the same flash the buttons use */
|
||||
stroke-width:2.6;
|
||||
/* user units, so it scales with the ring: 2 of 40 lands between 2.5 and
|
||||
3.4 real px across phone sizes, a shade heavier than the icon strokes
|
||||
because this one has to read past a thumb */
|
||||
stroke-width:2;
|
||||
}
|
||||
/* the whole circle, dim, there from the moment the finger lands: it shows how
|
||||
far there is to go, which an arc growing out of nothing cannot */
|
||||
#reset .ring-track{stroke:var(--icon)}
|
||||
/* and the bright arc that covers it, from 12 o'clock clockwise */
|
||||
#reset .ring-fill{
|
||||
stroke:var(--icon-hot);
|
||||
stroke-linecap:round;
|
||||
stroke-dasharray:113.1; /* 2πr, r=18 */
|
||||
animation:unwind 2s linear forwards;
|
||||
stroke-dashoffset:113.1; /* empty */
|
||||
/* The spring back. 180ms is the ceiling, not the figure: reversing a
|
||||
transition part-way shortens it in proportion, so letting go at 41% takes
|
||||
74ms, and the arc never spends longer retreating than it spent filling.
|
||||
The dim track outlasts it either way — the ring holds for 180ms and then
|
||||
fades, so even a stray tap shows a whole circle appear and go, which is
|
||||
what says there was something to finish. */
|
||||
transition:stroke-dashoffset 180ms ease-out;
|
||||
}
|
||||
#reset.holding .ring-fill{
|
||||
stroke-dashoffset:0;
|
||||
transition-duration:975ms; /* keep in step with HOLD_MS */
|
||||
transition-timing-function:linear;
|
||||
}
|
||||
/* the keyboard path: a full ring draining over the 2s window. An animation
|
||||
beats a transition on the same property, which is fine here only because
|
||||
.armed and .holding are never set at once. */
|
||||
#reset.armed .ring-fill{animation:unwind 2s linear forwards}
|
||||
@keyframes unwind{
|
||||
from{stroke-dashoffset:0}
|
||||
to{stroke-dashoffset:113.1}
|
||||
@@ -347,6 +409,13 @@
|
||||
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
*{transition-duration:.01ms!important}
|
||||
/* ...except the reset ring, which reports how far through the hold you are.
|
||||
Collapsing it would fill the ring the instant you touched the button and
|
||||
tell you it was done 650ms before it was. This one is state, not
|
||||
decoration. Higher specificity than the * above, so it wins despite both
|
||||
being !important. */
|
||||
#reset .ring-fill{transition-duration:180ms!important}
|
||||
#reset.holding .ring-fill{transition-duration:975ms!important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -372,8 +441,12 @@
|
||||
</section>
|
||||
|
||||
<div class="bar">
|
||||
<button class="btn" id="reset" aria-label="Reset clock">
|
||||
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
|
||||
<button class="btn" id="reset" aria-label="Reset clock, hold to confirm">
|
||||
<!-- ring-track/ring-fill, not track/fill: those two are the delay bar's -->
|
||||
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<circle class="ring-track" cx="20" cy="20" r="18"/>
|
||||
<circle class="ring-fill" cx="20" cy="20" r="18"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
|
||||
</svg>
|
||||
@@ -584,12 +657,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 = {
|
||||
@@ -697,6 +775,15 @@
|
||||
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
|
||||
tone(440, 0.32, 0.55, 0.26, "triangle", true);
|
||||
}
|
||||
// the flag's mirror: the same triad rising, and quicker — a fresh start.
|
||||
// Deliberately not another rising fifth; sndSwap owns that shape and plays on
|
||||
// every single handover, so nothing else may sound like it.
|
||||
function sndReset(){
|
||||
if(!cfg.sound) return;
|
||||
tone(523.25, 0.00, 0.16, 0.20, "triangle", false);
|
||||
tone(659.25, 0.07, 0.16, 0.20, "triangle", false);
|
||||
tone(783.99, 0.14, 0.40, 0.22, "triangle", true);
|
||||
}
|
||||
|
||||
/* ============ helpers ============ */
|
||||
function elapsed(){
|
||||
@@ -905,16 +992,19 @@
|
||||
|
||||
// clocks back to full for the match length; the score is not this function's business
|
||||
function resetClocks(){
|
||||
disarm();
|
||||
cancelHold();
|
||||
st.reserve = [matchTime(), matchTime()];
|
||||
newGame();
|
||||
saveGame();
|
||||
}
|
||||
|
||||
// the Reset button — a whole new match
|
||||
// the Reset button — a whole new match. The sound lives here rather than in
|
||||
// resetClocks(), which the settings sheet also calls: changing the match
|
||||
// length shouldn't sound like a reset.
|
||||
function resetAll(){
|
||||
st.score = [0, 0];
|
||||
resetClocks();
|
||||
sndReset();
|
||||
}
|
||||
|
||||
function clampScore(){
|
||||
@@ -961,26 +1051,88 @@
|
||||
if(st.phase === PAUSE && st.active < 0) st.phase = IDLE; // can't be mid-turn with no turn
|
||||
}
|
||||
|
||||
// the only hooks iOS reliably fires before killing an app
|
||||
// The hooks iOS fires before killing an app. Android's WebView is the problem
|
||||
// child: neither of these is guaranteed there, whatever MainActivity's
|
||||
// onPause() forwarding suggests, and when they don't fire the newest save is
|
||||
// the one tap() wrote — which carries held ≈ 0, because tap() sets turnAt and
|
||||
// saves in the same breath. Reopening then hands back the entire turn, and
|
||||
// the bug hides in plain sight: quit inside the delay window and a clock that
|
||||
// correctly hasn't moved looks exactly like a clock that lost its place.
|
||||
document.addEventListener("visibilitychange", function(){
|
||||
if(document.visibilityState === "hidden") saveGame();
|
||||
});
|
||||
window.addEventListener("pagehide", saveGame);
|
||||
// a different signal from visibilitychange, and it does fire when Android's
|
||||
// task switcher opens. saveGame() is idempotent, so an extra call is free
|
||||
window.addEventListener("blur", saveGame);
|
||||
|
||||
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
|
||||
var armT = null;
|
||||
function disarm(){
|
||||
// and the one that doesn't depend on being told anything: a second is the
|
||||
// most a force-quit can now cost, which is well inside the delay nobody is
|
||||
// charged for. A timer rather than a hook in loop(), because rAF stops when
|
||||
// the page is hidden — exactly when this matters most.
|
||||
setInterval(function(){ if(st.phase === RUN) saveGame(); }, 1000);
|
||||
|
||||
/* Reset is a hold, not a tap. It used to be two taps inside 2s, and a test
|
||||
user found the button, tapped it once and gave up — which is exactly what
|
||||
the old feedback invited, since a draining ring reads as "wait", not "press
|
||||
me again". There is no wordless way to say "do that again"; there is a very
|
||||
well-worn one for "keep holding", and letting go early springs the ring
|
||||
back, which teaches the gesture without a word in the bar.
|
||||
|
||||
A stray 650ms hold is also far less likely than two stray taps in 2s, so
|
||||
the live game it was guarding is better guarded than before. */
|
||||
var HOLD_MS = 975; // keep in step with the .holding transition
|
||||
var SLOP = 12; // px a press may drift and still count; also the panels'
|
||||
var holdT = null, armT = null, holdDown = null;
|
||||
|
||||
function cancelHold(){
|
||||
if(holdT){ clearTimeout(holdT); holdT = null; }
|
||||
if(armT){ clearTimeout(armT); armT = null; }
|
||||
holdDown = null;
|
||||
btnReset.classList.remove("holding");
|
||||
btnReset.classList.remove("armed");
|
||||
btnReset.setAttribute("aria-label", "Reset clock");
|
||||
btnReset.setAttribute("aria-label", "Reset clock, hold to confirm");
|
||||
}
|
||||
|
||||
btnReset.addEventListener("pointerdown", function(ev){
|
||||
ev.preventDefault();
|
||||
audio(); // unlocking on the press is a valid gesture, and earlier is better
|
||||
cancelHold();
|
||||
holdDown = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
||||
// so the lift still lands here if the finger drifts off the button
|
||||
try{ btnReset.setPointerCapture(ev.pointerId); }catch(e){}
|
||||
btnReset.classList.add("holding");
|
||||
holdT = setTimeout(function(){
|
||||
cancelHold(); // before, not after: resetAll() hides this button
|
||||
resetAll();
|
||||
}, HOLD_MS);
|
||||
});
|
||||
|
||||
btnReset.addEventListener("pointermove", function(ev){
|
||||
var d = holdDown;
|
||||
if(!d || ev.pointerId !== d.id) return;
|
||||
// a finger that has travelled this far was on its way somewhere else
|
||||
if(Math.abs(ev.clientX - d.x) > SLOP || Math.abs(ev.clientY - d.y) > SLOP) cancelHold();
|
||||
});
|
||||
|
||||
function endHold(ev){
|
||||
if(holdDown && ev.pointerId !== holdDown.id) return;
|
||||
cancelHold(); // the spring back is the whole lesson
|
||||
}
|
||||
btnReset.addEventListener("pointerup", endHold);
|
||||
btnReset.addEventListener("pointercancel", endHold);
|
||||
|
||||
/* The keyboard and switch path, which can't hold: two presses inside 2s, as
|
||||
the whole button used to work. detail is 0 only for activation that didn't
|
||||
come from a pointer — touch and mouse were dealt with above, and the click
|
||||
trailing their press must not arm anything. */
|
||||
function armReset(){
|
||||
if(armT){ disarm(); resetAll(); return; } // second tap inside the window
|
||||
if(armT){ cancelHold(); resetAll(); return; } // second press inside the window
|
||||
btnReset.classList.remove("armed");
|
||||
void btnReset.offsetWidth; // restart the drain animation
|
||||
btnReset.classList.add("armed");
|
||||
btnReset.setAttribute("aria-label", "Tap again to reset");
|
||||
armT = setTimeout(disarm, 2000);
|
||||
btnReset.setAttribute("aria-label", "Press again to reset");
|
||||
armT = setTimeout(cancelHold, 2000);
|
||||
}
|
||||
|
||||
/* ============ theme ============ */
|
||||
@@ -988,6 +1140,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");
|
||||
});
|
||||
@@ -1114,7 +1267,7 @@
|
||||
// can reach the timer controls once the match is under way
|
||||
if(!matchPristine()) return;
|
||||
pauseNow(); // nobody's clock runs while a sheet is open
|
||||
disarm();
|
||||
cancelHold();
|
||||
paintSettings();
|
||||
openSheet(sheet);
|
||||
});
|
||||
@@ -1154,7 +1307,7 @@
|
||||
// set themselves survives
|
||||
resumeOnClose = (st.phase === RUN);
|
||||
pauseNow();
|
||||
disarm();
|
||||
cancelHold();
|
||||
// seed from what the panels are showing, not from the banked reserve — for
|
||||
// the interrupted turn those differ by the delay already spent
|
||||
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
||||
@@ -1209,7 +1362,10 @@
|
||||
closeScore();
|
||||
});
|
||||
|
||||
btnReset.addEventListener("click", armReset);
|
||||
btnReset.addEventListener("click", function(ev){
|
||||
if(ev.detail !== 0) return; // the tail of a press we already handled
|
||||
armReset();
|
||||
});
|
||||
btnPlay.addEventListener("click", togglePause);
|
||||
|
||||
/* ============ panel taps ============ */
|
||||
@@ -1217,8 +1373,8 @@
|
||||
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
||||
// bottom panel owns that edge, and starting a swipe home is a finger landing.
|
||||
// A swipe travels past SLOP, or the system claims it and we get pointercancel
|
||||
// instead of a lift. Either way there's no turn change.
|
||||
var SLOP = 12; // px of travel a press may drift and still count
|
||||
// instead of a lift. Either way there's no turn change. (SLOP is declared
|
||||
// with the reset hold, which measures drift the same way.)
|
||||
var wantFullscreen = true;
|
||||
panels.forEach(function(p){
|
||||
var down = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Cache-first: once installed the clock never touches the network again. */
|
||||
const CACHE = "bgclock-v17";
|
||||
const CACHE = "bgclock-v20";
|
||||
const FILES = [
|
||||
"./",
|
||||
"./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()
|
||||