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>
178 lines
6.4 KiB
Python
178 lines
6.4 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, not photographed, so they reproduce exactly and can be
|
|
redone whenever the UI changes. Output is 1170x2532 — 390x844 CSS pixels at
|
|
device scale 3, an iPhone-sized frame that F-Droid is happy with.
|
|
|
|
Three things bite, and all three fail quietly rather than loudly:
|
|
|
|
1. The capture lands when Chromium's virtual-time budget runs out, not when
|
|
this script finishes. So the page is frozen deliberately — the app's render
|
|
loop is `render(); requestAnimationFrame(loop)`, and replacing
|
|
requestAnimationFrame after the first synchronous render pins the frame.
|
|
|
|
2. CSS transitions don't advance under --virtual-time-budget. A panel caught
|
|
mid-transition photographs in its *old* colour, so transitions are disabled
|
|
outright in the copy being shot.
|
|
|
|
3. Snap-confined Chromium cannot write into hidden directories — not ~/.cache,
|
|
not a dot-directory anywhere, and not the private /tmp it gets given. It
|
|
fails with a bare "Permission denied" and no hint. Both the HTML it reads
|
|
and the PNG it writes therefore live in a plain directory in the repo.
|
|
|
|
The scenes are set up by seeding localStorage before the app boots, so the app
|
|
renders its own state from its own save format rather than having the DOM
|
|
poked from outside. Only the final clicks (open a sheet, start the clock) are
|
|
driven through the real buttons.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SOURCE = ROOT / "public_html" / "index.html"
|
|
OUT = ROOT / "fastlane" / "metadata" / "android" / "en-US" / "images" / "phoneScreenshots"
|
|
WORK = ROOT / "build-screenshots" # plain name: see note 3 above
|
|
|
|
WIDTH, HEIGHT, SCALE = 390, 844, 3 # -> 1170x2532
|
|
|
|
CHROMIUM = ("chromium-browser", "chromium", "google-chrome", "google-chrome-stable")
|
|
|
|
# st.phase in the app; a running clock is saved as PAUSE, because time that
|
|
# passes while the app is dead can't be charged to anyone
|
|
IDLE, RUN, PAUSE, FLAG = 0, 1, 2, 3
|
|
|
|
MIN = 60_000
|
|
|
|
# A five-point match, mid-game, from the bottom player's side. Panel i shows
|
|
# score[i] as "You" and score[1-i] as "Them", so one array serves both ends.
|
|
MATCH = dict(
|
|
cfg=dict(points=5, base=3 * MIN, delay=12_000),
|
|
game=dict(score=[1, 2], reserve=[14 * MIN, 13 * MIN + 15_000],
|
|
moves=[7, 6], active=1, held=5_000, phase=PAUSE),
|
|
)
|
|
|
|
SCENES = [
|
|
# 1 — the clock itself, mid-match, delay running down on the bottom player
|
|
dict(name="1", click="playpause", **MATCH),
|
|
|
|
# 2 — the score sheet, where a finished game is written down
|
|
dict(name="2", click="score", **MATCH),
|
|
|
|
# 3 — the settings sheet. It only opens on a pristine match (the app refuses
|
|
# once a game is under way), so this one starts from a fresh state and
|
|
# seeds no saved game at all.
|
|
dict(name="3", click="settings",
|
|
cfg=dict(points=5, base=3 * MIN, delay=12_000), game=None),
|
|
|
|
# 4 — a single game: no match score, so the score lines aren't there
|
|
dict(name="4", click="playpause",
|
|
cfg=dict(points=1, base=3 * MIN, delay=12_000),
|
|
game=dict(score=[0, 0], reserve=[3 * MIN, 3 * MIN],
|
|
moves=[4, 3], active=1, held=5_000, phase=PAUSE)),
|
|
|
|
# 5 — a flagged clock: the top player's time is gone
|
|
dict(name="5", click=None,
|
|
cfg=dict(points=5, base=3 * MIN, delay=12_000),
|
|
game=dict(score=[1, 2], reserve=[0, 42_000],
|
|
moves=[7, 6], active=0, held=0, phase=FLAG)),
|
|
]
|
|
|
|
# Runs in <head>, before the app's own script, so the app boots into this state
|
|
# and renders it itself.
|
|
SEED = """
|
|
<script>
|
|
(function(){
|
|
try{ localStorage.clear(); }catch(e){}
|
|
var seed = %s;
|
|
for(var k in seed) localStorage.setItem(k, seed[k]);
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
# Runs after the app's script, which ends with render(); loop().
|
|
FREEZE = """
|
|
<style>
|
|
/* transitions don't advance under --virtual-time-budget, so a panel caught
|
|
mid-transition would photograph in its old colour */
|
|
*, *::before, *::after{transition:none !important; animation:none !important}
|
|
</style>
|
|
<script>
|
|
(function(){
|
|
// the first render has already happened synchronously; pin it
|
|
window.requestAnimationFrame = function(){ return 0; };
|
|
var click = %s;
|
|
if(click){
|
|
var b = document.getElementById(click);
|
|
if(!b) throw new Error("no such button: " + click);
|
|
b.click();
|
|
}
|
|
document.title = "shot-ready";
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
|
|
def find_chromium():
|
|
for name in CHROMIUM:
|
|
path = shutil.which(name)
|
|
if path:
|
|
return path
|
|
sys.exit("no chromium found; tried: " + ", ".join(CHROMIUM))
|
|
|
|
|
|
def build_page(scene, source):
|
|
"""A copy of the app with the scene seeded and the render loop pinned."""
|
|
seed = {
|
|
"bg.points": str(scene["cfg"]["points"]),
|
|
"bg.base": str(scene["cfg"]["base"]),
|
|
"bg.delay": str(scene["cfg"]["delay"]),
|
|
"bg.theme": scene["cfg"].get("theme", "sage"),
|
|
"bg.sound": "1",
|
|
}
|
|
if scene["game"] is not None:
|
|
seed["bg.game"] = json.dumps(dict(v=1, **scene["game"]))
|
|
|
|
html = source.replace("</head>", SEED % json.dumps(seed) + "</head>", 1)
|
|
html = html.replace("</body>", FREEZE % json.dumps(scene["click"]) + "</body>", 1)
|
|
return html
|
|
|
|
|
|
def main():
|
|
chromium = find_chromium()
|
|
source = SOURCE.read_text()
|
|
WORK.mkdir(exist_ok=True)
|
|
try:
|
|
for scene in SCENES:
|
|
page = WORK / ("scene-%s.html" % scene["name"])
|
|
page.write_text(build_page(scene, source))
|
|
target = OUT / ("%s.png" % scene["name"])
|
|
shot = WORK / target.name
|
|
|
|
subprocess.run([
|
|
chromium,
|
|
"--headless", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
|
|
"--window-size=%d,%d" % (WIDTH, HEIGHT),
|
|
"--force-device-scale-factor=%d" % SCALE,
|
|
"--virtual-time-budget=4000",
|
|
"--screenshot=%s" % shot,
|
|
page.as_uri(),
|
|
], check=True, capture_output=True)
|
|
|
|
if not shot.exists():
|
|
sys.exit("chromium wrote nothing for scene %s" % scene["name"])
|
|
shutil.move(str(shot), str(target))
|
|
print("%s %d bytes" % (target.relative_to(ROOT), target.stat().st_size))
|
|
finally:
|
|
shutil.rmtree(WORK, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|