Make the clock behave on iOS

Two real bugs, both worst on iOS:

The frame was taller than the screen in Safari. .app declared
`height:100dvh;height:100vh`, so the fallback won and 100vh on iOS is the
large viewport — the control bar and part of the bottom panel sat behind
the toolbar. The declarations are now in the right order.

The wake lock never came back. iOS drops it whenever the app backgrounds,
but `lock` was never cleared, so the `!lock` guard blocked every later
re-request and the screen started sleeping mid-game. It now listens for
`release` and clears the handle; against a stubbed API the old code stays
at one request after a release-and-return, the new code makes a second.

Smaller iOS fixes: claim a playback audio session so the ringer switch
doesn't silence the clock (this pauses other audio — the README says how
to drop it), resume the audio context when returning from the background,
suppress the long-press callout, keep sheet scrolling from rubber-banding
the app, back 88dvh with 88vh, and name the home-screen icon.

Untested on real hardware — there's no WebKit engine here, so this is a
code audit plus a Chromium regression run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:53:56 +02:00
co-authored by Claude Opus 5
parent 0274e10d4c
commit 247eb0cf04
3 changed files with 53 additions and 9 deletions
+21 -3
View File
@@ -15,12 +15,29 @@ GitHub Pages is free and takes about two minutes:
2. Upload all six files to the root: `index.html`, `manifest.webmanifest`, 2. Upload all six files to the root: `index.html`, `manifest.webmanifest`,
`sw.js`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`. `sw.js`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`.
3. **Settings → Pages → Source: Deploy from a branch → `main` / `(root)` → Save.** 3. **Settings → Pages → Source: Deploy from a branch → `main` / `(root)` → Save.**
4. Wait a minute, then open `https://<you>.github.io/bgclock/` in Chrome on Android. 4. Wait a minute, then open `https://<you>.github.io/bgclock/` on your phone.
5. Chrome menu (⋮) → **Add to Home screen****Install**. 5. **Android, Chrome:** menu (⋮) → **Add to Home screen****Install**.
**iOS, Safari:** Share (□↑) → **Add to Home Screen****Add**. It has to be
Safari — Chrome and Firefox on iOS can't install a web app.
Open it from the home-screen icon and it runs fullscreen with no browser Open it from the home-screen icon and it runs fullscreen with no browser
chrome, offline, with the screen kept awake while a game is on. chrome, offline, with the screen kept awake while a game is on.
On iOS a few things behave differently, none of them fatal:
- **Install from Safari, and play from the home-screen icon.** In a Safari tab
you lose a strip of the screen to the toolbar, and iOS clears the saved
settings of a site you haven't visited for a week. The installed app keeps
its own storage and doesn't get pruned that way.
- **There's no vibration** — iOS gives web pages no haptics at all, so the
turn-change buzz is silent there. The sounds still play.
- **The ringer switch doesn't mute the clock.** The page claims a playback
audio session so you can hear the delay tick with the phone silenced, which
in exchange pauses any music you had going. If you'd rather keep the music,
delete the `navigator.audioSession` line in `index.html`.
- **Screen wake needs iOS 16.4 or newer.** Below that the screen dims on its
usual schedule mid-game.
If you're using a different path than `/bgclock/`, edit the `"id"` field in If you're using a different path than `/bgclock/`, edit the `"id"` field in
`manifest.webmanifest` to match, or just delete that line. `manifest.webmanifest` to match, or just delete that line.
@@ -62,7 +79,8 @@ Brass is the colour in the chess.com app, if you want the exact original look.
Changing time or delay resets the clock. Settings are kept in `localStorage`, so Changing time or delay resets the clock. Settings are kept in `localStorage`, so
they survive closing the tab, force-quitting the installed app and rebooting the they survive closing the tab, force-quitting the installed app and rebooting the
phone — the app also asks for persistent storage so they aren't evicted when the phone — the app also asks for persistent storage so they aren't evicted when the
device runs low on space. Only the settings are stored; a game in progress isn't. device runs low on space (Chrome honours that request; Safari ignores it). Only
the settings are stored; a game in progress isn't.
## Sounds ## Sounds
+31 -5
View File
@@ -7,6 +7,7 @@
<meta name="theme-color" content="#332e2b"> <meta name="theme-color" content="#332e2b">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="BG Clock">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon-192.png"> <link rel="icon" href="icon-192.png">
@@ -40,13 +41,16 @@
font-feature-settings:"tnum" 1; font-feature-settings:"tnum" 1;
-webkit-tap-highlight-color:transparent; -webkit-tap-highlight-color:transparent;
-webkit-user-select:none;user-select:none; -webkit-user-select:none;user-select:none;
-webkit-touch-callout:none; /* no iOS long-press callout on a panel */
overscroll-behavior:none; overscroll-behavior:none;
touch-action:manipulation; touch-action:manipulation;
} }
/* ---------- frame ---------- */ /* ---------- frame ---------- */
.app{ .app{
height:100dvh;height:100vh; height:100vh;height:100dvh; /* dvh must win — 100vh on iOS Safari is the
large viewport, so the bar and the bottom
panel end up under the toolbar */
display:grid; display:grid;
grid-template-rows:1fr auto 1fr; grid-template-rows:1fr auto 1fr;
} }
@@ -193,7 +197,8 @@
padding:8px 22px max(22px,env(safe-area-inset-bottom)); padding:8px 22px max(22px,env(safe-area-inset-bottom));
transform:translateY(101%); transform:translateY(101%);
transition:transform .26s cubic-bezier(.32,.72,0,1); transition:transform .26s cubic-bezier(.32,.72,0,1);
max-height:88dvh;overflow-y:auto; max-height:88vh;max-height:88dvh;overflow-y:auto;
overscroll-behavior:contain; /* scrolling the sheet can't rubber-band the app */
box-shadow:0 -18px 50px rgba(0,0,0,.45); box-shadow:0 -18px 50px rgba(0,0,0,.45);
} }
.sheet.on{transform:translateY(0)} .sheet.on{transform:translateY(0)}
@@ -478,8 +483,18 @@
if(ac.state === "suspended") ac.resume(); if(ac.state === "suspended") ac.resume();
return ac; return ac;
} }
// iOS silences Web Audio through the ringer switch unless the page claims a
// playback session. Trade-off: it also interrupts whatever else is playing,
// so it's only claimed the first time the clock actually makes a sound.
var claimedSession = false;
function claimAudioSession(){
if(claimedSession) return;
claimedSession = true;
try{ if(navigator.audioSession) navigator.audioSession.type = "playback"; }catch(e){}
}
function tone(freq, at, dur, peak, type, partial){ function tone(freq, at, dur, peak, type, partial){
var c = audio(); if(!c) return; var c = audio(); if(!c) return;
claimAudioSession();
var t0 = c.currentTime + at; var t0 = c.currentTime + at;
var o = c.createOscillator(), g = c.createGain(); var o = c.createOscillator(), g = c.createGain();
o.type = type || "sine"; o.type = type || "sine";
@@ -773,11 +788,22 @@
/* ============ keep the screen awake ============ */ /* ============ keep the screen awake ============ */
var lock = null; var lock = null;
function keepAwake(){ function keepAwake(){
if(!navigator.wakeLock) return; if(!navigator.wakeLock || lock) return;
navigator.wakeLock.request("screen").then(function(l){ lock = l; }).catch(function(){}); if(document.visibilityState !== "visible") return; // request() rejects when hidden
navigator.wakeLock.request("screen").then(function(l){
lock = l;
// the OS drops the lock whenever the app goes to the background; without
// clearing it here the stale handle blocks every later re-request
l.addEventListener("release", function(){ lock = null; });
}).catch(function(){});
} }
document.addEventListener("visibilitychange", function(){ document.addEventListener("visibilitychange", function(){
if(document.visibilityState === "visible" && !lock) keepAwake(); if(document.visibilityState !== "visible") return;
keepAwake();
// iOS interrupts the audio context on backgrounding; nudge it back so the
// flag still sounds even if nobody has tapped since returning
// (old webkitAudioContext.resume() returns undefined, so don't chain on it)
if(ac && ac.state === "suspended"){ try{ ac.resume(); }catch(e){} }
}); });
keepAwake(); keepAwake();
+1 -1
View File
@@ -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-v2"; const CACHE = "bgclock-v3";
const FILES = [ const FILES = [
"./", "./",
"./index.html", "./index.html",