feat(web): SvelteKit PWA shell with login
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:
@@ -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;
|
||||
}
|
||||
Vendored
+18
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user