Installable app & offline use
For how to install the app and what works offline, see Install and use offline.
The web app ships as a progressive web app: it can be installed to a computer's dock or app list or a phone's home screen, 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: every lesson this device holds, their images as binary blobs, and a git repository per lesson behind version history (see Lessons on this device, 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 offline | Needs the network |
|---|---|
Opening the app at any client-side route outside WORKER_PATHS | The lesson hub, lesson pages, profiles, comments |
| Switching between the lessons on this device | Publishing a fork's changes as a proposal |
| Writing, editing, reordering, deleting sections and blocks | Save to cloud (publishing or a private backup) |
| Images already in the local image store | Image search (Pixabay, Wikimedia Commons) |
| Version history: commits, browsing, restoring | AI text / question / lesson-idea dialogs, AI fixes |
| DOCX export and PDF printing (the export chunk is precached) | Fact checking |
| Lesson images seen before (cached by hash) | Live collaboration |
| Sign-in, Save to Google Docs | |
| Downloading an on-device model for the first time |
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 the default generateSW mode, so Workbox writes dist/sw.js from the build's own asset list; nothing has to be kept in sync by hand. injectRegister is null: src/lib/pwa.jsx registers the worker itself so it can own the update UI.
Precache
The precache is the built shell, matched by globPatterns: ["**/*.{html,js,css,woff2,svg,png,ico}"]: index.html, the JS/CSS chunks (lazy ones included), the self-hosted Fontsource .woff2 files, and the icons. Two things are deliberately left out:
public/home/*.jpg, the homepage's feature screenshots (about 490 KB). Those are marketing images; they aren't worth an install-time download, so a runtimeStaleWhileRevalidaterule picks them up the first time someone actually looks at the homepage. (They're JPEGs, so the glob never matches them.)dist/docs/, this documentation site (globIgnores: ["docs/**"]).pnpm build:docscopies the VitePress 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. cleanupOutdatedCaches is on.
Navigation fallback, and the paths it must not touch
Client-side routes are answered from the precached index.html (navigateFallback: "/index.html"), which is what makes a deep link like /editor or /library 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 frontend fall-through (apps/api/src/routes/spa.js). An over-broad allowlist, by contrast, would have the service worker confidently answer /docs/intro with the React app.
That fall-through does need the matching edit, and for a different reason: it is what decides whether a path is a page or a 404. A route it doesn't know still renders; it just carries the wrong status.
Runtime caching
| Rule | Handler | Cache | Why |
|---|---|---|---|
/images/<64-hex> | CacheFirst | lesson-images | Lesson images are addressed by the SHA-256 of their bytes, so a URL's content can never change. |
| Other same-origin images | StaleWhileRevalidate | static-images | The homepage screenshots: worth keeping, not worth blocking on. |
Both keep entries for 30 days; the lesson-image cache holds up to 300 and the static one up to 60.
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.
Downloaded AI models
The features that run a model in the page with transformers.js save what they download in a Cache Storage bucket of their own, transformers-cache, which the service worker never touches: natural voices (interactive mode), the summary fallback (lesson summaries), the translation fallback (lesson and comment translation) and the model behind Import from text. The ONNX runtime's own .wasm files go in the same bucket. Between them that can come to a few gigabytes, and the browser keeps it until something deletes it.
The This device card on the settings page shows the total under Downloaded AI models and has a Delete models button, so that space can be had back without clearing the site's data, which would take every lesson on the device with it. packages/core/src/browser/modelCache.js does the work:
modelCacheBytes()adds up each entry'sContent-Length, without reading the bodies, for entries that arrived uncompressed. That covers the model weights, which Hugging Face sends as they are. An entry with aContent-Encodingis read for its real size instead: jsDelivr sends the ONNX runtime's.wasmas brotli, so itsContent-Length(about 5.5 MB) is the compressed size, while the cache holds it decoded. It returnsnullwhen the page can't use Cache Storage at all, and the row is left out then.clearModelCache()deletes the whole bucket. Nothing else writes to it, so there's nothing to pick through.
The row measures again every 5 seconds (MODEL_SIZE_POLL_MS in SettingsPage.jsx) while the page is in view, when the tab comes back into view, and when the dialog opens, because a download keeps going after you leave the page that started it and another tab can finish one. Only the first measurement can hide the row; a later read that fails keeps the last size.
Deleting is safe at any time, even mid-download. transformers.js stores each file whole and opens the bucket by name for every file it loads, so the next download just starts a new one. A model already loaded in an open page keeps working from memory; the next page load downloads it again.
Chrome's built-in models (the Translator, LanguageDetector and Summarizer APIs) belong to the browser, not the site, so they aren't counted or deleted here. The confirmation dialog says so.
The module hard-codes the bucket name (MODEL_CACHE_NAME) instead of importing transformers.js, which would pull the library into the settings page's bundle. Its test checks the name against transformers.js's own env.cacheKey, so an upgrade that renames the bucket fails the test instead of leaving the button deleting nothing.
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" ("Reload to update. Your saved lessons aren't affected."), 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 (UPDATE_CHECK_INTERVAL), 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 control in the app. 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. An appinstalled listener clears it.
InstallAppButton has two callers: the header's utility cluster (an icon with an Install app tooltip, hidden below sm along with the rest of that cluster) and the settings page's This device card (an Install app button under Install the app). Both render nothing at all unless the app is actually installable, so on a visit that doesn't qualify, or once the app is installed (detected via (display-mode: standalone) or navigator.standalone), the header and the card are unchanged.
Safari has no equivalent event: on iOS and iPadOS an app is installed through Share, then 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 (iPadOS is detected as a "Macintosh" user agent with touch points), and opens a dialog with the two steps. Chrome, Firefox, Edge and Opera on iOS (CriOS, FxiOS, EdgiOS, OPiOS) 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. Desktop Safari and Firefox get no button either.
Signing in from the installed app
A magic link signs in whichever browser opens it, and from an installed app that's rarely the app itself:
- iOS and iPadOS open every emailed link in Safari, and a home screen app's storage is separate from Safari's. The PKCE verifier the link needs was saved in the app, so Safari can't finish the sign-in, and even if it could, the session would land in Safari.
- Desktop Chrome and Edge open the link in a normal browser tab. Storage is shared with the installed app there, so the sign-in works, but the person is left in a browser tab rather than the app window.
So the "Check your email" screen also takes the one-time code from the same email (EmailCodeForm.jsx, labeled Code from the email; see Hub & accounts). A code has no redirect, so it signs in wherever it's typed. The field uses autocomplete="one-time-code", so phones can offer the code from the email as a keyboard suggestion. An instance using username and password sign-in has no link to lose.
The manifest and icons
The manifest is generated from the manifest block in vite.config.js; vite-plugin-pwa injects the <link rel="manifest">. It names the app "Spelling Lesson Maker" (short name "Spelling"), sets an explicit id: "/" so the install identity survives a change to start_url, uses display: "standalone", and adds two shortcuts: New lesson (/editor) and Lesson hub (/hub).
The tags it doesn't own are written by hand in apps/web/index.html: theme-color (twice; a prefers-color-scheme: dark variant, #12131f, first, since Safari takes the first match), mobile-web-app-capable, apple-mobile-web-app-title, the status-bar style (black-translucent), and apple-touch-icon. iOS ignores the manifest's icons and its display field without those.
theme_color and background_color in the manifest are both #dee3f3, as is the light theme-color meta tag. The comments beside them say they track the light --background, since the top of the screen is now the page rather than an indigo app bar. (They used to be --primary, #4f5fd9.)
Icons live in apps/web/public/icons/. icon.svg and maskable.svg are the sources; the PNGs are rasterized from them.
icon.svg/icon-192.png/icon-512.png: purposeany. Drawn as is by the platform, so they carry their own rounded corners.maskable.svg/maskable-512.png: purposemaskable. 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 center 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.pngsips rasterizes 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 previewlocalhost counts as a secure context, so the worker registers there without TLS. Chrome DevTools' Application panel (Service Workers and 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.