Installable app & offline use

The web app ships as a progressive web app: it can be installed to a phone's Home Screen or a desktop dock, it opens in its own window with no browser chrome, and the editor keeps working with no network at all.

Offline support is mostly something the app already had. Lessons live in IndexedDB — the working document, its images as binary blobs, and the whole git repository behind version history (see Version history and Lesson images). What was missing was the other half: without a service worker the browser still has to fetch index.html and the JS bundle over the network before any of that stored data can be reached. Precaching the built shell closes that gap.

What works offline, and what doesn't

Works offlineNeeds the network
Opening the app at any client-side routeThe lesson hub, profiles, comments
Writing, editing, reordering, deleting sections and blocksPublishing / saving to the cloud
Images already in the local image storeImage search (Pixabay, Wikimedia)
Version history: commits, browsing, restoringAI text / question / lesson-idea dialogs
DOCX export and PDF printingLive collaboration
Lesson images seen before (cached by hash)Sign-in, Save to Google Docs

Everything in the right-hand column already degrades with a clear message when the feature is unconfigured (see Getting started); with no connection they fail the same way rather than being hidden.

How it's put together

The service worker and the manifest come from vite-plugin-pwa, configured in the VitePWA block of apps/web/vite.config.js. It runs in generateSW mode, so Workbox writes dist/sw.js from the build's own asset list — nothing has to be kept in sync by hand.

Precache

The precache is the built shell: index.html, the JS/CSS chunks, the self-hosted Fontsource .woff2 files, and the icons. Two things are deliberately left out:

  • public/home/*.jpg, the homepage's feature screenshots (~245 KB). Those are marketing images; they aren't worth an install-time download, so a runtime StaleWhileRevalidate rule picks them up the first time someone actually looks at the homepage.
  • dist/docs/, this documentation site. pnpm build:docs copies the Rspress output in after the web build, and it has its own hashed assets and its own pages — none of which belong in the app's precache.

maximumFileSizeToCacheInBytes is raised to 4 MiB because the vendor chunk is over Workbox's 2 MiB default on its own.

Client-side routes are answered from the precached index.html, which is what makes a deep link like /hub/abc123 work with no network. But the Cloudflare Worker in front of the app answers plenty of paths itself — run_worker_first means it sees every request before the static assets do — and the service worker must not shadow those with the app shell.

WORKER_PATHS at the top of apps/web/vite.config.js is that list, passed to Workbox as navigateFallbackDenylist: the server-rendered routes (/hub, /hub/…, /users/…), /docs, /images/…, /git/…, /collab, /og-image, the MCP OAuth paths (/authorize, /token, /register, /mcp, /.well-known/…), and the SEO endpoints (sitemap.xml, robots.txt, feed.xml, spelling-words.json). Anything not on it is assumed to be a route in src/App.jsx.

The server-rendered routes are on the list for a reason worth spelling out: if the service worker answers /hub/abc123 from the precached shell, the Worker is never asked, and server rendering silently stops happening — for returning visitors specifically, the only people whose browsers have the shell cached. Nothing is lost offline, since all three need the network for their data anyway.

It's a denylist rather than an allowlist on purpose. Adding a page to App.jsx shouldn't require a matching edit to the build config, and the failure mode is the gentler one: an unlisted route falls through to the network, which online still lands on index.html via the Worker's single-page-application asset fallback. An over-broad allowlist, by contrast, would have the service worker confidently answer /docs/intro with the React app.

Runtime caching

RuleHandlerWhy
/images/<64-hex>CacheFirstLesson images are addressed by the SHA-256 of their bytes, so a URL's content can never change.
Other same-origin imagesStaleWhileRevalidateThe homepage screenshots — worth keeping, not worth blocking on.

The image rule is matched by a callback rather than a RegExp: VITE_API_URL may point at a different origin, and Workbox only applies a RegExp route cross-origin when it matches from the very start of the URL.

cacheableResponse accepts status 200 and not 0. Status 0 is an opaque response, which is what a cross-origin <img> the app didn't fetch itself yields — but opaque means no status at all, so a 404 or a 502 looks exactly like a hit. Under CacheFirst that error would then be served from the cache for the full 30 days with no retry, so a single blip while an image was still uploading would break it permanently. (Opaque entries are padded to megabytes apiece for quota accounting too, and the rule allows 300 of them.)

The deployed app gives nothing up for this: the Worker serves both the SPA and /images from the same origin, so the responses are basic and carry a real status. A self-host that points VITE_API_URL at a different origin loses offline images — the browser's own HTTP cache still applies — which is the right way round from a cache that can poison itself.

Hub listings, profiles and comments are not cached. They're user-specific and change often, and a stale hub is more confusing than an unavailable one.

Updates

registerType is "prompt", not "autoUpdate". Activating a new service worker reloads the page, and this is an editor — swapping the running build out from under someone mid-lesson isn't something to do silently.

So src/lib/pwa.jsx registers the worker and raises a Sonner toast when a new build is waiting: "A new version is available", with a Reload action that calls updateServiceWorker(true). The toast has no timeout and can be dismissed; dismissing lets it be offered again at the next check rather than never again for the life of the tab. That check runs hourly, and only while navigator.onLine — an installed PWA's window can stay open for days, and without a poll it would only notice a deployment on a manual reload.

Installing

src/lib/useInstallPrompt.js captures Chromium's beforeinstallprompt event and calls preventDefault() on it, which suppresses the browser's own mini-infobar and lets InstallAppButton.jsx put the affordance in the header next to the other nav actions. The event fires before React mounts, so the listener is registered at module scope and the captured event is held in a small store that components read through useSyncExternalStore.

The button renders nothing at all unless the app is actually installable, so on a desktop visit that doesn't qualify — or once the app is installed, detected via (display-mode: standalone) — the header is unchanged.

Safari has no equivalent event: on iOS an app is installed through Share → Add to Home Screen, and there is no API to trigger it or even to ask whether it's available. There the button is shown on browser sniffing instead, and opens a dialog with the two steps. Chrome, Firefox and Edge on iOS are excluded — they are Safari underneath, but their UI has no "Add to Home Screen" item, and pointing someone at a menu entry they don't have is worse than saying nothing.

The manifest and icons

The manifest is generated from the manifest block in vite.config.js; vite-plugin-pwa injects the <link rel="manifest">. The tags it doesn't own are written by hand in apps/web/index.html: theme-color (twice — a prefers-color-scheme: dark variant first, since Safari takes the first match), mobile-web-app-capable, apple-mobile-web-app-title, the status-bar style, and apple-touch-icon. iOS ignores the manifest's icons and its display field without those.

theme_color (#4f5fd9) and background_color (#dee3f3) are --primary and --background from src/styles/globals.css, so the OS window chrome and the launch splash are continuous with the app's own light theme.

Icons live in apps/web/public/icons/. icon.svg and maskable.svg are the sources; the PNGs are rasterised from them.

  • icon.svg / icon-192.png / icon-512.png — purpose any. Drawn as-is by the platform, so they carry their own rounded corners.
  • maskable.svg / maskable-512.png — purpose maskable. A full-bleed square that Android crops to whatever shape the launcher uses, with the glyph at 45% so it stays inside the safe zone (the centre 80% circle).
  • apple-touch-icon.png (180px) — full-bleed for the same reason; iOS applies its own rounding.

To regenerate the PNGs after editing an SVG, on macOS:

cd apps/web/public/icons
sips -s format png icon.svg --out icon-512.png
sips -s format png maskable.svg --out maskable-512.png

sips rasterises at the SVG's intrinsic size, so the 192px and 180px variants need an SVG whose width/height say so — change those two attributes (the viewBox stays) and convert again.

Local development

devOptions.enabled is false, so no service worker runs under pnpm dev. That keeps Vite's HMR behaving normally and avoids debugging a stale cache that only exists on your machine. To exercise the PWA, build and preview:

pnpm build
pnpm preview

localhost counts as a secure context, so the worker registers there without TLS. Chrome DevTools → ApplicationService Workers / Manifest is the fastest way to check registration, and its Offline checkbox to check the fallback. Remember to Unregister (or tick Update on reload) between builds — otherwise the previous worker keeps serving the previous bundle, which is exactly what it's designed to do.