Files
familysync/.planning/phases/05-web-push-notifications/05-PATTERNS.md
T

28 KiB
Raw Blame History

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 208224) and memberCredentials table (lines 5569)

Imports pattern (lines 114):

import {
  mysqlTable,
  mysqlEnum,
  varchar,
  text,
  int,
  timestamp,
  index,
  unique,
  // customType if collation needed — see varcharBin pattern lines 2225
} from 'drizzle-orm/mysql-core'

Core table pattern — copy listShares structure (lines 208224):

// 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 170)

Imports pattern (lines 2034):

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 5769:

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 154)

Module structure — same module-level singleton with a clear export surface:

// listEmitter.ts singleton pattern (lines 1722):
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 189) — exact structural match

Imports pattern (lines 1625 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 8389 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 6876 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-inapps/api/src/index.ts lines 107113:

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 107116 (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 2737

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 1100) — exact pattern

Mock boilerplate (lines 3244 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 7780 of lists.test.ts):

async function getApp() {
  const { app } = await import('../../src/index.js')
  return app
}

Request helper (lines 8692):

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 5058):

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 148) — migrate in-place

Current config to preserve (lines 840):

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):

  1. navigateFallbackDenylist: /^\/callback/, /^\/api\//, /^\/health/ (from vite.config.ts lines 1619, T-03-20)
  2. runtimeCaching: [] — no API caching (line 22)
  3. autoUpdate behavior: self.skipWaiting() + clientsClaim() (replaces generateSW auto-behavior)
  4. 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.tsxWalkthroughSheet sub-component (lines 121269)

Bottom sheet layout pattern (lines 122152 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 153192 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 445462 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 284297 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 160) for sheet lifecycle, plus InstallPrompt.tsx WalkthroughSheet for layout

Sheet open/close pattern (CreateListSheet.tsx lines 2860):

// 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 321406)

Banner layout pattern (lines 322337 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 363374 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.tsxuseAndroidInstallPrompt hook (lines 76105)

Hook structure (lines 76105 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 284297 for notificationsEnabled key.


apps/pwa/src/components/AppNav.tsx (promote avatar to button)

Analog: same file lines 7380 (current avatar div)

Current pattern (lines 7380 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 382396):

<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 5769 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 7076, 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 95116 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 121152 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 382396 (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/CTA
  • var(--color-destructive, #DC2626) — permission-denied icon
  • var(--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)) — backdrop
  • var(--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 1619.

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