# bland PWA Readiness Investigation Date: 2026-04-09 ## Scope This investigation answers a narrow question: What does it take to make `bland` PWA-ready without expanding beyond the current product spec? The intended offline boundary is already defined in [bland-production-spec.md](./bland-production-spec.md): - `bland` supports per-document offline editing for previously visited pages - `bland` does not support offline workspace mutations - `bland` does not support full offline-first behavior - workspace metadata is online-first with a stale local cache So "PWA-ready" for `bland` should mean: - installable on desktop and mobile - launches in a standalone app window - the SPA shell can boot offline - previously visited pages can still load and edit offline - reconnect sync still uses the existing Yjs merge path It should not mean: - offline create, move, delete, share, invite, search, or upload - a new offline sync subsystem - a second source of truth outside D1 + Durable Objects + client-local caches ## Current State In The Live Tree The repo already implements most of the product-facing offline behavior once the app is running. ### Already present - Per-page Yjs persistence exists via `y-indexeddb` in [src/client/components/editor/editor-pane.tsx](../src/client/components/editor/editor-pane.tsx). - Cached-doc hints exist in [src/client/lib/doc-cache-hints.ts](../src/client/lib/doc-cache-hints.ts) and are used to distinguish "previously visited" pages from pages that do not have local document data yet. - Workspace state is persisted with Zustand `persist` in [src/client/stores/workspace-store.ts](../src/client/stores/workspace-store.ts). - Startup falls back to `LOCAL_ONLY` when refresh fails due to network or transport issues in [src/client/main.tsx](../src/client/main.tsx) and [src/client/lib/api.ts](../src/client/lib/api.ts). - Session rehydration already retries refresh when connectivity returns in [src/client/hooks/use-session-rehydration.ts](../src/client/hooks/use-session-rehydration.ts). - Page loading already tries the API first, then falls back to cached page metadata and local Yjs state in [src/client/components/page-view.tsx](../src/client/components/page-view.tsx). - The root route already has a cached-workspace recovery path through [src/client/components/empty-workspace-view.tsx](../src/client/components/empty-workspace-view.tsx) and [src/client/lib/root-workspace-gateway.ts](../src/client/lib/root-workspace-gateway.ts). - Offline UI indicators already exist: - banner in [src/client/components/app-shell.tsx](../src/client/components/app-shell.tsx) - sync-status dot in [src/client/components/presence/sync-status.tsx](../src/client/components/presence/sync-status.tsx) - disabled online-only actions in the sidebar and page actions - Explicit logout already clears local auth state, cached-doc hints, and persisted workspace state in [src/client/hooks/use-auth.ts](../src/client/hooks/use-auth.ts) and [src/client/stores/auth-store.ts](../src/client/stores/auth-store.ts). - Cache ownership validation already clears cached workspace data and local Yjs docs when the cached user changes in [src/client/stores/workspace-store.ts](../src/client/stores/workspace-store.ts). - The deployed app already has SPA navigation fallback at the edge via [wrangler.jsonc](../wrangler.jsonc). This means `bland` is already close to the spec's offline document behavior after initial load. ### Still missing The browser-facing PWA layer is still absent. - No `manifest.webmanifest` - No install icon set - No browser service worker file - No service worker registration in the client - No PWA plugin or service-worker build wiring in [vite.config.ts](../vite.config.ts) - No standalone install metadata in [index.html](../index.html) - No `` in [index.html](../index.html) - No tracked favicon or app icon asset, even though [index.html](../index.html) references `/favicon.svg` - No last-page or last-route restore for offline cold start The tracked `public/` directory is also empty today. The current build still succeeds without these assets, so this is an installability gap rather than a current build blocker. ## Main Gaps ### 1. No install metadata or tracked browser assets [index.html](../index.html) sets the page title, a favicon link, and Google Fonts, but it does not link a web app manifest or any install metadata. There is also no tracked icon or favicon asset anywhere in the repo today. Minimum missing pieces: - `manifest.webmanifest` - `` - `` - app icons, including a maskable icon ### 2. No browser service worker The Cloudflare Worker in [src/worker/index.ts](../src/worker/index.ts) is server infrastructure, not a browser service worker. Right now there is: - no client-side `navigator.serviceWorker.register(...)` - no generated or hand-written service worker entry - no build-time precache manifest wiring That means: - the app shell is not cached for offline launch - an installed copy would not reliably cold-start offline - visited docs may be available once the app is running, but the app itself is not yet an offline-resilient installed shell ### 3. Offline cold start is partially covered, but not finished The current root flow is better than a hard live-only bootstrap. The `/` entry path already uses [src/client/lib/root-workspace-gateway.ts](../src/client/lib/root-workspace-gateway.ts) to redirect into a cached workspace when the API is unreachable and a persisted workspace is available. But the cold-start experience is still incomplete: - there is no persisted "last useful route" or last opened page - offline launch depends on having enough persisted workspace state to recover - direct page reopen still depends on both cached page metadata and a local Yjs document - if there is no cached workspace to recover, `/` falls into the unavailable state instead of reopening the last page So the remaining launch gap is not "root is live-only". It is "the app cannot yet deliberately restore the last useful page when launched offline". ### 4. Media is not explicitly covered by offline caching Previously visited page text is already backed by local Yjs persistence, but page covers and inline images still resolve to network URLs: - [src/client/components/ui/page-cover.tsx](../src/client/components/ui/page-cover.tsx) - [src/client/components/editor/extensions/image-node.tsx](../src/client/components/editor/extensions/image-node.tsx) Uploads are served from same-origin authenticated routes in [src/worker/routes/uploads.ts](../src/worker/routes/uploads.ts), and successful responses already send: - `Cache-Control: private, max-age=31536000, immutable` But nothing in the browser is currently taking advantage of that for offline boot or runtime caching. If `bland` wants visited pages with images to render fully offline, the browser service worker should cache same-origin `GET /uploads/:id` responses after they have been fetched once. Without that, offline page rendering still works for text, but some media will disappear when the network is gone. ### 5. Fonts are still network-dependent [index.html](../index.html) currently loads fonts from Google Fonts. That does not block PWA installability, but it reduces offline fidelity and adds an external dependency to the app shell. For the smallest acceptable implementation, this can stay as-is initially if system fallback fonts are acceptable offline. If visual fidelity matters, the fonts should be self-hosted. ### 6. Browser-cache invalidation does not exist yet `bland` already clears local auth and document/workspace caches on explicit logout or cached-user changes. What does not exist yet is browser-cache invalidation for service-worker caches, because there is no service worker today. Once a browser PWA layer is added, the behavior should be intentional: - logout should best-effort clear browser caches owned by the current user - user-switch should clear caches keyed to the previous user - revocation can continue to follow the existing v1 local-cache tradeoff from the product spec ## Recommended Minimum Implementation The smallest implementation that matches the current spec is still a thin browser-PWA layer on top of the existing offline document strategy. ### 1. Add manifest and actual icon assets Add a root-served `manifest.webmanifest` with: - `name: "bland"` - `short_name: "bland"` - `start_url: "/"` - `scope: "/"` - `display: "standalone"` - `background_color` matching the app background - `theme_color` matching the app chrome - at least one 192x192 icon - at least one 512x512 icon - at least one `purpose: "maskable"` icon Also update `index.html` to link the manifest and theme color, and replace the current unresolved `/favicon.svg` reference with a real tracked asset. ### 2. Add a browser service worker Use a browser service worker to make the app shell boot offline. Recommended cache policy: - precache the built SPA shell assets - precache the manifest and icons - navigation requests: network-first with offline fallback to `index.html` - same-origin static assets: stale-while-revalidate or cache-first - `/api/*`: network-only - `/parties/*`: do not cache - `/uploads/*` `GET`: defer to a later phase unless visited-media caching is explicitly included ### 3. Add last useful route restore Persist the last successfully opened workspace/page, or at minimum the last page id plus workspace slug, and use that during offline startup. That lets an installed app reopen to the last useful screen instead of relying only on cached workspace recovery from `/`. ### 4. Extend existing logout and user-switch cleanup to browser caches When the user logs out or the cached workspace owner changes: - keep the existing local state cleanup - additionally clear service-worker caches that contain authenticated media or app data - keep this best-effort, matching the current local-cache model ## Recommended Tooling Follow the repo's "less code" rule. Start with `vite-plugin-pwa` using `generateSW`, not a hand-written service worker. Why: - the plugin is not installed or configured today - Vite already owns the build output and hashed asset graph - `bland` only needs a modest runtime caching policy - this is likely less code than maintaining a custom service worker by hand Only move to `injectManifest` if `bland` later needs more custom service worker logic than `generateSW` can express cleanly. ## What Should Stay Out Of Scope Do not expand the implementation to include: - offline page creation - offline page moves or archive actions - offline search - offline uploads - background sync queues for workspace mutations - push notifications - a second offline database for workspace metadata Those are separate product decisions and are not required by the current spec. ## Suggested Rollout ### Phase 1 Make `bland` installable. - add manifest - add actual icon assets - link metadata in `index.html` - register the browser service worker ### Phase 2 Make the installed app boot offline. - precache shell assets - add navigation fallback to the SPA shell - verify standalone launch works offline ### Phase 3 Improve previously visited page fidelity offline. - persist and restore the last useful route - optionally cache same-origin upload `GET` responses for visited media - clear browser caches on logout and user switch ## Verification Checklist - Install `bland` on desktop Chrome and on mobile Chrome/Safari home screen - Launch the installed app and confirm it opens in standalone mode - Open `/` online, then relaunch offline and confirm cached workspace recovery still redirects when local workspace state exists - Launch `/` offline with no cached workspace and confirm the unavailable state is explicit - Visit a page online, reload offline, and confirm the page still opens if it was previously visited - Edit a previously visited page offline and confirm changes merge on reconnect - Try to open a never-visited page offline and confirm the failure is explicit - Confirm page create/archive/share/search remain disabled or unavailable offline - If visited-media caching is implemented, visit a page with uploaded media, go offline, and confirm the media still renders after the page has been fetched once - Confirm explicit logout clears current local state today, and browser caches too once a service worker exists ## Decision `bland` still does not need a broad offline-first redesign to become PWA-ready. It needs a thin browser-PWA layer on top of the current offline document strategy: - manifest - icons - browser service worker - offline app-shell caching - last-route restore - optional visited-media caching That remains the smallest implementation that matches both the current product spec and the live codebase. ## Live Tree References Verified against: - [docs/bland-production-spec.md](./bland-production-spec.md) - [index.html](../index.html) - [vite.config.ts](../vite.config.ts) - [wrangler.jsonc](../wrangler.jsonc) - [src/client/main.tsx](../src/client/main.tsx) - [src/client/lib/api.ts](../src/client/lib/api.ts) - [src/client/lib/doc-cache-hints.ts](../src/client/lib/doc-cache-hints.ts) - [src/client/lib/root-workspace-gateway.ts](../src/client/lib/root-workspace-gateway.ts) - [src/client/hooks/use-auth.ts](../src/client/hooks/use-auth.ts) - [src/client/hooks/use-session-rehydration.ts](../src/client/hooks/use-session-rehydration.ts) - [src/client/stores/auth-store.ts](../src/client/stores/auth-store.ts) - [src/client/stores/workspace-store.ts](../src/client/stores/workspace-store.ts) - [src/client/components/app-shell.tsx](../src/client/components/app-shell.tsx) - [src/client/components/empty-workspace-view.tsx](../src/client/components/empty-workspace-view.tsx) - [src/client/components/page-view.tsx](../src/client/components/page-view.tsx) - [src/client/components/editor/editor-pane.tsx](../src/client/components/editor/editor-pane.tsx) - [src/client/components/editor/extensions/image-node.tsx](../src/client/components/editor/extensions/image-node.tsx) - [src/client/components/ui/page-cover.tsx](../src/client/components/ui/page-cover.tsx) - [src/client/components/presence/sync-status.tsx](../src/client/components/presence/sync-status.tsx) - [src/worker/routes/uploads.ts](../src/worker/routes/uploads.ts)