Skip to content

feat(pwa): the app installs to a home screen and opens with no network - #307

Merged
JanWelker merged 2 commits into
mainfrom
feat/pwa-installable
Sep 9, 2026
Merged

feat(pwa): the app installs to a home screen and opens with no network#307
JanWelker merged 2 commits into
mainfrom
feat/pwa-installable

Conversation

@JanWelker

@JanWelker JanWelker commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Closes the buildable half of #306, and documents why the other half is not buildable.

What this adds

Knead Time now installs from Safari's share sheet or Chrome's install prompt, opens standalone with its own icon and no browser chrome, and — once installed — opens with no network at all. That is what a job ticket you live inside for two days in a kitchen actually needs, and it is cheap here for the same reason the one-origin contract exists: the app fetches nothing from anywhere, so the whole of it is a finite list known at build time. Offline is a precache, not a strategy.

static/manifest.webmanifest name, icons, display: standalone
static/icon*.{svg,png} the two sources and the four rendered PNGs
src/app.html manifest + apple-touch-icon links, iOS meta
src/service-worker.ts precache of build + files + prerendered
scripts/render-icons.mjs re-renders the PNGs from the SVGs, by hand
e2e/pwa.spec.ts 9 new browser tests

Why there are no notifications

The issue's third and fifth bullets ask for Web Push and "a small backend/serverless piece". Both are unbuildable inside this app's contract, and the notification half is unbuildable client-side at all:

  • iOS suspends a backgrounded web app's JavaScript, so a setTimeout for a step eight hours out never runs.
  • It wakes a service worker for exactly one thing: an incoming push message — which needs a server to send.
  • Notification Triggers (TimestampTrigger/showTrigger), the API that would let a page hand the OS a "fire this at 06:40" instruction, was removed from Chromium and never existed in Safari.

So a reminder at 03:00 requires a backend, which CLAUDE.md lists as out of scope and which the one-origin promise exists to avoid. The .ics export — with the user's own calendar firing the alarm — is the only client-side path to an alert that fires with the app closed. The reasoning is written into app.html and service-worker.ts so it survives the next reading of the issue rather than being re-litigated.

Two traps worth reviewing

Both cost a debugging round and are now pinned in e2e/pwa.spec.ts:

  • cache.addAll is all-or-nothing by design — either the whole app is offline or none of it is, never half — so one unfetchable entry silently costs the entire precache, leaving an app that works perfectly in a tab and dies on a Home Screen. .nojekyll and CNAME are filtered out: they are instructions to GitHub Pages rather than assets, and vite preview (which the browser suite serves the real output with) refuses to serve dotfiles at all. This is what actually broke first.
  • Claim, but never skipWaiting(). Claiming is what makes the first visit work offline. Skipping the wait would swap the worker under a page that is already open — and this app is left open for two days: the running page would then ask the new worker for the old bundle's hashed filename, which the new cache does not have and the deploy has already deleted.

BASE_PATH

Every URL in the manifest is relative ("start_url": ".", "src": "icon-192.png") because they resolve against the manifest's own address — the same trick the hashed .woff2 files use, and it keeps BASE_PATH out of a file static/ copies verbatim. An absolute / would point every PR preview's installed app at the production root. Verified by building with BASE_PATH=/knead-time/pr-306: the head links resolve ./manifest.webmanifest from / and ../manifest.webmanifest from /print/en, and the worker derives its own base from location.pathname. A test asserts start_url and scope land on the app root rather than the domain root.

Icons

The mark is now the job ticket itself — a sheet standing on a solid block of ink offset down and right (the same out-of-register second pass every .card carries), dotted leaders walking to their figures, the stub's perforation, and one spot of tomato below the tear as the only colour. The bites punched out of the perforation are why the block is deliberately asymmetric: the left bite is wider than the ink beside it and punches through to the stock, the right one stays inside the block.

It is drawn as SVG rather than dropped in as a raster, so it stays crisp at 16 px and uses the press's own palette tokens. static/favicon.svg is gone — the tab, the Home Screen and the manifest all point at static/icon.svg, so the icon in a tab strip cannot drift from the one on a phone; a test pins the two hrefs equal.

static/icon-maskable.svg is the same drawing at scale(0.7). It is a rectangle, so its diagonal has to clear Android's safe circle, not its width: 336 × 416 is 535 corner to corner against the 410 a launcher guarantees, and 0.75 lands on exactly 410 where a round crop shaves the block's corners.

Both are rendered to PNG by node scripts/render-icons.mjs, which borrows Playwright's Chromium — already a devDependency — rather than adding a native rasteriser. The PNGs are committed, so no build or CI job depends on the script. The ground is a full-bleed rect because iOS composites a transparent icon onto black; a test reads the corner pixel to keep it that way.

Verification

  • npm run lint, npm run check, npm run build — clean
  • npm run test:coverage — 953 unit tests, 100 % lines/branches/functions/statements
  • npm run test:e2e136 passed (127 + 9 new), including self-hosted.spec.ts: the worker adds no cross-origin request and the app still arrives as one script and one stylesheet
  • .github/test-baseline.json raised to 136

Version bumped to 7.1.0 — minor: new user-facing feature, no URL-schema change, so v=7 links are untouched.

Adds the manifest, the icons and a service worker, so Knead Time can be
added to a Home Screen from Safari's share sheet or Chrome's install
prompt and opens standalone — and, once installed, opens with no signal
at all. That is what a job ticket you live inside for two days in a
kitchen actually needs, and it is cheap here for the same reason the
one-origin contract exists: the app fetches nothing from anywhere, so
the whole of it is a finite list known at build time. Offline is a
precache, not a strategy.

Every URL in the manifest is relative, so BASE_PATH stays out of a file
static/ copies verbatim — the same trick the hashed .woff2 files use. An
absolute "/" would point every PR preview's installed app at the
production root.

Notifications are the half of #306 that cannot be built. iOS suspends a
backgrounded web app's JavaScript, so a timer for a step eight hours out
never runs, and it wakes a service worker for exactly one thing: an
incoming push message, which needs a server to send. Notification
Triggers is gone from Chromium and never existed in Safari. So a
reminder at 03:00 needs a backend, which this app does not have and is
not going to grow; the .ics export, with the user's own calendar firing
the alarm, is the only client-side path to one. The reasoning is written
into app.html and service-worker.ts so it survives the next reading of
the issue.

Two traps, both pinned in e2e/pwa.spec.ts:

- cache.addAll is all-or-nothing by design, so one unfetchable entry
  silently costs the entire precache — an app that works perfectly in a
  tab and dies on a Home Screen. .nojekyll and CNAME are filtered out:
  they are instructions to GitHub Pages, not assets, and vite preview
  refuses to serve dotfiles at all.
- Claim, but never skipWaiting. Claiming is what makes the first visit
  work offline; skipping the wait would swap the worker under a page
  that is already open, and the running page would then ask for the old
  bundle's hashed filename that the deploy has already deleted.

Icons are rendered from two committed SVGs by scripts/render-icons.mjs
through Playwright's Chromium, already a devDependency. The ground is a
full-bleed rect because iOS composites a transparent icon onto black,
and a test reads the corner pixel to keep it that way.

Refs #306
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-09 20:11 UTC

Replaces the pizza-clock with the ticket: a sheet standing on a solid
block of ink offset down and right — the same out-of-register second
pass every .card on the page carries — with dotted leaders walking to
their figures, the stub's perforation, and one spot of tomato below the
tear as the only colour. The bites punched out of the perforation are
why the block is deliberately not symmetrical: the left bite is wider
than the ink beside it and punches through to the stock, the right one
stays inside the block.

Drawn as SVG rather than dropped in as the generated raster: the source
image carried a watermark, the mark filled about half the square, and a
favicon has to stay crisp at 16 px. The palette is the press's own
tokens rather than the render's approximations of them.

The tab, the Home Screen and the manifest now all point at icon.svg and
static/favicon.svg is gone, so the icon a reader recognises in a tab
strip cannot drift from the one on their phone. A test pins the two
hrefs equal, since re-adding a separate favicon is the easy invisible
way to break it.

The maskable cut is the same drawing at 0.70. It is a rectangle, so its
DIAGONAL has to clear Android's safe circle, not its width: 336 by 416
is 535 corner to corner against the 410 a launcher guarantees, and 0.75
lands on exactly 410 where a round crop shaves the block's corners.
@JanWelker
JanWelker merged commit fb907b3 into main Sep 9, 2026
6 of 7 checks passed
@JanWelker
JanWelker deleted the feat/pwa-installable branch September 9, 2026 20:11
@JanWelker
JanWelker restored the feat/pwa-installable branch September 9, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant