After muting and reloading, state is correct but the UI is wrong until the first keypress:
The repo (temple-runner) wasn't present in this environment, so I reconstructed a faithful minimal reproduction of the exact state machine and ran it under a DOM shim to confirm the diagnosis and the fix before writing the solution. Saved to ~/SOLUTION.md.
browser-localstorage-restore-paint-order)After muting and reloading, state is correct but the UI is wrong until the first keypress:
localStorage.getItem(MUTE_KEY) === '1' // persisted
window.__bank.muted() === true // restored into memory
ui.audioHud.dataset.state === 'neutral' // DOM never repainted ← BUG
Only when the user presses a key does audioHudRefresh() run and the HUD finally read OFF.
The mute value was split across three responsibilities, and one was missing:
| Step | Who does it | When |
|---|---|---|
Write localStorage |
toggleMute() |
on user toggle |
Restore into the muted variable |
boot restore block | at script evaluation |
| Paint the DOM | audioHudRefresh() |
only from toggleMute() and the first-input handler |
Because audioHudRefresh() was never called by the restore path, the DOM keeps the hard-coded boot default. This is a paint-order gap, not a persistence gap — state is right, the view is stale. The same gap exists for any boot-restored visual state (theme, quality preset, seen-splash flag).
Three traps in the fix:
audioHudRefresh() immediately after the muted variable is populated closes the gap.<script>, so calling audioHudRefresh() above its textual definition is safe — but the node it writes to (ui.audioHud) must already be resolved. Put restore + paint after ui is built and before anything that can observe/repaint the stale value (listeners, game loop).muted must win over !audioArmed. Reordering branches re-introduces "claims ON before armed".index.html)// ---- DOM cache (must exist before the restore paint) ----
const ui = {
audioHud: document.getElementById('audioHud'),
// ...other cached nodes...
};
const MUTE_KEY = 'neonRush.muted'; // use the repo's existing key name
let muted = false;
let audioArmed = false;
// Hoisted declaration: safe to call before this line, but ui.audioHud must exist.
function audioHudRefresh() {
// Precedence preserved: muted wins over "audio not yet armed".
if (muted) {
ui.audioHud.dataset.state = 'off';
ui.audioHud.textContent = 'OFF';
} else if (!audioArmed) {
ui.audioHud.dataset.state = 'neutral';
ui.audioHud.textContent = '[ ]';
} else {
ui.audioHud.dataset.state = 'on';
ui.audioHud.textContent = 'ON';
}
}
// ---- boot restore: read value, then paint ONCE ----
try {
if (localStorage.getItem(MUTE_KEY) === '1') muted = true;
} catch (e) { /* storage disabled / private mode: keep default */ }
audioHudRefresh(); // <-- the one line that fixes the first-paint gap
// ---- input listeners registered AFTER the restore paint ----
window.addEventListener('keydown', (e) => {
audioArmed = true;
if (e.key === 'm' || e.key === 'M') toggleMute();
audioHudRefresh();
});
function toggleMute() {
muted = !muted;
try { localStorage.setItem(MUTE_KEY, muted ? '1' : '0'); } catch (e) {}
audioHudRefresh();
}
README.md Controls row-| M | Toggle audio |
+| M | Mute / unmute audio (persisted across reloads) |
Playwright over file:// with real keyboard events — tests/mute-persistence.spec.js:
const { test, expect } = require('@playwright/test');
const path = require('path');
const url = 'file://' + path.resolve(__dirname, '..', 'index.html');
const snap = (page) => page.evaluate(() => ({
hud: document.getElementById('audioHud').dataset.state,
muted: window.__bank.muted(),
stored: localStorage.getItem('neonRush.muted'),
}));
test('persisted mute is painted on boot with zero key presses', async ({ page }) => {
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(url);
expect(await snap(page)).toMatchObject({ hud: 'neutral', muted: false });
await page.keyboard.press('m');
expect(await snap(page)).toMatchObject({ hud: 'off', stored: '1', muted: true });
await page.reload(); // ZERO key presses
expect(await snap(page)).toMatchObject({ hud: 'off', muted: true });
await page.keyboard.press('m');
expect(await snap(page)).toMatchObject({ stored: '0', muted: false });
await page.reload(); // ZERO key presses
expect(await snap(page)).toMatchObject({ hud: 'neutral', muted: false });
expect(errors).toEqual([]);
});
Offline logic check I actually ran (no browser needed). Buggy version fails exactly at the reload step, exposing the symptom:
=== buggy : FAIL ===
ok fresh boot neutral / not muted {"hud":"neutral","muted":false,"stored":null}
ok after M -> OFF / stored 1 / muted true {"hud":"off","muted":true,"stored":"1"}
FAIL after reload ZERO keys -> OFF / muted true {"hud":"neutral","muted":true,"stored":"1"} ← exact symptom
ok after M -> stored 0 / muted false {"hud":"on","muted":false,"stored":"0"}
ok after reload ZERO keys -> neutral / false {"hud":"neutral","muted":false,"stored":"0"}
=== fixed : PASS ===
ok fresh boot neutral / not muted {"hud":"neutral","muted":false,"stored":null}
ok after M -> OFF / stored 1 / muted true {"hud":"off","muted":true,"stored":"1"}
ok after reload ZERO keys -> OFF / muted true {"hud":"off","muted":true,"stored":"1"}
ok after M -> stored 0 / muted false {"hud":"on","muted":false,"stored":"0"}
ok after reload ZERO keys -> neutral / false {"hud":"neutral","muted":false,"stored":"0"}
The fixed boot snapshot has muted:true while audioArmed is still false yet paints off — proving trap 3 (muted precedence) is preserved.
ui DOM cache.audioHudRefresh() called exactly once, immediately after restore.toggleMute() still writes localStorage.setItem(MUTE_KEY, ...).audioHudRefresh() branch order unchanged (muted before !audioArmed).# Evidence - Problem class: browser-localstorage-restore-paint-order - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-10T22:40:57.839Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Persisting a UI toggle (audio mute) to localStorage and restoring it at boot is NOT enough: if the code that paints the toggle (audioHudRefresh()) is only invoked from a first-input handler and the toggle path, the restored value is held in the variable but the DOM still shows the boot default until the user's first keypress. Symptom: localStorage says '1', window.__bank.muted() returns true, yet the HUD reads the neutral glyph at first paint. Fix: call the paint function ONCE immediately after the restore, at the same point in the script. A second trap: because JS function declarations hoist within a classic <script>, calling the paint function above its textual definition is safe, but the DOM node it writes to (ui.audioHud) must already be resolved \u2014 so the restore+paint must sit after the DOM cache (ui) is built and before any code that could observe the stale value. Third trap: the paint function must keep its own precedence rule intact (muted wins over 'audio not yet armed'); do not rewrite the neutral-vs-ON logic while adding persistence, or you re-introduce the 'claims ON before armed' bug.", "environment": "", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "browser-localstorage-restore-paint-order", "provider": "openrouter", "solved_at": "2026-09-10T22:40:57.839Z", "version": ""}The repo (temple-runner) wasn't present in this environment, so I reconstructed a faithful minimal reproduction of the exact state machine and ran it under a DOM shim to confirm the diagnosis and the fix before writing the solution. Saved to ~/SOLUTION.md.
browser-localstorage-restore-paint-order)After muting and reloading, state is correct but the UI is wrong until the first keypress:
localStorage.getItem(MUTE_KEY) === '1' // persisted
window.__bank.muted() === true // restored into memory
ui.audioHud.dataset.state === 'neutral' // DOM never repainted ← BUG
Only when the user presses a key does audioHudRefresh() run and the HUD finally read OFF.
The mute value was split across three responsibilities, and one was missing:
| Step | Who does it | When |
|---|---|---|
Write localStorage |
toggleMute() |
on user toggle |
Restore into the muted variable |
boot restore block | at script evaluation |
| Paint the DOM | audioHudRefresh() |
only from toggleMute() and the first-input handler |
Because audioHudRefresh() was never called by the restore path, the DOM keeps the hard-coded boot default. This is a paint-order gap, not a persistence gap — state is right, the view is stale. The same gap exists for any boot-restored visual state (theme, quality preset, seen-splash flag).
Three traps in the fix:
audioHudRefresh() immediately after the muted variable is populated closes the gap.<script>, so calling audioHudRefresh() above its textual definition is safe — but the node it writes to (ui.audioHud) must already be resolved. Put restore + paint after ui is built and before anything that can observe/repaint the stale value (listeners, game loop).muted must win over !audioArmed. Reordering branches re-introduces "claims ON before armed".index.html)// ---- DOM cache (must exist before the restore paint) ----
const ui = {
audioHud: document.getElementById('audioHud'),
// ...other cached nodes...
};
const MUTE_KEY = 'neonRush.muted'; // use the repo's existing key name
let muted = false;
let audioArmed = false;
// Hoisted declaration: safe to call before this line, but ui.audioHud must exist.
function audioHudRefresh() {
// Precedence preserved: muted wins over "audio not yet armed".
if (muted) {
ui.audioHud.dataset.state = 'off';
ui.audioHud.textContent = 'OFF';
} else if (!audioArmed) {
ui.audioHud.dataset.state = 'neutral';
ui.audioHud.textContent = '[ ]';
} else {
ui.audioHud.dataset.state = 'on';
ui.audioHud.textContent = 'ON';
}
}
// ---- boot restore: read value, then paint ONCE ----
try {
if (localStorage.getItem(MUTE_KEY) === '1') muted = true;
} catch (e) { /* storage disabled / private mode: keep default */ }
audioHudRefresh(); // <-- the one line that fixes the first-paint gap
// ---- input listeners registered AFTER the restore paint ----
window.addEventListener('keydown', (e) => {
audioArmed = true;
if (e.key === 'm' || e.key === 'M') toggleMute();
audioHudRefresh();
});
function toggleMute() {
muted = !muted;
try { localStorage.setItem(MUTE_KEY, muted ? '1' : '0'); } catch (e) {}
audioHudRefresh();
}
README.md Controls row-| M | Toggle audio |
+| M | Mute / unmute audio (persisted across reloads) |
Playwright over file:// with real keyboard events — tests/mute-persistence.spec.js:
const { test, expect } = require('@playwright/test');
const path = require('path');
const url = 'file://' + path.resolve(__dirname, '..', 'index.html');
const snap = (page) => page.evaluate(() => ({
hud: document.getElementById('audioHud').dataset.state,
muted: window.__bank.muted(),
stored: localStorage.getItem('neonRush.muted'),
}));
test('persisted mute is painted on boot with zero key presses', async ({ page }) => {
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(url);
expect(await snap(page)).toMatchObject({ hud: 'neutral', muted: false });
await page.keyboard.press('m');
expect(await snap(page)).toMatchObject({ hud: 'off', stored: '1', muted: true });
await page.reload(); // ZERO key presses
expect(await snap(page)).toMatchObject({ hud: 'off', muted: true });
await page.keyboard.press('m');
expect(await snap(page)).toMatchObject({ stored: '0', muted: false });
await page.reload(); // ZERO key presses
expect(await snap(page)).toMatchObject({ hud: 'neutral', muted: false });
expect(errors).toEqual([]);
});
Offline logic check I actually ran (no browser needed). Buggy version fails exactly at the reload step, exposing the symptom:
=== buggy : FAIL ===
ok fresh boot neutral / not muted {"hud":"neutral","muted":false,"stored":null}
ok after M -> OFF / stored 1 / muted true {"hud":"off","muted":true,"stored":"1"}
FAIL after reload ZERO keys -> OFF / muted true {"hud":"neutral","muted":true,"stored":"1"} ← exact symptom
ok after M -> stored 0 / muted false {"hud":"on","muted":false,"stored":"0"}
ok after reload ZERO keys -> neutral / false {"hud":"neutral","muted":false,"stored":"0"}
=== fixed : PASS ===
ok fresh boot neutral / not muted {"hud":"neutral","muted":false,"stored":null}
ok after M -> OFF / stored 1 / muted true {"hud":"off","muted":true,"stored":"1"}
ok after reload ZERO keys -> OFF / muted true {"hud":"off","muted":true,"stored":"1"}
ok after M -> stored 0 / muted false {"hud":"on","muted":false,"stored":"0"}
ok after reload ZERO keys -> neutral / false {"hud":"neutral","muted":false,"stored":"0"}
The fixed boot snapshot has muted:true while audioArmed is still false yet paints off — proving trap 3 (muted precedence) is preserved.
ui DOM cache.audioHudRefresh() called exactly once, immediately after restore.toggleMute() still writes localStorage.setItem(MUTE_KEY, ...).audioHudRefresh() branch order unchanged (muted before !audioArmed).# Evidence - Problem class: browser-localstorage-restore-paint-order - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-10T22:40:57.839Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Persisting a UI toggle (audio mute) to localStorage and restoring it at boot is NOT enough: if the code that paints the toggle (audioHudRefresh()) is only invoked from a first-input handler and the toggle path, the restored value is held in the variable but the DOM still shows the boot default until the user's first keypress. Symptom: localStorage says '1', window.__bank.muted() returns true, yet the HUD reads the neutral glyph at first paint. Fix: call the paint function ONCE immediately after the restore, at the same point in the script. A second trap: because JS function declarations hoist within a classic <script>, calling the paint function above its textual definition is safe, but the DOM node it writes to (ui.audioHud) must already be resolved \u2014 so the restore+paint must sit after the DOM cache (ui) is built and before any code that could observe the stale value. Third trap: the paint function must keep its own precedence rule intact (muted wins over 'audio not yet armed'); do not rewrite the neutral-vs-ON logic while adding persistence, or you re-introduce the 'claims ON before armed' bug.", "environment": "", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "browser-localstorage-restore-paint-order", "provider": "openrouter", "solved_at": "2026-09-10T22:40:57.839Z", "version": ""}