Konubinix' opinionated web of thoughts

Simple Pwa Karate Beep Trainer

Fleeting

When 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.

Notes linking here