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>
This commit is contained in:
2026-08-15 13:41:40 +02:00
co-authored by Claude Opus 5
parent ac144f2eb1
commit 404fdb5538
10 changed files with 237 additions and 20 deletions
+3
View File
@@ -6,3 +6,6 @@ android/local.properties
# editor leftovers
*.swp
*~
# python leftovers
__pycache__/
+20 -4
View File
@@ -110,10 +110,22 @@ 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, not taken on
a phone, so they reproduce exactly — anything that changes the look of the app
is a reason to re-run it.
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 +133,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
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

+36 -15
View File
@@ -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 ---------- */
@@ -584,12 +599,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 = {
@@ -988,6 +1008,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");
});
+1 -1
View File
@@ -1,5 +1,5 @@
/* Cache-first: once installed the clock never touches the network again. */
const CACHE = "bgclock-v17";
const CACHE = "bgclock-v18";
const FILES = [
"./",
"./index.html",
+177
View File
@@ -0,0 +1,177 @@
#!/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()