Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31087223a | ||
|
|
404fdb5538 |
@@ -6,3 +6,6 @@ android/local.properties
|
|||||||
# editor leftovers
|
# editor leftovers
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# python leftovers
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -110,10 +110,22 @@ 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, 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
|
- 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 +133,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
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "nl.hansdezwart.bgclock"
|
applicationId = "nl.hansdezwart.bgclock"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 2
|
versionCode = 3
|
||||||
versionName = "1.1"
|
versionName = "1.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Improved the contrast to be compliant with accessibility guidelines.
|
||||||
|
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">
|
<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 ---------- */
|
||||||
@@ -584,12 +599,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 = {
|
||||||
@@ -988,6 +1008,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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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-v18";
|
||||||
const FILES = [
|
const FILES = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|||||||
@@ -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()
|
||||||