# Installable App ## Summary The web app can be installed as an app. Chrome, Edge, and Android offer to install it, and on an iPhone, Add to Home Screen gives it its icon and name. It opens in its own window, with a title bar in the page's chrome color. Opened with no connection, it shows a short offline page, which reloads by itself once the connection is back. ## Motivation People use the app on their phones, where an app on the home screen is how a chat app is expected to open. A tab in the browser gets lost among others and spends space on the browser's own bars. Installing needs a manifest, square icons, and, so an installed app never opens on the browser's error page, a service worker with an offline page. ## Design ### Manifest The server builds `/manifest.webmanifest` on each request, in `apps/server/src/web-manifest.ts`, since the app's name and description come from the admin area. Its `name` and `short_name` are the app name, `lang` is `en`, as the page's is, `start_url` and `scope` are `/`, and `display` is `standalone`. `background_color` and `theme_color` are the light `--color-chrome`, as a manifest takes only a plain color. It is sent as `application/manifest+json` with `Cache-Control: no-cache`, so a renamed app installs under its new name without a restart. The root route links it, and links the Apple touch icon. The root route also links `icon-192.png` as a PNG icon ahead of the SVG logo, for browsers without SVG icons, such as Safari before 26. Its `sizes` keeps browsers that read SVG on the logo. ### Icons `apps/web/icons/render.ts`, run with `pnpm --filter @scn-chat/web icons`, draws the icons from `src/logo.svg` through headless Chrome, and its output is committed to `apps/web/public/icons/`. They show the logo as the sign-in page does in light mode, in its light colors on the light `--color-panel`, with the colors read from `theme.css`, so the icons follow the theme when they are drawn again. - `icon-192.png` and `icon-512.png`, purpose `any`: the tile with rounded corners and transparency outside them. - `icon-maskable-512.png`, purpose `maskable`: the tile to its edges, with the logo inside the middle 80% circle a launcher keeps. - `apple-touch-icon.png`, 180 pixels: the tile to its edges with no transparency, since iOS rounds the corners and fills transparency with black. `CHROME` names the browser's binary where it isn't in the macOS default place. ### Title bar color The shell renders two `theme-color` tags, one per scheme by media query and named in `data-scheme`, from `chromeColors` in `src/shared/theme.ts`. They sit in the shell's `` ahead of `HeadContent`, since a route's `head` keeps only one meta tag per name. When the device fixes a scheme, the head's theme script points both tags at that scheme's color before the first paint, and `showTheme` does the same on a change, or gives each tag its own scheme back for System. ### Service worker `apps/web/src/sw.js` is a classic worker in plain JavaScript, since TypeScript's worker types clash with the DOM's. The Vite plugin `serviceWorker()` writes it to `/sw.js` in the browser build, with its cache named `scn-chat-` and a hash of the build's file names and of the public files, which keep their names when they change, so each build installs a new worker with a fresh cache. The server sends `/sw.js` with `Cache-Control: no-cache`. - On install it caches `/offline.html`, then takes over without waiting. - On activate it deletes every other `scn-chat-` cache, turns on navigation preload, and claims open pages. - A same-origin `GET` under `/assets/` comes from the cache, or from the network and into the cache. These files are named by a hash of their content, so a kept copy never goes stale. - A request under `/api/`, `/oauth/`, or `/xrpc/` is left to the browser, even as a page load, so signing in and a blob opened in a tab never pass through the worker. - A page load goes to the network, through navigation preload. Only when the network can't be reached does it get the offline page. A response with an error status is shown as it is. - Everything else, including writes and other sites, is left to the browser. `useServiceWorker` in `src/shared/useServiceWorker.ts`, called by the shell, registers the worker in a production build. In development it unregisters any worker left by `pnpm preview` on the same port. `apps/web/public/offline.html` stands alone, without the app's stylesheet, and is laid out as the error page is: a grey round mark with a struck-through cloud, the heading, a muted line, and a green Try again button. It repeats the theme tokens it uses, and a test in `tests/styles/theme.test.ts` fails when any drift from `theme.css`. It follows the scheme chosen in Preferences, read from the same local store as the app. It says the app needs a connection to open, and reloads when the browser comes back online. ## Scope Boundaries - Chats can't be opened without a connection. The root route checks the session with the server, and pages render on the server. Opening the local copy offline is its own feature. - The worker caches nothing but the build and the offline page (ADR 0006). - No push notifications, background sync, or install button of the app's own. Browsers offer installing themselves. - The icon isn't set in the admin area. A fork changes `logo.svg` and draws the icons again. ## Edge Cases and Decisions - The page keeps the default viewport, without `viewport-fit=cover`, so an installed app on iOS stays clear of the status bar and home indicator without safe-area padding. - Signing in from an app on an iPhone's home screen leaves the app's scope for the user's PDS. iOS may finish OAuth in a browser sheet whose cookies the installed app doesn't share. If it does, signing in in Safari before adding the app is the way around it, until a fix of its own. - The manifest and the `theme-color` tags take only plain colors, so `chromeColors` in `theme.ts` and the server's `web-manifest.ts` repeat `--color-chrome` as hex. A test in `tests/styles/theme.test.ts` fails when either drifts from the token, and the styling spec lists them as the exceptions to the theme being the only place colors live. - The offline page has no app name, since it is a static file cached before any page names the app. ## Acceptance Criteria - `/manifest.webmanifest` is sent as `application/manifest+json` with `no-cache`, names the app from the settings, follows a rename without a restart, and lists the two `any` icons and the maskable one. - `/sw.js` is sent with `no-cache`. - Every page links the manifest, the Apple touch icon, and a PNG icon ahead of the SVG logo, and has a `theme-color` tag for each scheme. - In a production build, the worker controls the page after a reload. Its cache holds only `/offline.html` and files under `/assets/`, and requests under `/api/`, `/oauth/`, and `/xrpc/`, page loads included, never reach it. - A page load with no connection shows the offline page at the URL asked for, and coming back online loads the app. - A new build's worker deletes the previous build's cache, and a change to a public file alone makes a new build name. - A device that fixes a scheme gets that scheme's chrome color in both `theme-color` tags, and System gives each its own back. - The offline page shows in the app's look, in the scheme the device chose, and its repeated tokens match the theme. - The hex chrome colors match `--color-chrome`. - In development, no worker is registered. ## Files - `apps/server/src/web-manifest.ts`, `apps/server/src/app.ts` - `apps/web/src/sw.js`, `apps/web/vite.config.ts`, `apps/web/src/shared/useServiceWorker.ts` - `apps/web/src/routes/__root.tsx`, `apps/web/src/shared/theme.ts` - `apps/web/public/offline.html`, `apps/web/public/icons/`, `apps/web/icons/render.ts`