feat(web): SvelteKit PWA shell with login
CI / Repo hygiene (pull_request) Successful in 2s
CI / API (lint, types, tests) (pull_request) Successful in 2s
CI / Migrations reversible (pull_request) Successful in 3s
CI / Web (lint, typecheck, build) (pull_request) Failing after 8s

Phase 0 scaffolding for the frontend: SvelteKit + adapter-static in SPA mode
(fallback index.html, ssr disabled in the root layout — no Node process in
production, Caddy serves build/ directly per docs/PLAN.md), a login page and
auth store backed by /api/v1/auth/{login,me,logout}, an installable-PWA shell
(hand-written manifest, iOS meta/safe-area handling, an install-onboarding
banner), and a Dockerfile whose only job is to produce a buildable /app/build
artifact for deploy/ to consume.

Service worker notes, since the wiring isn't obvious from the diff:

- injectManifest, not generateSW: the caching policy needs to say "never
  cache /api/*", which generateSW's declarative config can't express as
  precisely as hand-written Workbox routes can.
- Uses the base `vite-plugin-pwa` plugin, not `@vite-pwa/sveltekit`'s
  SvelteKit-specific wrapper. That wrapper's injectManifest build expects
  SvelteKit's own built-in src/service-worker.{js,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,
  which our SW needs. The base plugin bundles src/service-worker.ts directly
  instead, which works. SvelteKit's native service-worker convention is
  explicitly disabled (`kit.files.serviceWorker` pointed at a path that
  doesn't exist) so the two builds can't collide and silently clobber each
  other's output — they do, if both are left enabled, and the failure mode is
  silent (the SW builds fine, just precaches nothing).
- workbox-core/precaching/routing/strategies had to be added as direct
  devDependencies even though workbox-build depends on them — pnpm doesn't
  hoist transitive deps into the top-level node_modules, so the SW bundle
  step couldn't resolve them otherwise.
- The SPA fallback index.html doesn't exist yet at service-worker-build time
  (adapter-static writes it after all Vite plugins finish), so it can't be
  glob-hashed into the precache manifest normally. It gets a synthetic
  manifest entry instead, revisioned by a per-build-invocation timestamp
  (see the swIndexRevision comment in vite.config.ts) so the cached shell
  still invalidates correctly on every deploy.

Full rationale for each choice is inline as comments in vite.config.ts and
apps/web/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 08:18:36 -04:00
co-authored by Claude Sonnet 5
parent e7cb06a6bc
commit 7bef79fae5
32 changed files with 6475 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.svelte-kit
build
.git
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+6
View File
@@ -0,0 +1,6 @@
.svelte-kit/
build/
dist/
node_modules/
pnpm-lock.yaml
static/icons/
+13
View File
@@ -0,0 +1,13 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": { "parser": "svelte" }
}
]
}
+36
View File
@@ -0,0 +1,36 @@
# This image's ONLY purpose is to produce /app/build — the static SPA output
# (HTML/CSS/JS/manifest/service worker). There is no Node runtime in
# production: per docs/PLAN.md's "Stack" and "The PWA decision", Caddy serves
# these files directly and proxies /api/* to the FastAPI backend. This image
# is never run as a long-lived container in production.
#
# Build context: this directory (apps/web), e.g. `docker build -f
# apps/web/Dockerfile apps/web`.
#
# How deploy/ is expected to consume this: build this image, then copy its
# /app/build contents out into a location the `caddy` service in
# deploy/docker-compose.yml bind-mounts — either via a multi-stage
# `COPY --from=bennybergle/velodrome-web:<tag> /app/build /srv/web` in the
# Caddy image build, or with a one-shot `docker create` + `docker cp` /
# `docker run --rm -v ...` step in the deploy pipeline that dumps /app/build
# into a named volume or bind-mounted host directory before `caddy` starts.
# Picked this over a scratch/busybox "artifact-holder" final stage because a
# single builder stage is simpler to reason about and there's nothing here
# that needs to run — the consumer only ever needs the files, not a container.
# The deploy/ agent may adjust this to whatever's simplest for the compose
# setup; this is just the contract (build → /app/build).
FROM node:22-slim AS builder
WORKDIR /app
# Install dependencies first, isolated from source changes, for layer caching.
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@9 --activate \
&& pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
# Nothing further: /app/build is the artifact. No CMD/ENTRYPOINT — this image
# is not meant to be run.
+99 -1
View File
@@ -1 +1,99 @@
SvelteKit static SPA, installable PWA. Not yet scaffolded — Phase 0.
# 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.
+43
View File
@@ -0,0 +1,43 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
// No svelte.config.js in this project — SvelteKit's config (adapter, etc.)
// lives inline in vite.config.ts's sveltekit() plugin call, and there are no
// non-default Svelte preprocessors, so eslint-plugin-svelte needs nothing
// from it.
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
},
{
// The service worker runs in a webworker global scope, not a browser
// window — see src/service-worker.ts's own triple-slash lib directives.
files: ['src/service-worker.ts'],
languageOptions: {
globals: { ...globals.serviceworker }
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/', 'node_modules/']
}
);
+44
View File
@@ -0,0 +1,44 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@vite-pwa/sveltekit": "^1.1.0",
"eslint": "^10.11.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.12.0",
"prettier": "^3.9.8",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vite-plugin-pwa": "^1.3.0",
"workbox-build": "^7.4.1",
"workbox-core": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"workbox-window": "^7.4.1"
}
}
+5430
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
:root {
--color-bg: #f5f6f8;
--color-surface: #ffffff;
--color-surface-raised: #ffffff;
--color-text: #14161a;
--color-text-muted: #5b6169;
--color-border: #dfe2e7;
--color-accent: #2f6fed;
--color-accent-contrast: #ffffff;
--color-danger: #d1453b;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f1115;
--color-surface: #16191f;
--color-surface-raised: #1e222a;
--color-text: #e8e8ec;
--color-text-muted: #9198a3;
--color-border: #2a2f38;
--color-accent: #5b8dfd;
--color-accent-contrast: #0f1115;
--color-danger: #ff6b60;
}
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
a {
color: var(--color-accent);
}
input,
button {
font: inherit;
}
+18
View File
@@ -0,0 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// Ambient type for the `virtual:pwa-register/svelte` module used by
// src/lib/components/UpdateToast.svelte (see vite-plugin-pwa/svelte.d.ts).
/// <reference types="vite-plugin-pwa/svelte" />
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="text-scale" content="scale" />
<meta name="theme-color" content="#0f1115" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png" />
<!-- iOS ignores rel=manifest for install chrome; these are the standalone-mode hooks. -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Velodrome" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
export type LoginResult = { ok: true } | { ok: false; reason: 'invalid-credentials' | 'network' };
/**
* POSTs credentials to /api/v1/auth/login. On success the API sets an HttpOnly
* session cookie in the response — we never see or handle the token itself, we
* just let the browser store it (same-origin, so this works through Caddy in
* production and through the dev proxy in vite.config.ts locally).
*/
export async function login(email: string, password: string): Promise<LoginResult> {
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password })
});
if (res.ok) {
return { ok: true };
}
if (res.status === 401) {
return { ok: false, reason: 'invalid-credentials' };
}
return { ok: false, reason: 'network' };
} catch {
return { ok: false, reason: 'network' };
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,96 @@
<script lang="ts">
// Phase 0: purely informational. No Notification.requestPermission() call
// anywhere here — that's gated behind standalone mode in a later phase, per
// docs/PLAN.md's PWA-decision table ("request only from a direct user
// gesture, only in standalone, only after an explanatory screen").
const DISMISS_KEY = 'ios-install-banner-dismissed';
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches;
}
function isIosSafari(): boolean {
const ua = window.navigator.userAgent;
const isIos =
/iPad|iPhone|iPod/.test(ua) || (ua.includes('Macintosh') && navigator.maxTouchPoints > 1);
// Exclude in-app browsers / other iOS browser shells that spoof Safari's
// UA but can't install (CriOS, FxiOS, and generic wrappers with "wv").
const isOtherBrowser = /CriOS|FxiOS|EdgiOS|OPiOS|mercury|wv\)/.test(ua);
return isIos && !isOtherBrowser;
}
// Reappears each session (sessionStorage, not localStorage) until the user
// actually installs, per the task: dismissible but not permanently.
let dismissed = $state(false);
let show = $state(false);
$effect(() => {
try {
dismissed = sessionStorage.getItem(DISMISS_KEY) === '1';
} catch {
dismissed = false;
}
show = !isStandalone() && isIosSafari();
});
function dismiss() {
dismissed = true;
try {
sessionStorage.setItem(DISMISS_KEY, '1');
} catch {
// Private browsing or storage disabled — banner just won't remember
// the dismissal for this session, which is a harmless fallback.
}
}
</script>
{#if show && !dismissed}
<div class="ios-banner" role="note">
<button class="close" type="button" aria-label="Dismiss" onclick={dismiss}>×</button>
<p><strong>Install Velodrome</strong> for offline access and reminders:</p>
<ol>
<li>Tap the Share icon in Safari's toolbar.</li>
<li>Scroll down and tap "Add to Home Screen".</li>
<li>Tap "Add" — Velodrome now opens full-screen, like a native app.</li>
</ol>
</div>
{/if}
<style>
.ios-banner {
position: relative;
margin: 0.75rem;
padding: 0.85rem 2.25rem 0.85rem 0.9rem;
border-radius: 0.6rem;
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
font-size: 0.85rem;
line-height: 1.4;
}
p {
margin: 0 0 0.4rem;
}
ol {
margin: 0;
padding-left: 1.2rem;
}
li {
margin-bottom: 0.15rem;
}
.close {
position: absolute;
top: 0.4rem;
right: 0.5rem;
border: none;
background: transparent;
color: var(--color-text-muted);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/svelte';
// needRefresh flips true once a new service worker is waiting; calling
// updateServiceWorker() sends skipWaiting and reloads once the new SW takes
// control (see src/service-worker.ts's `skipWaiting()` / `clientsClaim()`).
const { needRefresh, updateServiceWorker } = useRegisterSW({
onRegisterError(error) {
// Non-fatal: the app works without a service worker, just without
// offline/instant-open. Don't surface this to the user.
console.error('service worker registration failed', error);
}
});
let reloading = $state(false);
async function reload() {
reloading = true;
await updateServiceWorker(true);
}
</script>
{#if $needRefresh}
<div class="update-toast" role="status">
<span>New version available.</span>
<button type="button" onclick={reload} disabled={reloading}>
{reloading ? 'Reloading…' : 'Reload'}
</button>
</div>
{/if}
<style>
.update-toast {
position: fixed;
left: 50%;
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 1rem;
border-radius: 0.6rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
box-shadow: 0 4px 20px rgb(0 0 0 / 0.25);
font-size: 0.9rem;
}
button {
border: none;
border-radius: 0.4rem;
padding: 0.35rem 0.7rem;
background: var(--color-accent);
color: var(--color-accent-contrast);
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
</style>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+46
View File
@@ -0,0 +1,46 @@
import { writable } from 'svelte/store';
export interface AuthUser {
id: string;
email: string;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'authenticated'; user: AuthUser }
| { status: 'unauthenticated' };
function createAuthStore() {
const { subscribe, set } = writable<AuthState>({ status: 'unknown' });
/** Calls GET /api/v1/auth/me and updates the store. Never throws. */
async function refresh(): Promise<AuthState> {
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (res.ok) {
const user = (await res.json()) as AuthUser;
const next: AuthState = { status: 'authenticated', user };
set(next);
return next;
}
} catch {
// Network failure: fall through to unauthenticated. The login page's own
// fetch will surface a retry message if the user tries to act.
}
const next: AuthState = { status: 'unauthenticated' };
set(next);
return next;
}
async function logout(): Promise<void> {
try {
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' });
} finally {
set({ status: 'unauthenticated' });
}
}
return { subscribe, refresh, logout };
}
export const auth = createAuthStore();
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { auth } from '$lib/stores/auth';
import UpdateToast from '$lib/components/UpdateToast.svelte';
import IosInstallBanner from '$lib/components/IosInstallBanner.svelte';
let { children } = $props();
// One auth check per app load; routes react to the store rather than each
// re-fetching /api/v1/auth/me themselves.
void auth.refresh();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="app-shell">
<IosInstallBanner />
{@render children()}
<UpdateToast />
</div>
<style>
.app-shell {
min-height: 100dvh;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
// Static SPA: nothing here is ever server-rendered or prerendered. Caddy serves the
// build output as files and falls back to index.html for unknown paths (see
// vite.config.ts's adapter-static config, `fallback: 'index.html'`).
export const ssr = false;
export const prerender = false;
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { auth } from '$lib/stores/auth';
$effect(() => {
if ($auth.status === 'unauthenticated') {
goto(resolve('/login'));
}
});
</script>
{#if $auth.status === 'authenticated'}
<main class="dashboard">
<div class="header-row">
<h1>Velodrome</h1>
<button
type="button"
class="logout"
onclick={async () => {
await auth.logout();
goto(resolve('/login'));
}}
>
Log out
</button>
</div>
<p class="signed-in-as">Signed in as {$auth.user.email}</p>
<p class="empty-state">No rides yet.</p>
</main>
{:else if $auth.status === 'unknown'}
<main class="dashboard" aria-busy="true">
<p>Loading…</p>
</main>
{/if}
<style>
.dashboard {
max-width: 32rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.header-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.75rem;
}
.logout {
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
border-radius: 0.4rem;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.signed-in-as {
margin: 0 0 2rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.empty-state {
color: var(--color-text-muted);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { login } from '$lib/api/auth';
import { auth } from '$lib/stores/auth';
// Already signed in? Don't show the form.
$effect(() => {
if ($auth.status === 'authenticated') {
goto(resolve('/'));
}
});
let email = $state('');
let password = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (submitting) return;
submitting = true;
error = null;
const result = await login(email, password);
if (result.ok) {
await auth.refresh();
await goto(resolve('/'));
return;
}
error =
result.reason === 'invalid-credentials'
? 'Invalid email or password.'
: 'Could not reach the server. Check your connection and try again.';
submitting = false;
}
</script>
<main class="login">
<h1>Velodrome</h1>
<form onsubmit={handleSubmit}>
<label>
Email
<input type="email" name="email" autocomplete="username" required bind:value={email} />
</label>
<label>
Password
<input
type="password"
name="password"
autocomplete="current-password"
required
bind:value={password}
/>
</label>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<button type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</main>
<style>
.login {
max-width: 22rem;
margin: 3rem auto;
padding: 0 1rem;
}
h1 {
margin: 0 0 1.5rem;
font-size: 1.5rem;
text-align: center;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
input {
padding: 0.55rem 0.65rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-size: 1rem;
}
button {
margin-top: 0.5rem;
padding: 0.6rem;
border: none;
border-radius: 0.4rem;
background: var(--color-accent);
color: var(--color-accent-contrast);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.error {
margin: 0;
color: var(--color-danger);
font-size: 0.85rem;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
/// <reference lib="webworker" />
// Hand-written service worker (injectManifest strategy, not generateSW) — see
// vite.config.ts for why. Keep this file's job narrow: caching and (later)
// displaying pushes. Nothing here should be load-bearing for the app to work,
// per docs/PLAN.md's "Aggressive service-worker termination" row.
import { clientsClaim } from 'workbox-core';
import {
cleanupOutdatedCaches,
createHandlerBoundToURL,
precacheAndRoute
} from 'workbox-precaching';
import { NavigationRoute, registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
import type { PrecacheEntry } from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<PrecacheEntry | string>;
};
// Activate the new SW as soon as it's done installing, and take control of
// open tabs immediately, rather than waiting for a reload. Paired with the
// in-app "New version available" prompt (src/lib/components/UpdateToast.svelte)
// so this never surprises the user mid-session.
self.skipWaiting();
clientsClaim();
// Precache the injected manifest: hashed build assets plus the SPA fallback
// index.html (kit.spa / kit.adapterFallback in vite.config.ts adds it here).
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// --- Navigations: cache-first app shell ------------------------------------
// Every navigation (including deep links SvelteKit's client router will take
// over from) resolves from the precached index.html, so the app opens
// instantly and offline. This is the app-shell pattern generateSW's
// `navigateFallback` gives you for free; we do it by hand here because
// everything else in this file needs injectManifest.
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html')));
// --- API: network-only, always ----------------------------------------------
// Never cache anything under /api/ in this phase. An API response cached by
// the service worker is a correctness bug here, not a UX nicety — see
// CLAUDE.md's invariants and docs/PLAN.md's caching table ("network-only on
// all mutations, never silently cached"). NetworkOnly also means a request
// that fails offline fails loudly instead of silently returning stale data.
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
// --- Future runtime caches ---------------------------------------------------
// Deliberately empty for Phase 0. Later phases add, e.g.:
// registerRoute(({url}) => url.pathname.startsWith('/tiles/'), new CacheFirst({...}))
// registerRoute(({url}) => url.pathname === '/garage', new NetworkFirst({ networkTimeoutSeconds: 3, ... }))
// Keep those as separate registerRoute() calls below this comment, one route
// per surface, so the caching policy stays legible route-by-route.
Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+30
View File
@@ -0,0 +1,30 @@
{
"name": "Velodrome",
"short_name": "Velodrome",
"description": "Self-hosted cycling ride sync, mileage tracking, and maintenance reminders.",
"start_url": "/?src=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#0f1115",
"theme_color": "#0f1115",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+94
View File
@@ -0,0 +1,94 @@
import adapter from '@sveltejs/adapter-static';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
// The adapter-static SPA fallback (build/index.html) is written by the adapter
// *after* every Vite plugin's build has finished, so at service-worker-build
// time there's no file for injectManifest to glob and hash. We instead give it
// a synthetic precache entry (below) with a revision that's fresh per build
// invocation, so the shell still gets correctly invalidated on every deploy.
const swIndexRevision = String(Date.now());
export default defineConfig({
server: {
proxy: {
// Local dev only. In production Caddy does this same-origin proxy
// (see deploy/Caddyfile) — the app only ever calls same-origin /api/v1.
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
},
plugins: [
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// SvelteKit has its own built-in convention for src/service-worker.{js,ts}:
// it auto-detects the file by this exact path and runs its own separate,
// very restricted build for it (only $service-worker, $env/static/public and
// $app/env/public may be imported — nothing from npm). vite-plugin-pwa (below)
// bundles and injects our actual service worker instead; if both run, the
// SvelteKit's native pass executes *after* vite-plugin-pwa's and silently
// overwrites its output with an un-bundled, un-injected file (the
// self.__WB_MANIFEST placeholder is left as literal text — the app "works"
// but nothing is ever precached). Point SvelteKit's lookup at a path that
// doesn't exist so only vite-plugin-pwa's build runs.
files: {
serviceWorker: 'src/service-worker-disabled'
},
// Static SPA: no Node process in production. Caddy serves the build output
// directly and falls back to index.html for client-side routes (see
// docs/PLAN.md "Stack" and "The PWA decision"). Pair with `export const ssr =
// false` in src/routes/+layout.ts — without that, SvelteKit still tries to
// prerender/SSR routes and the static adapter fails the build.
adapter: adapter({
fallback: 'index.html'
})
}),
VitePWA({
// injectManifest, not generateSW: we hand-write the caching policy (cache-first
// navigations, network-only for /api/*) in src/service-worker.ts. generateSW's
// declarative config can't express "never cache this origin path" as precisely
// as we want here — see docs/PLAN.md's caching table.
//
// We use the base vite-plugin-pwa plugin rather than @vite-pwa/sveltekit's
// SvelteKit-specific wrapper: that wrapper's injectManifest build expects
// SvelteKit's own built-in src/service-worker.{js,ts} convention to have
// already transpiled our file, which only resolves SvelteKit's own three
// virtual modules and rejects anything else. The base plugin bundles
// src/service-worker.ts itself directly, which is what we want here.
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
// We own the manifest as a plain static file (static/manifest.webmanifest),
// linked explicitly from app.html, instead of letting the plugin generate one.
manifest: false,
// We register the service worker ourselves via virtual:pwa-register/svelte
// (see src/lib/components/UpdateToast.svelte) so we can show an in-app update
// prompt instead of the plugin's default injected register script.
injectRegister: false,
injectManifest: {
injectionPoint: 'self.__WB_MANIFEST',
// Default globPatterns is just JS/CSS/HTML — widen it to also precache
// our icons and manifest so an offline install has everything it needs.
globPatterns: ['**/*.{js,css,svg,png,webmanifest}'],
// See the swIndexRevision comment above: the real build/index.html
// doesn't exist yet when this runs, so we can't glob-hash it.
additionalManifestEntries: [{ url: 'index.html', revision: swIndexRevision }]
},
devOptions: {
// Keep the SW out of `pnpm dev` — dev already has instant HMR, and a dev-mode
// SW is a common source of "why is my change not showing up" confusion.
enabled: false,
type: 'module'
}
})
]
});