feat(pwa): the app installs to a home screen and opens with no network - #307
Merged
Conversation
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
|
7 tasks
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.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.webmanifestdisplay: standalonestatic/icon*.{svg,png}src/app.htmlapple-touch-iconlinks, iOS metasrc/service-worker.tsbuild+files+prerenderedscripts/render-icons.mjse2e/pwa.spec.tsWhy 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:
setTimeoutfor a step eight hours out never runs.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.mdlists as out of scope and which the one-origin promise exists to avoid. The.icsexport — 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 intoapp.htmlandservice-worker.tsso 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.addAllis 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..nojekyllandCNAMEare filtered out: they are instructions to GitHub Pages rather than assets, andvite preview(which the browser suite serves the real output with) refuses to serve dotfiles at all. This is what actually broke first.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.woff2files use, and it keepsBASE_PATHout of a filestatic/copies verbatim. An absolute/would point every PR preview's installed app at the production root. Verified by building withBASE_PATH=/knead-time/pr-306: the head links resolve./manifest.webmanifestfrom/and../manifest.webmanifestfrom/print/en, and the worker derives its own base fromlocation.pathname. A test assertsstart_urlandscopeland 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
.cardcarries), 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.svgis gone — the tab, the Home Screen and the manifest all point atstatic/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.svgis the same drawing atscale(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— cleannpm run test:coverage— 953 unit tests, 100 % lines/branches/functions/statementsnpm run test:e2e— 136 passed (127 + 9 new), includingself-hosted.spec.ts: the worker adds no cross-origin request and the app still arrives as one script and one stylesheet.github/test-baseline.jsonraised to 136Version bumped to 7.1.0 — minor: new user-facing feature, no URL-schema change, so
v=7links are untouched.