28 KiB
Phase 5: Web Push Notifications — Pattern Map
Mapped: 2026-06-09 Files analyzed: 16 new/modified files Analogs found: 15 / 16
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
apps/api/src/db/schema.ts (add pushSubscriptions) |
model | CRUD | same file — existing listShares / memberCredentials tables |
exact |
apps/api/src/db/migrations/0003_*.sql |
migration | — | apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql |
exact |
apps/api/src/routes/push.ts |
route/controller | request-response | apps/api/src/routes/lists.ts |
exact |
apps/api/src/lib/pushDispatcher.ts |
utility | request-response | apps/api/src/lib/listEmitter.ts (module-singleton pattern) |
role-match |
apps/api/src/lib/pushCoalescer.ts |
utility | event-driven | apps/api/src/lib/listEmitter.ts (in-memory singleton) |
role-match |
apps/api/src/lib/eventChangeDispatcher.ts |
service | event-driven | apps/api/src/lib/listEmitter.ts + apps/api/src/broker/sync.ts (hook point) |
partial |
apps/api/src/broker/reminderScheduler.ts |
service/worker | batch | apps/api/src/broker/poller.ts |
exact |
apps/api/src/index.ts (wire push routes + scheduler) |
config | — | same file — startBrokerPoller / startOutboxWorker startup pattern |
exact |
apps/api/test/setup.ts (add pushSubscriptions truncation) |
test | — | same file — existing truncation pattern | exact |
apps/api/tests/routes/push.test.ts |
test | request-response | apps/api/tests/routes/lists.test.ts |
exact |
apps/api/tests/lib/pushDispatcher.test.ts |
test | — | apps/api/tests/lib/ unit test pattern |
role-match |
apps/api/tests/lib/pushCoalescer.test.ts |
test | — | apps/api/tests/lib/ unit test pattern |
role-match |
apps/api/tests/broker/reminderScheduler.test.ts |
test | — | apps/api/tests/broker/ broker test pattern |
role-match |
apps/pwa/src/sw.ts (new custom SW) |
config/service-worker | event-driven | apps/pwa/vite.config.ts (current generateSW options to preserve) |
partial |
apps/pwa/vite.config.ts (migrate to injectManifest) |
config | — | same file | exact |
apps/pwa/src/components/PushPermissionPrompt.tsx |
component | request-response | apps/pwa/src/components/InstallPrompt.tsx (WalkthroughSheet) |
exact |
apps/pwa/src/components/SettingsSheet.tsx |
component | request-response | apps/pwa/src/components/CreateListSheet.tsx + InstallPrompt.tsx |
exact |
apps/pwa/src/components/PermissionDeniedBanner.tsx |
component | — | apps/pwa/src/components/InstallPrompt.tsx (iOS banner layout) |
exact |
apps/pwa/src/hooks/usePushSubscription.ts |
hook | request-response | apps/pwa/src/components/InstallPrompt.tsx (useAndroidInstallPrompt) |
role-match |
apps/pwa/src/components/AppNav.tsx (promote avatar to button) |
component | — | same file | exact |
apps/pwa/src/App.tsx (mount new surfaces) |
component | — | same file | exact |
apps/pwa/src/components/InstallPrompt.tsx (add push trigger) |
component | — | same file | exact |
Pattern Assignments
apps/api/src/db/schema.ts — add pushSubscriptions table
Analog: same file — listShares table (lines 208–224) and memberCredentials table (lines 55–69)
Imports pattern (lines 1–14):
import {
mysqlTable,
mysqlEnum,
varchar,
text,
int,
timestamp,
index,
unique,
// customType if collation needed — see varcharBin pattern lines 22–25
} from 'drizzle-orm/mysql-core'
Core table pattern — copy listShares structure (lines 208–224):
// listShares: userId FK with cascade, composite unique, index on userId
export const listShares = mysqlTable(
'list_shares',
{
id: int().primaryKey().autoincrement(),
listId: int('list_id').notNull().references(() => lists.id, { onDelete: 'cascade' }),
userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [
unique('uniq_list_share').on(t.listId, t.userId),
index('idx_list_shares_user_id').on(t.userId),
],
)
pushSubscriptions uses the same FK + unique + index structure. endpoint is globally unique (one endpoint per device across all users). text columns for long subscription fields (endpoint, p256dh); varchar(256) for auth. No customType needed — no special collation required for push subscription strings.
Migration constraint: Never db:push. Always:
pnpm --filter @familysync/api db:generate
pnpm --filter @familysync/api db:migrate
Next migration file: apps/api/src/db/migrations/0003_<generated-name>.sql
apps/api/src/routes/push.ts (POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key)
Analog: apps/api/src/routes/lists.ts (lines 1–70)
Imports pattern (lines 20–34):
import { Hono } from 'hono'
import type { Context } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { pushSubscriptions } from '../db/schema.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import '../auth/devBypass.js'
Auth helper pattern — copy verbatim from lists.ts lines 57–69:
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const auth = await getAuth(c)
if (!auth) return null
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const displayName = deriveDisplayName(auth)
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
}
Zod validation pattern — copy createListSchema style from lists.ts line 78:
const subscribeSchema = z.object({
endpoint: z.string().url().max(2048),
keys: z.object({
p256dh: z.string().min(1).max(512),
auth: z.string().min(1).max(256),
}),
})
Route handler pattern — copy the POST handler structure from lists.ts:
export const pushRouter = new Hono()
// GET /api/push/vapid-public-key — unauthenticated; serves the public VAPID key to the PWA
pushRouter.get('/vapid-public-key', (c) => {
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' })
})
// POST /api/push/subscription — subscribe (authenticated)
pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c) => {
const userId = await resolveUserId(c)
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
const body = c.req.valid('json')
// upsert: one endpoint may belong to one user; unique constraint on endpoint
await db.insert(pushSubscriptions).values({
userId,
endpoint: body.endpoint,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
}).onDuplicateKeyUpdate({ set: { userId, p256dh: body.keys.p256dh, auth: body.keys.auth } })
return c.json({ ok: true }, 201)
})
// DELETE /api/push/subscription — unsubscribe (authenticated)
pushRouter.delete('/subscription', async (c) => {
const userId = await resolveUserId(c)
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId))
return c.json({ ok: true })
})
Mount pattern — add to apps/api/src/index.ts after other route mounts (line 66):
import { pushRouter } from './routes/push.js'
// ...
app.route('/api/push', pushRouter)
apps/api/src/lib/pushDispatcher.ts
Analog: apps/api/src/lib/listEmitter.ts (module singleton pattern, lines 1–54)
Module structure — same module-level singleton with a clear export surface:
// listEmitter.ts singleton pattern (lines 17–22):
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
emitter.setMaxListeners(200)
export function publishListEvent(...) { emitter.emit(...) }
export function subscribeListEvents(...) { ... }
pushDispatcher.ts uses webpush (initialized once at module load / startup) as the singleton:
import webpush from 'web-push' // default import — web-push is CommonJS (Pitfall 7)
// setVapidDetails called once from index.ts isMainModule() guard, NOT at module scope
Error handling pattern: 410/404 prune (no analog exists — use RESEARCH.md Pattern 1). Log errors with console.error('[pushDispatcher] ...') prefix matching the broker pattern used in poller.ts line 71 and outboxWorker.ts line 611.
apps/api/src/lib/pushCoalescer.ts
Analog: apps/api/src/lib/listEmitter.ts (in-memory module-level Map singleton)
Module pattern — module-level Map, no external dependencies:
// listEmitter.ts pattern: module-level singleton never exported directly
const emitter = new EventEmitter() // ← same: Map<string, ...> as module-level singleton
pushCoalescer.ts uses a Map<string, { count: number; timer: ReturnType<typeof setTimeout> }> keyed by ${listId}:${actorId}. The module exports a single function — same minimal API surface as publishListEvent.
apps/api/src/lib/eventChangeDispatcher.ts
Analog: apps/api/src/lib/listEmitter.ts (dispatch pattern) + apps/api/src/broker/sync.ts (hook point)
No existing event-change dispatcher exists. This is a new module called from inside syncCalendar (or a callback passed to it) after the DB upsert detects a changed event. Pattern: export a single dispatchEventChange(event, actorUserId) function that queries pushSubscriptions and calls pushDispatcher. Mirror the publishListEvent single-function export idiom.
apps/api/src/broker/reminderScheduler.ts
Analog: apps/api/src/broker/poller.ts (lines 1–89) — exact structural match
Imports pattern (lines 16–25 of poller.ts):
import { schedule } from 'node-cron'
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberCredentials, calendars } from '../db/schema.js'
// reminderScheduler adds: calendarEvents, pushSubscriptions
Cron schedule pattern (lines 83–89 of poller.ts):
export function startBrokerPoller(): void {
schedule('*/5 * * * *', () => {
runPoll().catch((err: unknown) => {
console.error('[broker/poller] Unhandled runPoll error:', err)
})
})
}
reminderScheduler.ts uses the same export shape:
export function startReminderScheduler(): void {
schedule('* * * * *', () => { // every minute (not */5)
runReminderCheck().catch((err: unknown) => {
console.error('[broker/reminderScheduler] Unhandled error:', err)
})
})
}
Per-credential error isolation (lines 68–76 of poller.ts):
try {
// ... per-item work
} catch (err) {
console.error(
`[broker/poller] Error processing ...`,
err instanceof Error ? err.message : String(err),
)
}
Copy this catch shape for per-event and per-subscription errors in the scheduler.
Startup wire-in — apps/api/src/index.ts lines 107–113:
if (isMainModule()) {
startBrokerPoller()
startOutboxWorker()
// Add:
startReminderScheduler()
// Also: webpush.setVapidDetails(...) here, before the scheduler starts
serve(...)
}
Reminder deduplication: Use an in-memory Set<string> of ${eventUid}:${minuteBucket} (acceptable for single-process deployment per D-12). Reset on process restart — two-person household, acceptable data loss on restart.
apps/api/src/index.ts (modifications)
Pattern: lines 107–116 (isMainModule guard). Add startReminderScheduler() and webpush.setVapidDetails() inside the same guard. Add app.route('/api/push', pushRouter) at line 66 alongside other route mounts.
apps/api/test/setup.ts (add push_subscriptions truncation)
Analog: same file, lines 27–37
Current pattern:
afterEach(async () => {
try {
await db.delete(listItems)
await db.delete(listShares)
await db.delete(lists)
} catch { /* swallow */ }
})
Add await db.delete(pushSubscriptions) before the lists delete (no FK dependency on lists; delete in any order relative to lists, but after listItems / listShares).
apps/api/tests/routes/push.test.ts
Analog: apps/api/tests/routes/lists.test.ts (lines 1–100) — exact pattern
Mock boilerplate (lines 32–44 of lists.test.ts):
let currentDevUserId = 1
vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
c.set('user', { id: currentDevUserId })
await next()
},
}))
vi.mock('@hono/oidc-auth', () => ({
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
getAuth: () => null,
}))
Lazy app import (lines 77–80 of lists.test.ts):
async function getApp() {
const { app } = await import('../../src/index.js')
return app
}
Request helper (lines 86–92):
function jsonRequest(method: string, path: string, body?: unknown): Request {
return new Request(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
Seed helper (lines 50–58):
async function seedUser(label: string): Promise<number> {
const [result] = await db.insert(users).values({
oidcIss: 'https://auth.test',
oidcSub: `sub-${label}-${randomUUID()}`,
displayName: `User ${label}`,
color: '#4A90D9',
}).$returningId()
return result.id
}
apps/pwa/vite.config.ts (migrate generateSW → injectManifest)
Analog: same file (lines 1–48) — migrate in-place
Current config to preserve (lines 8–40):
VitePWA({
registerType: 'autoUpdate',
workbox: {
navigateFallback: '/index.html',
navigateFallbackDenylist: [
/^\/callback/, // CRITICAL: T-03-20 — must not be lost in migration
/^\/api\//,
/^\/health/,
],
runtimeCaching: [],
},
manifest: {
name: 'FamilySync', short_name: 'FamilySync',
description: 'Family calendar and lists',
theme_color: '#4A90D9', background_color: '#ffffff',
display: 'standalone', scope: '/', start_url: '/',
icons: [...]
},
})
Target config — replace workbox: {} with strategies: 'injectManifest':
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'autoUpdate',
injectManifest: {
globIgnores: ['**/node_modules/**', '**/callback**'],
},
manifest: { /* identical to current manifest block */ },
})
navigateFallback / navigateFallbackDenylist / runtimeCaching move OUT of workbox:{} and are re-implemented explicitly in sw.ts (see below).
apps/pwa/src/sw.ts (new custom service worker)
No exact analog in codebase — no existing custom SW. Use RESEARCH.md Patterns 2 and the code examples for navigateFallback preservation.
Critical constraints from codebase inspection (must preserve):
navigateFallbackDenylist:/^\/callback/,/^\/api\//,/^\/health/(fromvite.config.tslines 16–19, T-03-20)runtimeCaching: []— no API caching (line 22)autoUpdatebehavior:self.skipWaiting()+clientsClaim()(replaces generateSW auto-behavior)- Every push MUST call
event.waitUntil(showNotification(...))— iOS revokes after ~3 silent pushes (D-11)
Required devDependencies (not yet installed):
pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core workbox-routing
apps/pwa/src/components/PushPermissionPrompt.tsx
Analog: apps/pwa/src/components/InstallPrompt.tsx — WalkthroughSheet sub-component (lines 121–269)
Bottom sheet layout pattern (lines 122–152 of InstallPrompt.tsx):
<div
role="dialog"
aria-modal="true"
aria-label="Add to Home Screen walkthrough" // ← change to "Enable push notifications"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.5))',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
zIndex: 1000,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'var(--color-surface, #ffffff)',
borderRadius: '12px 12px 0 0',
padding: 'var(--space-6, 24px)',
maxHeight: '90dvh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4, 16px)',
}}>
CRITICAL difference from WalkthroughSheet: Per UI-SPEC Surface 1, the permission prompt backdrop does NOT dismiss on click (permission UX must be explicit). Remove the onClick backdrop-dismiss from the outer div.
Header with close button (lines 153–192 of InstallPrompt.tsx):
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{
margin: 0,
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 'var(--text-heading-weight, 600)',
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}>Stay in the loop</h2>
<button onClick={onDismiss} aria-label="Dismiss"
style={{ background: 'none', border: 'none', cursor: 'pointer',
minWidth: '44px', minHeight: '44px', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: 'var(--color-text-secondary, #5c6472)',
borderRadius: 'var(--space-1, 4px)' }}>
<X size={20} aria-hidden="true" />
</button>
</div>
Primary CTA button — accent color pattern from Android banner install button (lines 445–462 of InstallPrompt.tsx):
<button onClick={handleEnableClick}
style={{
background: 'var(--color-member-0, #4A90D9)', // ← accent, not --color-text-primary
color: '#ffffff',
border: 'none',
borderRadius: 'var(--space-1, 4px)',
minHeight: '48px', // 48px per UI-SPEC (not 44px)
padding: '0 var(--space-4, 16px)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
alignSelf: 'stretch',
}}>
Enable Notifications
</button>
localStorage guard pattern (lines 284–297 of InstallPrompt.tsx):
function readDismissed(): boolean {
try { return localStorage.getItem('installPromptDismissed') === '1' } catch { return false }
}
function persistDismissed(): void {
try { localStorage.setItem('installPromptDismissed', '1') } catch { /* ignore */ }
}
Copy for pushPermissionDismissed key.
apps/pwa/src/components/SettingsSheet.tsx
Analog: apps/pwa/src/components/CreateListSheet.tsx (lines 1–60) for sheet lifecycle, plus InstallPrompt.tsx WalkthroughSheet for layout
Sheet open/close pattern (CreateListSheet.tsx lines 28–60):
// CreateListSheet uses zustand store for open state
const isOpen = useListsStore((s) => s.createListSheetOpen)
const setOpen = useListsStore((s) => s.setCreateListSheetOpen)
// Escape key listener
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen])
SettingsSheet uses a local isOpen prop or zustand UI store — match whichever pattern the planner selects for the avatar trigger. Escape key listener is mandatory (copy pattern above).
Backdrop — same z-index layering as CreateListSheet: backdrop at zIndex: 300, sheet at zIndex: 301. Backdrop click closes (unlike PushPermissionPrompt).
Section label style (matches existing "Calendars" label in DesktopNav per UI-SPEC):
<div style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-muted, #9CA3AF)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: 'var(--space-2, 8px)',
}}>Notifications</div>
Toggle — inline role="switch", 44px touch target, aria-checked. No existing toggle analog in the codebase — implement inline in SettingsSheet following the button style pattern from InstallPrompt.
apps/pwa/src/components/PermissionDeniedBanner.tsx
Analog: apps/pwa/src/components/InstallPrompt.tsx — iOS banner layout (lines 321–406)
Banner layout pattern (lines 322–337 of InstallPrompt.tsx):
<div
role="banner" // ← change to role="alert" for PermissionDeniedBanner
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
background: 'var(--color-surface-raised, #ffffff)',
borderBottom: '1px solid var(--color-border, #e2e4e9)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
Inline link style (lines 363–374 of InstallPrompt.tsx):
<button onClick={() => setWalkthroughOpen(true)}
style={{
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
fontSize: '13px',
color: 'var(--color-focus-ring, #4A90D9)',
textDecoration: 'underline',
fontFamily: 'inherit',
}}>
How to enable
</button>
No dismiss button — banner is persistent until OS permission restored (UI-SPEC Surface 3).
apps/pwa/src/hooks/usePushSubscription.ts
Analog: apps/pwa/src/components/InstallPrompt.tsx — useAndroidInstallPrompt hook (lines 76–105)
Hook structure (lines 76–105 of InstallPrompt.tsx):
export function useAndroidInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<...>(null)
useEffect(() => {
const handler = (e: Event) => { ... }
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', installedHandler)
return () => { window.removeEventListener(...) }
}, [])
const triggerInstall = async () => { ... }
return { canInstall: ..., triggerInstall }
}
usePushSubscription follows the same shape: useEffect for health-check on mount (D-10 silent re-subscribe), returns { subscribe, unsubscribe, permission }. CRITICAL: subscribe() must NOT be called inside useEffect or any async boundary — it must be called directly inside the onClick handler of the "Enable Notifications" button (iOS user-gesture requirement, D-08/Pitfall 2).
localStorage guard — copy readDismissed / persistDismissed pattern from InstallPrompt.tsx lines 284–297 for notificationsEnabled key.
apps/pwa/src/components/AppNav.tsx (promote avatar to button)
Analog: same file lines 73–80 (current avatar div)
Current pattern (lines 73–80 of AppNav.tsx):
<div
style={{
width: '32px', height: '32px', borderRadius: '50%',
background: color,
display: 'flex', alignItems: 'center',
Promote to <button> with onClick opening SettingsSheet. Copy 44px touch target pattern from InstallPrompt dismiss button (lines 382–396):
<button
onClick={onOpenSettings}
aria-label={`${displayName} — open settings`}
style={{
background: 'none', border: 'none', cursor: 'pointer',
minWidth: '44px', minHeight: '44px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0,
borderRadius: 'var(--space-1, 4px)',
}}
>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', background: color, ... }} />
</button>
Shared Patterns
Auth (all API routes)
Source: apps/api/src/routes/lists.ts lines 57–69
Apply to: apps/api/src/routes/push.ts
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const auth = await getAuth(c)
if (!auth) return null
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const displayName = deriveDisplayName(auth)
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
}
Note: comment in lists.ts says "Duplicated per router (not extracted to shared module)" — maintain that convention.
Error logging (all broker/lib files)
Source: apps/api/src/broker/poller.ts line 70–76, apps/api/src/broker/outboxWorker.ts line 611
Apply to: pushDispatcher.ts, reminderScheduler.ts, eventChangeDispatcher.ts
console.error(
`[broker/reminderScheduler] Error processing ...:`,
err instanceof Error ? err.message : String(err),
)
Never log the decrypted app password (T-03-13). Log err.message not the full err object.
Background worker startup guard
Source: apps/api/src/index.ts lines 95–116
Apply to: startReminderScheduler() call + webpush.setVapidDetails() initialization
if (isMainModule()) {
startBrokerPoller()
startOutboxWorker()
// Phase 5 additions:
webpush.setVapidDetails('mailto:admin@...', process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!)
startReminderScheduler()
serve(...)
}
Bottom sheet layout (all PWA sheet components)
Source: apps/pwa/src/components/InstallPrompt.tsx WalkthroughSheet lines 121–152
Apply to: PushPermissionPrompt.tsx, SettingsSheet.tsx
Key values: borderRadius: '12px 12px 0 0', padding: 'var(--space-6, 24px)', zIndex: 1000 (permission prompt) or zIndex: 301 (settings sheet).
44px touch target (all interactive elements)
Source: apps/pwa/src/components/InstallPrompt.tsx lines 382–396 (dismiss button)
Apply to: All buttons in PushPermissionPrompt.tsx, SettingsSheet.tsx, PermissionDeniedBanner.tsx, AppNav.tsx
style={{ minWidth: '44px', minHeight: '44px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
Token CSS variables (all PWA components)
Source: apps/pwa/src/styles/tokens.css (read by UI-SPEC)
Apply to: All Phase 5 PWA components
Never hardcode hex values — always use var(--color-*, fallback). Key tokens for this phase:
var(--color-member-0, #4A90D9)— accent/CTAvar(--color-destructive, #DC2626)— permission-denied iconvar(--color-text-primary, #111318),var(--color-text-secondary, #5c6472),var(--color-text-muted, #9CA3AF)var(--color-border, #e2e4e9),var(--color-surface, #ffffff),var(--color-surface-raised, #ffffff)var(--color-overlay, rgba(0,0,0,0.32))— backdropvar(--color-focus-ring, #4A90D9)— inline links
No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
apps/pwa/src/sw.ts |
service-worker | event-driven | No existing custom SW — only generated SW (not editable). Use RESEARCH.md Pattern 2 + Pattern code examples for navigateFallback. Must preserve denylist from vite.config.ts lines 16–19. |
Dependency Gaps (must install before building)
| Package | Location | Install Command |
|---|---|---|
web-push |
apps/api | pnpm --filter @familysync/api add web-push |
@types/web-push |
apps/api (dev) | pnpm --filter @familysync/api add -D @types/web-push |
workbox-precaching |
apps/pwa (dev) | pnpm --filter @familysync/pwa add -D workbox-precaching |
workbox-core |
apps/pwa (dev) | pnpm --filter @familysync/pwa add -D workbox-core |
workbox-routing |
apps/pwa (dev) | pnpm --filter @familysync/pwa add -D workbox-routing |
Metadata
Analog search scope: apps/api/src/, apps/pwa/src/
Files read: schema.ts, listEmitter.ts, poller.ts, outboxWorker.ts, index.ts, routes/lists.ts, test/setup.ts, tests/routes/lists.test.ts, components/InstallPrompt.tsx, components/CreateListSheet.tsx, components/AppNav.tsx, vite.config.ts
Pattern extraction date: 2026-06-09