Simple Pwa Karate Beep Trainer
FleetingWhen I train karate alone, I want to practise reacting to a signal that fires at random intervals — the way a partner calling out at random would. I often train alone, so this stands in for the partner.
It’s a phone that beeps a configurable number of times, each beep at a random moment inside a window I set — never closer than, say, 3 seconds (or I’d have no time to reset), never longer than 10. I punch on the beep and wait for the next one.
A small PWA — installable, runs entirely offline, no server and no accounts. All it stores is a couple of settings; the timer forgets itself when the round ends.
Live: https://sam.konubinix.eu/trainer/.
Choice of technology
This app has little to remember: three numbers — how many beeps, and the floor and ceiling of the window — plus a transient session that exists only while the timer runs and isn’t worth persisting.
For the render layer I reach for VanJS — about a kilobyte, no build step, loaded as one ES module from a CDN. I picked it because I was curious to try it. A reactive state cell is the source of truth, and the DOM is built from functions that read those cells; when a cell changes, VanJS re-runs only the function that read it.
The inputs are a few number fields and two buttons — plain elements, styled with a dark palette.
The one thing this app is built around is sound. The beep comes from the Web Audio API — a synthesised tone, no audio file to ship or cache. That choice carries one constraint, and it gets its own chapter (The beep).
Setting up the state
Two things on this screen change reactively: the validation message, and the running session. Those are the VanJS state cells — read them inside a render function and VanJS re-runs that function when they change.
const configError = van.state('');
const session = van.state(null);
The configuration deliberately isn’t a state cell. The form is
uncontrolled — I type into it freely and the app reads it only when I press
Start (the persistence in Configure the session writes it through on each
keystroke, but nothing on screen needs to redraw when it changes). Keeping it a
plain object means typing never triggers a render, and never steals focus from
the field under my thumb. It starts from whatever loadConfig (Open the app)
reads back from storage.
const config = loadConfig();
VanJS builds DOM from functions named after tags, so the setup line is the import plus the handful of tag builders the views use.
import van from 'vanjs-core';
const { header, main, h1, h2, p, div, section, form, label, input, button, ul, li } = van.tags;
The whole app mounts into one element.
<div id="app"></div>
The root view is the one place reactivity branches: a state-derived child that
reads session and shows the live run while one is going, the configuration
form otherwise. Because only this function reads session, only this subtree
is rebuilt when a beep lands or a run ends; the rest of the page is built once.
function App(){
return [
header({ class: 'app-bar' }, h1('Karate beep trainer')),
main({ class: 'screen' },
() => session.val && session.val.running ? RunningView() : IdleView()),
];
}
Boot mounts the view into #app and raises the data-app-ready flag the tests
wait on.
van.add(document.getElementById('app'), App());
document.body.setAttribute('data-app-ready', '1');
Visual basics
The same dark palette as the organiser, pinned to CSS custom properties so the
feature chapters reach for var(--accent) or var(--danger) by name. One
addition: a danger red for the Stop control, which is the only place in this
app where a colour has to read as “interrupt.”
:root{
--bg:#1b1d2e; --card:#262a40; --fg:#e8e8f0; --muted:#8a8ea5;
--accent:#f9a826; --danger:#e63946;
}
body{margin:0;background:var(--bg);color:var(--fg);
font-family:system-ui,sans-serif;line-height:1.4;min-height:100vh}
.app-bar{padding:12px 16px;border-bottom:1px solid #2a2d44;text-align:center}
.app-bar h1{font-size:1.2rem;margin:0;color:var(--fg)}
.screen{padding:8px 0 32px}
The palette and the bar, made concrete — the dark ground, the centred title,
and the six tokens every later chapter reaches for by name, each shown as the
colour it actually is. The CSS here is the very css-base block above, pulled
in by noweb so this illustration can never drift from it.
Open the app
The first time I open the app there is nothing running, so what I should land on is the thing I came to set: how many beeps, and the window they fall in, with a button to begin. An empty or ambiguous screen between rounds would cost me the few seconds I opened the app to save.
The test pins the landing: the three fields and the Start button are present,
and nothing has run yet. (clear_state, used here and in every later test,
wipes localStorage between cases — defined in Playwright tests.)
@testcase
def test_landing_shows_controls(page):
"""A fresh open lands on the config form with a Start button and no run yet."""
clear_state(page)
assert page.get_by_role("button", name="Start").is_visible(), \
"Start button not on the landing screen"
for label in ["Number of beeps", "Minimum delay (s)", "Maximum delay (s)"]:
assert page.get_by_label(label).is_visible(), \
f"{label!r} field missing from the landing screen"
assert page.locator(".gap").count() == 0, "a session ran before Start was pressed"
print(" PASS: landing shows controls")
The configuration the form edits has to survive a reload — I set my window
once and want it there next time (see Configuration persists). So the
config lives in localStorage under one key, behind a loader that falls back
to sensible defaults when nothing is stored yet. The defaults are the example
I started from: ten beeps, three to ten seconds apart.
const CONFIG_KEY = 'karate-trainer.config';
const DEFAULTS = { count: 10, min: 3, max: 10 };
function loadConfig(){
try {
const raw = localStorage.getItem(CONFIG_KEY);
if(raw) return { ...DEFAULTS, ...JSON.parse(raw) };
} catch(_){}
return { ...DEFAULTS };
}
function saveConfig(){
try { localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); } catch(_){}
}
The idle screen itself: the form, an error line when there is one to show, the Start button, and the previous session’s summary if a session has finished.
The form carries novalidate: the browser’s native constraint UI would
otherwise swallow a submit on any field it deems invalid — a count of zero, a
delay that isn’t a round step — and the press would reach nothing. That’s the
opposite of what I want, because validateConfig is the single authority on
what’s allowed and it speaks in plain language. So the delays accept any value
(step="any"), and every press reaches startSession. The inputs are
uncontrolled — VanJS sets their value once when the form is built (from the
stored config, so a reload shows what I left) and otherwise leaves my typing
alone.
function IdleView(){
const c = config;
const s = session.val;
const last = s && !s.running && s.done > 0 ? s : null;
return div(
form({ class: 'config', novalidate: true,
onsubmit: e => { e.preventDefault(); startSession(); } },
label('Number of beeps',
input({ type: 'number', min: '1', step: '1', value: c.count,
oninput: e => setConfigField('count', parseInt(e.target.value || '0', 10)) })),
label('Minimum delay (s)',
input({ type: 'number', min: '0', step: 'any', value: c.min,
oninput: e => setConfigField('min', parseFloat(e.target.value || '0')) })),
label('Maximum delay (s)',
input({ type: 'number', min: '0', step: 'any', value: c.max,
oninput: e => setConfigField('max', parseFloat(e.target.value || '0')) })),
() => configError.val ? p({ class: 'error', role: 'alert' }, configError.val) : '',
button({ type: 'submit', class: 'primary' }, 'Start'),
),
last ? sessionSummary(last) : '',
);
}
The form is a centred column of fields with one prominent button — the kind of thing I can hit without looking once I know where it is.
.config{display:grid;gap:12px;padding:16px;max-width:420px;margin:0 auto}
.config label{display:grid;gap:4px;font-size:.9rem;color:var(--muted)}
.config input{background:var(--card);color:var(--fg);border:1px solid #333;
border-radius:6px;padding:10px;font:inherit;font-size:1.1rem}
.error{color:var(--danger);margin:0;font-size:.9rem}
button.primary{background:var(--accent);color:#111;border:0;border-radius:8px;
padding:14px;font:inherit;font-weight:700;font-size:1.1rem;
cursor:pointer;width:100%}
The landing screen itself — try it: type into the fields, and the one prominent Start button waits at the bottom.
Configure the session
Editing a field has to do two things at once: hold the new value so the run
picks it up, and persist it so it’s there next time. Both go through one
setter, which also re-runs the validator so the error line tracks what I’m
typing — it shows when the window stops making sense and clears when I fix it.
It mutates the plain config object rather than a state cell — no redraw of
the inputs, as Setting up the state explained; only the reactive error line
redraws.
function setConfigField(field, value){
config[field] = value;
saveConfig();
configError.val = validateConfig(config);
}
A window only makes sense if it’s a window. Zero beeps is no session; a non-positive minimum is no wait; a maximum below the minimum is an empty range with nothing to draw from. Each of those is a configuration I want the app to refuse out loud rather than start into something incoherent — a silent no-op would leave me staring at a screen that did nothing.
The first rejection: a maximum below the minimum. The error is visible, and the session does not start.
@testcase
def test_max_below_min_rejected(page):
"""A maximum below the minimum is refused with a message, and nothing runs."""
clear_state(page)
set_config(page, 5, 5, 2)
page.get_by_role("button", name="Start").click()
assert page.get_by_role("alert").is_visible(), "no error shown for max < min"
assert page.get_attribute("body", "data-running") != "1", "a session started anyway"
assert page.locator(".gap").count() == 0, "beeps were scheduled despite the bad window"
print(" PASS: max below min rejected")
The second: a count below one. Same contract — refused with a message.
@testcase
def test_count_must_be_at_least_one(page):
"""A beep count below 1 is refused with a message."""
clear_state(page)
set_config(page, 0, 3, 10)
page.get_by_role("button", name="Start").click()
assert page.get_by_role("alert").is_visible(), "no error shown for count < 1"
assert page.get_attribute("body", "data-running") != "1", "a session started anyway"
print(" PASS: count must be at least one")
The validator returns the message to show, or an empty string when the configuration is sound.
function validateConfig(c){
if(!(c.count >= 1)) return 'Number of beeps must be at least 1.';
if(!(c.min > 0)) return 'Minimum delay must be greater than 0.';
if(!(c.max >= c.min)) return 'Maximum delay must be at least the minimum.';
return '';
}
The same validator runs on every edit, not only on Start, so the message appears the moment the window stops making sense and clears the moment I fix it — I don’t have to press Start to find out what’s wrong.
@testcase
def test_validation_appears_while_typing(page):
"""An invalid window shows its message as I type, before Start is pressed."""
clear_state(page)
set_config(page, 5, 5, 2)
assert page.get_by_role("alert").is_visible(), \
"no error shown while typing an invalid window"
assert page.get_attribute("body", "data-running") != "1", \
"a session started without Start"
print(" PASS: validation appears while typing")
A maximum below the minimum, refused out loud — the message in danger red, the session never started.
The beep
The beep is the whole point, and it’s the one thing a test can’t hear. So this chapter draws a line the rest of the document respects: the sound is synthesised and played, untested; what’s observable — that a beep fired, on schedule, the right number of times — is carried by the session state the next chapters assert against. A test that claimed to verify a tone would be lying; the honest seam is the trigger, not the waveform.
Browsers won’t let a page make noise until the user has interacted with it — an autoplay guard. The Start tap is that interaction, so the AudioContext is created (and resumed, in case it starts suspended) the moment a run begins. Everything is wrapped so that a browser without Web Audio, or a context that refuses to start, degrades to silence rather than breaking the run — the visual cue (The flash) still fires, and the schedule still advances.
let audioCtx = null;
function ensureAudio(){
try {
if(!audioCtx){
const Ctx = window.AudioContext || window.webkitAudioContext;
if(!Ctx) return null;
audioCtx = new Ctx();
}
if(audioCtx.state === 'suspended') audioCtx.resume();
} catch(_){ audioCtx = null; }
return audioCtx;
}
The beep itself is a short tone with a quick fade in and out — a hard on/off would click. An 880 Hz square wave cuts through a room and a body’s worth of ambient noise; a fifth of a second is long enough to register mid-movement without dragging.
function beep(){
const ctx = ensureAudio();
if(!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = 880;
osc.connect(gain);
gain.connect(ctx.destination);
const t = ctx.currentTime;
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.4, t + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
osc.start(t);
osc.stop(t + 0.2);
} catch(_){}
}
Run a session
Pressing Start should give me exactly the number of beeps I asked for and then stop — not one more, not a stream I have to kill. That count is the spine of the session: it’s what I assert here, and it’s the observable proxy for “a beep fired” that The beep left untested.
The test sets a small, fast window so the suite doesn’t sit through real
training pauses, asks for four beeps, and waits until four delays have been
recorded. Each beep records the gap that preceded it as a .gap row (the row
is built in Beeps fall inside the window); counting rows is the same as
counting beeps. When the run ends, the body’s data-running flag drops to
0 and the Start button is back.
@testcase
def test_start_emits_configured_count(page):
"""Start produces exactly the configured number of beeps, then stops."""
clear_state(page)
set_config(page, 4, 0.02, 0.06)
page.get_by_role("button", name="Start").click()
page.wait_for_function(
"n => document.querySelectorAll('.gap').length === n", arg=4, timeout=5000)
assert page.get_attribute("body", "data-running") == "0", \
"session still marked running after the last beep"
page.get_by_role("button", name="Start").wait_for(state="visible")
print(" PASS: start emits configured count")
A run is a chain of single-shot timers rather than one repeating interval,
because every gap is a fresh random draw — there’s no fixed period to repeat.
Starting validates the configuration first (refusing as Configure the
session requires), unlocks audio under the tap, seeds a fresh session, and
schedules the first beep. Replacing the session cell is what flips the root
view from form to run; data-running mirrors the session onto the body so a
test can wait on a real transition instead of a guessed delay.
function startSession(){
const error = validateConfig(config);
if(error){ configError.val = error; return; }
ensureAudio();
session.val = { running: true, done: 0, total: config.count, gaps: [], timer: null };
document.body.setAttribute('data-running', '1');
scheduleNext();
}
Scheduling is recursive: if the quota is met, finish; otherwise draw a gap,
wait it out, then beep. The beep step records the gap it just waited by
replacing the session cell with a new one carrying the incremented count and
the appended gap — VanJS reacts to the replacement, not to a mutation, so the
run view rebuilds. The guard at the top of the timer callback makes a Stop
pressed mid-wait final: a cancelled session that somehow still had a timer in
flight sees running false and does nothing.
function scheduleNext(){
const s = session.val;
if(!s || !s.running) return;
if(s.done >= s.total){ finishSession(); return; }
const gap = randomGap(config.min, config.max);
s.timer = setTimeout(() => {
const cur = session.val;
if(!cur || !cur.running) return;
beep();
flash();
session.val = { ...cur, done: cur.done + 1, gaps: [...cur.gaps, gap] };
scheduleNext();
}, gap);
}
Finishing flips the session out of its running state and lowers the flag; the root view then falls back to the idle screen, where the just-finished session shows up as a summary.
function finishSession(){
const s = session.val;
if(s) session.val = { ...s, running: false };
document.body.setAttribute('data-running', '0');
}
While a run is live the screen is stripped to what matters mid-movement: a
line telling me to listen, a big tally of beeps so far against the total, and
one button — Stop. The tally carries role“status”= so it’s announced, and
the running gaps list lets me glance at the rhythm I’m being given.
function RunningView(){
const s = session.val;
return div({ class: 'run' },
p({ class: 'hint' }, 'Listen — punch on the beep.'),
div({ class: 'count', role: 'status' }, `${s.done} / ${s.total}`),
button({ class: 'danger', onclick: () => stopSession() }, 'Stop'),
gapList(s),
);
}
.run{padding:16px;max-width:420px;margin:0 auto}
.hint{text-align:center;color:var(--muted);margin:0}
.count{font-size:4rem;font-weight:800;text-align:center;color:var(--accent);
margin:16px 0;font-variant-numeric:tabular-nums}
button.danger{background:var(--danger);color:#fff;border:0;border-radius:8px;
padding:14px;font:inherit;font-weight:700;font-size:1.1rem;
cursor:pointer;width:100%}
Mid-run, stripped to the tally and the one red Stop — press it and the run ends into its summary, just as it would on the mat.
The running view also has to actually appear when a run starts — the Stop control present, the status tally live — and give way to a summary of the run once it ends. A window slow enough to observe makes both halves checkable without racing the beeps.
@testcase
def test_running_view_then_summary(page):
"""While running the screen shows Stop and a live tally; when done it shows a summary."""
clear_state(page)
set_config(page, 3, 0.2, 0.35)
page.get_by_role("button", name="Start").click()
assert page.get_by_role("button", name="Stop").is_visible(), \
"Stop control not shown during a run"
assert page.get_by_role("status").is_visible(), "live tally not shown during a run"
page.get_by_role("heading", name="Last session").wait_for(state="visible", timeout=5000)
assert page.get_by_role("button", name="Start").is_visible(), \
"Start button didn't come back after the run"
print(" PASS: running view then summary")
Beeps fall inside the window
This is the contract that makes the trainer worth using: never closer than my
floor, never longer than my ceiling. Closer and I’d have no time to reset;
longer and my focus drifts out. Every gap the session waits has to land inside
[min, max].
Testing that against the wall clock would be a flake factory — timer jitter
alone would push a measured gap past its bound. What I assert instead is the
drawn value, the number the schedule actually committed to, which is exact
and jitter-free. Each beep’s row carries that value: the precise milliseconds
in a data-ms attribute for the test, and a friendly rounded reading for me.
The gap is something I genuinely experience — it’s the reaction window I’m
training — so surfacing it isn’t a test-only hook; it’s a feature the test
happens to read.
The test draws many gaps from a small window and checks every one lands inside it. It also checks the draws aren’t all identical — a window that always returned its floor would pass a bounds check while being useless.
@testcase
def test_gaps_fall_inside_window(page):
"""Every gap the session draws lies within the configured window, and they vary."""
clear_state(page)
set_config(page, 20, 0.03, 0.09)
page.get_by_role("button", name="Start").click()
page.wait_for_function(
"n => document.querySelectorAll('.gap').length === n", arg=20, timeout=8000)
gaps = page.eval_on_selector_all(
".gap", "els => els.map(e => Number(e.getAttribute('data-ms')))")
assert len(gaps) == 20, f"expected 20 gaps, got {len(gaps)}"
for ms in gaps:
assert 30 <= ms <= 90, f"gap {ms}ms fell outside the 30–90ms window"
assert len(set(gaps)) > 1, "every gap was identical — the draw isn't random"
print(" PASS: gaps fall inside the window")
The draw is a uniform random point between the two bounds, in milliseconds so the timer takes it directly. Rounding can land it exactly on either bound, which is what an inclusive window should allow.
function randomGap(minSeconds, maxSeconds){
const lo = minSeconds * 1000, hi = maxSeconds * 1000;
return Math.round(lo + Math.random() * (hi - lo));
}
The list shows one row per gap — its order in the run and its length, the
reaction window I was given. The friendly reading is rounded to a tenth of a
second; the exact draw rides along in data-ms. VanJS flattens the array of
rows into the list’s children directly.
function gapList(s){
return ul({ class: 'gaps', 'aria-label': 'Delays before each beep' },
s.gaps.map((ms, i) =>
li({ class: 'gap', 'data-ms': ms }, `${i + 1}. ${(ms / 1000).toFixed(1)}s`)));
}
.gaps{list-style:none;padding:0;margin:16px 0 0;display:flex;flex-direction:column;gap:4px}
.gap{background:var(--card);border-radius:6px;padding:8px 12px;color:var(--fg);
font-variant-numeric:tabular-nums}
Once a run is over, the same list is the heart of its summary — how many beeps, and the windows they came in, so I can see the rhythm I just trained against.
function sessionSummary(s){
return section({ class: 'summary' },
h2('Last session'),
p(`${s.done} beep${s.done > 1 ? 's' : ''}.`),
gapList(s));
}
.summary{padding:0 16px;max-width:420px;margin:16px auto 0}
.summary h2{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;
color:var(--muted);margin:0 0 4px}
.summary p{margin:0;color:var(--fg)}
A finished session, summarised — each row the reaction window I was given, the rhythm I just trained against laid out to read back.
Stop a session early
A round ends when I’m winded, not when the counter says so. Stop has to cut the session immediately: no further beep, even the one already counting down.
The test starts a long, slow run, waits for the first beep so a session is genuinely in flight, then presses Stop. It records how many beeps had landed, confirms the run is marked stopped and the Start button is back — then waits out longer than a full window and checks the count hasn’t moved. That wait is the point of the test: it’s the only way to prove the next beep was actually cancelled and not merely delayed, so it’s a deliberate measured wait past the ceiling, not a guess.
@testcase
def test_stop_ends_session_early(page):
"""Stop cancels the run at once — no further beep fires, not even the pending one."""
clear_state(page)
set_config(page, 50, 0.3, 0.45)
page.get_by_role("button", name="Start").click()
page.wait_for_function(
"() => document.querySelectorAll('.gap').length >= 1", timeout=5000)
page.get_by_role("button", name="Stop").click()
stopped_at = page.locator(".gap").count()
assert page.get_attribute("body", "data-running") == "0", "session not marked stopped"
assert page.get_by_role("button", name="Start").is_visible(), "Start didn't return after Stop"
# Wait past the ceiling (0.45s) so a not-cancelled beep would have fired.
time.sleep(0.7)
assert page.locator(".gap").count() == stopped_at, "a beep fired after Stop"
assert stopped_at < 50, "the run somehow completed instead of stopping early"
print(" PASS: stop ends session early")
Stop clears the pending timer and replaces the session cell with a stopped
one. The guard in scheduleNext’s callback covers the gap between clearTimeout
and a timer that might already be on the event-loop queue: even if it fires,
it reads the stopped session and does nothing.
function stopSession(){
const s = session.val;
if(s && s.timer) clearTimeout(s.timer);
if(s) session.val = { ...s, running: false };
document.body.setAttribute('data-running', '0');
}
The flash
Sound alone fails me in two real situations: a dojo loud enough to swallow the beep, and the times I train with the phone face-up on the mat in the edge of my vision. A brief full-screen flash on every beep covers both — peripheral vision catches a light flick even when ears don’t catch a tone, and it’s the fallback that still fires when audio is unavailable (cf. The beep).
Like the sound, the flash is a transient paint with nothing meaningful to assert — a test that tried to catch a quarter-second fade would be timing it, not verifying it. The beep count already proves the trigger fired; the flash rides the same trigger in scheduleNext, so it needs no test of its own.
Re-adding the animation class on each beep wouldn’t restart the animation —
the browser coalesces a remove-then-add in the same frame into a no-op. Strip
the class, force a reflow by reading offsetWidth, then add it back, and each
beep gets a clean flash; an animationend listener clears it so the next one
can re-trigger.
function flash(){
const el = document.getElementById('flash');
if(!el) return;
el.classList.remove('on');
void el.offsetWidth;
el.classList.add('on');
el.addEventListener('animationend', () => el.classList.remove('on'), { once: true });
}
The overlay covers the whole viewport, sits above everything, ignores pointer events so it never eats a tap, and is invisible until the class lands. The keyframe snaps to the accent colour and fades — a flick, not a strobe.
<div id="flash" aria-hidden="true"></div>
#flash{position:fixed;inset:0;background:var(--accent);opacity:0;
pointer-events:none;z-index:80}
#flash.on{animation:flash-fade .25s ease-out}
@keyframes flash-fade{0%{opacity:0}10%{opacity:.85}100%{opacity:0}}
The overlay at its peak — the accent colour thrown across the whole viewport, the flick peripheral vision catches when ears don’t.
Configuration persists
I set my window once. Re-typing three numbers every time I open the app
between rounds would defeat the point of having an app. The configuration is
already written to localStorage on every edit (Configure the session) and
read back at boot (Open the app); this chapter is the regression test that
the round-trip holds.
The test changes all three fields, reloads, and checks the fields come back
with what I left them — waiting on the same data-app-ready flag the rest of
the suite uses so it reads the form only once the loader has run.
@testcase
def test_config_persists_across_reload(page):
"""The window I set is still there after a reload."""
clear_state(page)
set_config(page, 7, 4, 8)
page.reload()
page.wait_for_selector("[data-app-ready]")
assert page.get_by_label("Number of beeps").input_value() == "7", "count not persisted"
assert page.get_by_label("Minimum delay (s)").input_value() == "4", "min not persisted"
assert page.get_by_label("Maximum delay (s)").input_value() == "8", "max not persisted"
print(" PASS: config persists across reload")
PWA shell
I want this on my home screen, launching full-screen and working in a dojo with no signal — the same three pieces that turned the organiser into an installable PWA, reused wholesale.
The manifest gives the OS the name, the standalone display mode, the dark theme colour, and an icon drawn inline as an SVG data-URL so there’s no separate asset to ship.
{
"name": "Karate beep trainer",
"short_name": "Beeps",
"description": "Random-interval beeps for reaction training.",
"start_url": ".",
"display": "standalone",
"background_color": "#1b1d2e",
"theme_color": "#1b1d2e",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='96' fill='%231b1d2e'/><text x='256' y='350' font-size='280' text-anchor='middle' fill='%23f9a826'>K</text></svg>",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
The service worker is the shared cache-first-with-write-through handler from shared blocks; all this app supplies is which files to cache and a cache name keyed to the build hash, so a deploy drops the stale cache on the next activation instead of leaving my phone on last week’s build.
const CACHES = [
{ name: 'trainer-nil' },
];
const ASSETS = ['./', './index.html', './app.js', './manifest.json'];
nil
A loading ring covers the gap between tapping the icon and the JS being
ready, so a cold launch never looks broken. It disappears the moment
data-app-ready flips on the body — the same signal the tests wait on.
<div id="loading">Loading…</div>
#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:var(--bg);color:var(--muted);z-index:9999}
body[data-app-ready] #loading{display:none}
The cold-launch ring, held on the screen before the JS takes over.
And the build tag in the corner, so after a deploy I can read at a glance which build the device actually picked up.
<span class="build-tag" title="Build">nil</span>
.build-tag{position:fixed;top:calc(env(safe-area-inset-top) + 8px);left:8px;
font-size:.65rem;color:var(--muted);font-family:monospace;
pointer-events:none;z-index:50}
Playwright tests
The per-feature tests need a runner. Two rules shape it, the same two the organiser settled on.
First, tests reach for the user-facing surface — visible text, accessible
roles, labelled inputs — over DOM internals. The two windows into internals
this suite does use (data-running and each gap’s data-ms) are things the
user experiences without a name for: the run being live, and the length of
each reaction window. No reaching into the state cells.
Second, no central registry of tests to forget to update. Each test registers
itself with a decorator at definition time, so it lands in TESTS next to its
feature and the runner finds it in source order.
nil
nil
Each test starts from a clean slate. The only persistent state is the config in
localStorage, so the reset wipes it after a first navigation (so there’s a
document to wipe against) and reloads so the loader boots against an empty
store, then waits on data-app-ready.
def clear_state(page):
page.goto(BASE_URL)
page.wait_for_selector("[data-app-ready]")
page.evaluate("() => localStorage.clear()")
page.goto(BASE_URL)
page.wait_for_selector("[data-app-ready]")
Most tests set the three fields before pressing Start, so that’s one helper.
Filling a field fires the oninput handler that both stores and persists the
value, exactly as a typing user would.
def set_config(page, count, min_s, max_s):
page.get_by_label("Number of beeps").fill(str(count))
page.get_by_label("Minimum delay (s)").fill(str(min_s))
page.get_by_label("Maximum delay (s)").fill(str(max_s))
The imports block does a little setup beyond importing. The Nix shell doesn’t
put the Playwright browsers on a path the loader knows, so it points
PLAYWRIGHT_BROWSERS_PATH at the playwright-browsers entry in buildInputs
unless the variable is already set. It pins a portrait phone viewport, since
the app is phone-first. And it names the dev slot’s served URL as the default
target.
import os, sys, time
nil
from playwright.sync_api import sync_playwright
BASE_URL = os.environ.get("TRAINER_URL", "http://localhost:9682/debug/trainer/")
PHONE_VIEWPORT = {"width": 400, "height": 800}
The runner mirrors the organiser’s: run the whole suite by default, filter by
name substring from positional args, -x to stop on first failure, --headed
to watch. A shared page is fine — every test resets through clear_state.
nil
Conclusion
The whole app is a window, a random draw inside it, a tone, and a count: about a kilobyte of VanJS over a plain config object and two state cells. The sound and the flash can’t be tested directly, so the tests stand on the one thing that is observable — that a beep fires, on schedule, the number of times I asked for.