feat(web): SvelteKit PWA shell with login #3

Merged
BBergle merged 2 commits from feat/web-shell into main 2026-09-21 08:27:39 -04:00
32 changed files with 6445 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"
},
"packageManager": "pnpm@9.15.9",
"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",
"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"
}
}
+5400
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'
}
})
]
});