Files
bike-app/apps/web/src/lib/components/IosInstallBanner.svelte
T
BBergleandClaude Sonnet 5 7bef79fae5
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
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>
2026-09-21 08:18:36 -04:00

97 lines
2.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>