# apps/web — Velodrome PWA shell SvelteKit static SPA, installable PWA. Talks to the API at `/api/v1` — same-origin in production (Caddy proxies it), same-origin locally via the dev proxy in `vite.config.ts`. See `docs/PLAN.md` ("Stack", "The PWA decision") for why this shape was chosen; this file is just the how. ## Dev commands ```sh pnpm install pnpm run dev # vite dev server, proxies /api -> http://localhost:8000 pnpm run check # svelte-kit sync + svelte-check pnpm run lint # prettier --check + eslint pnpm run format # prettier --write pnpm run build # production build -> build/ pnpm run preview # serve the production build locally ``` `pnpm run lint`, `pnpm run check`, and `pnpm run build` are exactly the three steps CI's `web` job runs (`.gitea/workflows/ci.yml`) — keep those script names stable. Requires Node 22 and pnpm (via corepack). The service worker is disabled in dev (`devOptions.enabled: false` in `vite.config.ts`) — dev already has instant HMR, and a dev-mode SW mostly just causes "why isn't my change showing up" confusion. ## Shape of the app - **adapter-static, SPA mode, no prerendering.** `src/routes/+layout.ts` sets `export const ssr = false`, and `vite.config.ts` configures `@sveltejs/adapter-static` with `fallback: 'index.html'`. This is a client-only app: there's no Node process in production (see "Dockerfile" below), so every route has to resolve client-side against a single served shell, not be prerendered. - **Auth.** `src/lib/stores/auth.ts` is a tiny Svelte store backed by `GET /api/v1/auth/me`. `src/routes/+layout.svelte` calls `auth.refresh()` once on load; `/` and `/login` react to the store to decide what to show/redirect to. Login posts to `/api/v1/auth/login` (`src/lib/api/auth.ts`) and relies on the API setting an HttpOnly cookie — the app never touches the token directly. - **PWA / service worker.** `@vite-pwa/sveltekit` is _not_ used here — see the long comment block at the top of `vite.config.ts` for why (its `injectManifest` build expects SvelteKit's own built-in `src/service-worker.ts` convention to have already transpiled the file, but that native build only permits importing SvelteKit's own three virtual modules and hard-rejects `workbox-*` imports). We use the base `vite-plugin-pwa` (`VitePWA(...)`) instead, in `injectManifest` mode, and explicitly disable SvelteKit's native service-worker convention (`kit.files.serviceWorker` pointed at a nonexistent path) so only vite-plugin-pwa's build runs. `injectManifest`, not `generateSW`: the caching policy is hand-written in `src/service-worker.ts` using Workbox primitives directly, because the policy needs to say "never, ever cache `/api/*`" — more precisely than `generateSW`'s declarative config expresses. The policy, straight from `docs/PLAN.md`'s caching table: - Precache the injected build manifest (hashed JS/CSS/icons/`manifest.webmanifest`), plus a synthetic entry for the SPA fallback `index.html` (see the `swIndexRevision` comment in `vite.config.ts` — the real `build/index.html` doesn't exist yet when the SW is built, since the adapter writes it afterward, so its precache revision is a per-build-invocation timestamp instead of a content hash). - Navigations: cache-first, served from the precached `index.html` via `NavigationRoute(createHandlerBoundToURL('index.html'))` — the app opens instantly and works offline. - `/api/*`: `NetworkOnly`, unconditionally. Never cached, per CLAUDE.md's invariants. - Nothing else yet — tile/stream caching is a later phase; the file is structured so those are additional `registerRoute()` calls, not a rewrite. `skipWaiting()` + `clientsClaim()` run immediately. `src/lib/components/UpdateToast.svelte` uses `virtual:pwa-register/svelte`'s `useRegisterSW()` to show a "New version available — reload" toast instead of silently swapping the SW under the user. - **Manifest / icons.** `static/manifest.webmanifest` is hand-written (not plugin-generated) and linked explicitly from `src/app.html`, along with the `apple-touch-icon`, `apple-mobile-web-app-capable` meta, and `viewport-fit=cover` (paired with `env(safe-area-inset-*)` padding in the root layout) for iOS standalone mode. Icons in `static/icons/` are placeholder solid-color PNGs — fine for Phase 0, swap them for real artwork later. - **iOS install banner.** `src/lib/components/IosInstallBanner.svelte` — informational only ("Add to Home Screen" instructions), shown when not already standalone and the UA looks like iOS Safari. Dismissible per session (`sessionStorage`), reappears next session until actually installed. No `Notification.requestPermission()` here — that's gated behind standalone mode in a later phase, per `docs/PLAN.md`'s PWA-decision table. - **Styling.** Plain CSS (`src/app.css`), CSS custom properties for theming, `prefers-color-scheme` for dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase. ## Dockerfile This image's **only** purpose is to produce `/app/build` (the static SPA output) as a buildable artifact — see the comment block at the top of `Dockerfile` for the full rationale. There is no Node runtime in production; Caddy serves the built files directly and proxies `/api/*` to the API container (`docs/PLAN.md`, "Service topology"). This image is never run as a long-lived container. Build it with the `apps/web` directory as context: ```sh docker build -f apps/web/Dockerfile -t velodrome-web-build apps/web ``` How `deploy/` is expected to consume it (my assumption — the `deploy/` work is happening in a parallel worktree, so this may get adjusted there): a multi-stage `COPY --from=velodrome-web-build /app/build /srv/web` in whatever image serves Caddy, or a one-shot `docker create` + `docker cp` / bind-mount step in the deploy pipeline that populates the volume Caddy reads from before it starts. Went with a single plain builder stage (no `scratch`/`busybox` artifact-holder final stage) because nothing here needs to _run_ — the only thing anyone needs from this image is the files in `/app/build`, and a second stage would add complexity without adding anything.