CI's web job runs bare `corepack enable && pnpm install --frozen-lockfile` with no explicit pnpm version, so without a `packageManager` field corepack can resolve a different pnpm than the one that generated the lockfile (lockfileVersion 9, requires pnpm 9+) — that's what broke the first CI run. Also drops @vite-pwa/sveltekit, left over from an earlier approach abandoned in favor of the base vite-plugin-pwa plugin (see vite.config.ts) and no longer imported anywhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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
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.tssetsexport const ssr = false, andvite.config.tsconfigures@sveltejs/adapter-staticwithfallback: '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.tsis a tiny Svelte store backed byGET /api/v1/auth/me.src/routes/+layout.sveltecallsauth.refresh()once on load;/and/loginreact 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/sveltekitis not used here — see the long comment block at the top ofvite.config.tsfor why (itsinjectManifestbuild expects SvelteKit's own built-insrc/service-worker.tsconvention to have already transpiled the file, but that native build only permits importing SvelteKit's own three virtual modules and hard-rejectsworkbox-*imports). We use the basevite-plugin-pwa(VitePWA(...)) instead, ininjectManifestmode, and explicitly disable SvelteKit's native service-worker convention (kit.files.serviceWorkerpointed at a nonexistent path) so only vite-plugin-pwa's build runs.injectManifest, notgenerateSW: the caching policy is hand-written insrc/service-worker.tsusing Workbox primitives directly, because the policy needs to say "never, ever cache/api/*" — more precisely thangenerateSW's declarative config expresses. The policy, straight fromdocs/PLAN.md's caching table:- Precache the injected build manifest (hashed JS/CSS/icons/
manifest.webmanifest), plus a synthetic entry for the SPA fallbackindex.html(see theswIndexRevisioncomment invite.config.ts— the realbuild/index.htmldoesn'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.htmlviaNavigationRoute(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.svelteusesvirtual:pwa-register/svelte'suseRegisterSW()to show a "New version available — reload" toast instead of silently swapping the SW under the user. - Precache the injected build manifest (hashed JS/CSS/icons/
-
Manifest / icons.
static/manifest.webmanifestis hand-written (not plugin-generated) and linked explicitly fromsrc/app.html, along with theapple-touch-icon,apple-mobile-web-app-capablemeta, andviewport-fit=cover(paired withenv(safe-area-inset-*)padding in the root layout) for iOS standalone mode. Icons instatic/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. NoNotification.requestPermission()here — that's gated behind standalone mode in a later phase, perdocs/PLAN.md's PWA-decision table. -
Styling. Plain CSS (
src/app.css), CSS custom properties for theming,prefers-color-schemefor 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:
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.