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

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-web-push-notifications 08 execute 6
05-04
apps/pwa/src/hooks/usePushSubscription.ts
apps/pwa/src/components/SettingsSheet.tsx
apps/pwa/src/components/PermissionDeniedBanner.tsx
apps/pwa/src/components/AppNav.tsx
apps/pwa/src/App.tsx
false
NOTIF-01
NOTIF-02
NOTIF-03
truths artifacts key_links
A single master on/off toggle in a Settings sheet (opened from the avatar) enables/disables all FamilySync push notifications (D-09)
On app open, if OS permission is still granted but the push subscription is missing/expired, the app silently re-subscribes — no user action (D-10)
If the OS permission itself was revoked (denied) and notifications were previously enabled, a persistent permission-denied banner appears with OS-specific re-enable instructions (D-10)
The avatar in AppNav (phone + desktop) is a real button opening the Settings sheet (a11y: aria-label, 44px target)
path provides exports
apps/pwa/src/components/SettingsSheet.tsx Settings bottom sheet with the master NotificationToggle (D-09)
SettingsSheet
path provides exports
apps/pwa/src/components/PermissionDeniedBanner.tsx persistent OS-revoked banner with re-enable instructions (D-10)
PermissionDeniedBanner
from to via pattern
apps/pwa/src/hooks/usePushSubscription.ts pushManager.getSubscription mount health-check → silent re-subscribe when permission granted but no subscription getSubscription
from to via pattern
apps/pwa/src/components/AppNav.tsx apps/pwa/src/components/SettingsSheet.tsx avatar button onClick opens settings onOpenSettings
The opt-out + reliability surface: a single master notifications toggle (D-09), silent dead-subscription recovery on app open (D-10), and a permission-denied banner for the OS-revoked case (D-10). Completes the user-facing half of the mandatory iOS health-check (success criterion 4) and the lone settings control.

Purpose: D-10's silent re-subscribe is what keeps subscriptions alive across inactivity without bothering the non-technical member; the banner only surfaces when the OS itself revoked permission (the one case the app cannot silently fix). D-09's single toggle is the entire settings surface for v1.

Output: usePushSubscription gains the mount health-check + permission state; SettingsSheet (avatar-triggered) with the master toggle; PermissionDeniedBanner; AppNav avatar promoted to a button.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @apps/pwa/src/hooks/usePushSubscription.ts @apps/pwa/src/components/InstallPrompt.tsx @apps/pwa/src/components/AppNav.tsx @apps/pwa/src/components/CreateListSheet.tsx @apps/pwa/src/App.tsx @.planning/phases/05-web-push-notifications/05-PATTERNS.md @.planning/phases/05-web-push-notifications/05-UI-SPEC.md Task 1: usePushSubscription health-check + permission state (D-10) - apps/pwa/src/hooks/usePushSubscription.ts (the subscribe/unsubscribe built in Plan 05-04) - apps/pwa/src/components/InstallPrompt.tsx (useAndroidInstallPrompt useEffect pattern lines 76-105; readDismissed/persistDismissed lines 284-297) - .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 7 usePushSubscription; D-10 silent re-subscribe) - .planning/phases/05-web-push-notifications/05-CONTEXT.md (D-10) Extend apps/pwa/src/hooks/usePushSubscription.ts: add a mount useEffect that runs the D-10 health-check — if Notification.permission==='granted', await navigator.serviceWorker.ready, getSubscription(); if none exists AND localStorage.notificationsEnabled !== '0', silently re-subscribe (call the existing subscribe path WITHOUT a tap gesture — allowed because permission is already granted, no OS dialog). Expose `permission` (current Notification.permission) and an `isSubscribed` flag, and a `setEnabled(on:boolean)` that on→off calls unsubscribe()+localStorage.notificationsEnabled='0', and off→on (permission granted) silently subscribes / (permission default) requires the tap-handler subscribe path / (permission denied) is a no-op (caller shows the denied hint). Do NOT call the tap-gated subscribe inside the health-check useEffect — only the already-granted silent path. cd apps/pwa && grep -q "getSubscription" src/hooks/usePushSubscription.ts && grep -q "permission" src/hooks/usePushSubscription.ts && pnpm build 2>&1 | tail -2 Hook exposes permission + isSubscribed + setEnabled; mount health-check silently re-subscribes only when permission is granted and a subscription is missing and notifications weren't explicitly disabled (D-10). No tap-gated subscribe in the effect. Silent dead-subscription recovery implemented (D-10 reliability half). Task 2: SettingsSheet (master toggle) + AppNav avatar button - apps/pwa/src/components/CreateListSheet.tsx (sheet open/close + Escape pattern lines 28-60; z-index 300/301) - apps/pwa/src/components/AppNav.tsx (PhoneNav avatar lines 73-102; DesktopNav "Calendars" section-label idiom lines 173-184) - apps/pwa/src/components/InstallPrompt.tsx (44px button pattern; X close button) - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### SettingsSheet.tsx; ### AppNav.tsx promote avatar to button) - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Surface 2 — full layout, copy, toggle behavior + initial state, toggle states table) Create apps/pwa/src/components/SettingsSheet.tsx: bottom sheet (role="dialog", aria-modal, aria-label="Settings", borderRadius 12px 12px 0 0, padding var(--space-6), zIndex 301, backdrop 300 click-to-close, Escape closes — copy the CreateListSheet lifecycle). Contents per UI-SPEC Surface 2: heading row "Settings" + X (aria-label "Close settings"); section label "Notifications" (uppercase, muted, letter-spacing 0.06em); a toggle row — Bell icon + column ("FamilySync Notifications" / "Reminders, event changes, list updates") + an inline role="switch" toggle (aria-checked, aria-label, 44px target; on=track var(--color-member-0), off=track var(--color-border), disabled+opacity 0.5 when permission==='denied'). Wire the toggle to usePushSubscription setEnabled + permission. Initial state: on when localStorage.notificationsEnabled!=='0' AND permission==='granted' AND isSubscribed; off otherwise. Toggling on while permission==='default' must call the tap-gated subscribe inside the switch's onClick (no await before pushManager.subscribe). When permission==='denied' show the inline permission-denied hint (AlertCircle + "Notifications are blocked…" + "How to enable" link) and the toggle stays disabled. Use a Loader2 spinner while a subscribe is in flight. All copy verbatim from UI-SPEC Copywriting Contract. Modify apps/pwa/src/components/AppNav.tsx: promote the PhoneNav avatar div (and the DesktopNav equivalent) to a with a 44px target wrapping the 32px color circle (per PATTERNS AppNav section). Thread an onOpenSettings prop. cd apps/pwa && grep -q 'role="switch"' src/components/SettingsSheet.tsx && grep -q "FamilySync Notifications" src/components/SettingsSheet.tsx && grep -q "onOpenSettings" src/components/AppNav.tsx && pnpm build 2>&1 | tail -2 SettingsSheet matches UI-SPEC Surface 2 (copy, toggle states, a11y); avatar is a button opening it; toggle on/off drives setEnabled; permission-denied disables the toggle and shows the hint. Master notifications toggle (D-09) live; avatar opens settings. Task 3: PermissionDeniedBanner + App mount + desktop verification - apps/pwa/src/components/InstallPrompt.tsx (banner layout lines 321-406; WalkthroughSheet for the iOS re-enable instructions sheet) - apps/pwa/src/App.tsx (mount tree) - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Surface 3 — banner copy, when-shown rule, "How to enable" iOS/Android instruction steps) - .claude/skills/playwright-cli/SKILL.md apps/pwa/src/components/PermissionDeniedBanner.tsx: persistent role="alert" banner (InstallPrompt banner layout) shown ONLY when Notification.permission==='denied' AND localStorage.notificationsEnabled was previously '1' (D-10 — silent re-subscribe covers expired subscriptions; this banner is the OS-revoked case only). AlertCircle (var(--color-destructive)) + "Notifications blocked" / "Re-enable in your browser settings." + "How to enable" inline link. No dismiss button. "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step, copy verbatim from UI-SPEC Copywriting Contract). App.tsx mounts PermissionDeniedBanner (below AppNav, above content) and SettingsSheet; AppNav receives onOpenSettings to drive the sheet's open state. Implement PermissionDeniedBanner + mount it and SettingsSheet in App.tsx, wiring AppNav's onOpenSettings to the SettingsSheet open state. Then verify on desktop Chromium with playwright-cli: with notifications enabled then permission revoked, confirm the banner appears and "How to enable" opens the instruction sheet; with permission granted, confirm no banner. Capture playwright-cli evidence. 1. Serve API (dev-bypass) + PWA. 2. playwright-cli: grant then revoke Notifications; confirm the banner renders with the exact copy and "How to enable" opens the sheet. 3. Confirm the banner is absent when permission is granted or was never enabled. 4. iOS-standalone banner behavior + real push delivery remain on the device-only Phase 5 human gate. Type "approved" or describe what failed cd apps/pwa && grep -q 'role="alert"' src/components/PermissionDeniedBanner.tsx && grep -q "Notifications blocked" src/components/PermissionDeniedBanner.tsx && grep -q "PermissionDeniedBanner" src/App.tsx && grep -q "SettingsSheet" src/App.tsx && pnpm build 2>&1 | tail -2 Banner shows only in the OS-revoked-after-enabled case with verbatim UI-SPEC copy + working "How to enable" sheet; absent otherwise; SettingsSheet + banner mounted in App; desktop playwright-cli verified. Permission-denied banner + settings mounted; opt-out + reliability surface complete on desktop.

<threat_model>

Trust Boundaries

Boundary Description
client permission state → UI Notification.permission + localStorage drive which surface shows; no server trust involved

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-23 Tampering silent re-subscribe without permission mitigate health-check only re-subscribes when Notification.permission==='granted'; never forces an OS dialog
T-05-24 Information Disclosure XSS via copy mitigate all copy is plain-text JSX children (no dangerouslySetInnerHTML), matching existing InstallPrompt convention
T-05-25 Repudiation toggle off leaves stale server subscription mitigate setEnabled off calls DELETE /api/push/subscription (Plan 05-04) so the server prunes the row
</threat_model>
- `pnpm --filter @familysync/pwa build` green. - Desktop playwright-cli: banner shows on revoke, hidden when granted; settings toggle drives subscribe/unsubscribe.

<success_criteria>

  • Single master toggle (D-09) in an avatar-opened Settings sheet.
  • Silent re-subscribe on app open when permission still granted (D-10).
  • Permission-denied banner only in the OS-revoked-after-enabled case (D-10), with re-enable instructions. </success_criteria>
Create `.planning/phases/05-web-push-notifications/05-08-SUMMARY.md` when done.