--- phase: 05-web-push-notifications plan: 08 type: execute wave: 6 depends_on: [05-04] files_modified: - 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 autonomous: false requirements: [NOTIF-01, NOTIF-02, NOTIF-03] must_haves: truths: - '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)' artifacts: - path: 'apps/pwa/src/components/SettingsSheet.tsx' provides: 'Settings bottom sheet with the master NotificationToggle (D-09)' exports: ['SettingsSheet'] - path: 'apps/pwa/src/components/PermissionDeniedBanner.tsx' provides: 'persistent OS-revoked banner with re-enable instructions (D-10)' exports: ['PermissionDeniedBanner'] key_links: - from: 'apps/pwa/src/hooks/usePushSubscription.ts' to: 'pushManager.getSubscription' via: 'mount health-check → silent re-subscribe when permission granted but no subscription' pattern: 'getSubscription' - from: 'apps/pwa/src/components/AppNav.tsx' to: 'apps/pwa/src/components/SettingsSheet.tsx' via: 'avatar button onClick opens settings' pattern: '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. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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