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>
183 lines
6.6 KiB
Python
183 lines
6.6 KiB
Python
#!/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()
|