--- phase: 05-web-push-notifications plan: 08 subsystem: pwa/hooks, pwa/components tags: [web-push, settings, permission-denied, toggle, reliability, D-09, D-10] dependency_graph: requires: [05-04] provides: [SettingsSheet (master toggle D-09), PermissionDeniedBanner (D-10), usePushSubscription setEnabled/isSubscribed, silent re-subscribe health-check (D-10)] affects: - 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/components/CalendarShell.tsx - apps/pwa/src/App.tsx - apps/pwa/src/styles/tokens.css tech_stack: added: [] patterns: - usePushSubscription setEnabled master toggle (D-09) - Silent dead-subscription recovery on mount (D-10) - PermissionDeniedBanner role=alert, OS-revoked-only gate - SettingsSheet bottom sheet (role=dialog, z:301, Escape+backdrop close) - AppNav avatar promoted to button with onOpenSettings prop chain key_files: created: - apps/pwa/src/components/SettingsSheet.tsx - apps/pwa/src/components/PermissionDeniedBanner.tsx modified: - apps/pwa/src/hooks/usePushSubscription.ts - apps/pwa/src/components/AppNav.tsx - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/App.tsx - apps/pwa/src/styles/tokens.css decisions: - "setEnabled(true) + permission=default: no-op; caller must tap-gated subscribe() — iOS user-gesture requirement" - "readNotificationsDisabled() guards health-check re-subscribe: skip if notificationsEnabled=0 (explicit user off)" - "persistNotificationsEnabled(false) now writes '0' instead of removing key — allows banner to detect prior-enabled state" - "onOpenSettings threaded through App → CalendarShell → AppNav (not hoisted to global store) — keeps settings state local to App.tsx" - "@keyframes spin added to tokens.css — shared by SettingsSheet Loader2 and SyncStateToast spinners" metrics: duration: 9 completed_date: "2026-06-10" tasks_completed: 3 files_changed: 7 --- # Phase 05 Plan 08: Opt-Out + Reliability Surface Summary Single master notifications toggle (D-09) in an avatar-opened Settings sheet, silent dead-subscription recovery on app open (D-10), and a persistent permission-denied banner for the OS-revoked case (D-10) — completing the user-facing half of the mandatory iOS health-check. ## Tasks Executed ### Task 1: usePushSubscription health-check + permission state (D-10) **Status:** Completed. Commit: `458d6e4` Extended `apps/pwa/src/hooks/usePushSubscription.ts`: - Added `isSubscribed: boolean` state (true when pushManager has active subscription) - Added `setEnabled(on: boolean)` master toggle: off → unsubscribe + persist '0'; on + permission granted → silent subscribe; on + permission default/denied → no-op - Health-check now calls `readNotificationsDisabled()` — skips silent re-subscribe if user explicitly turned notifications off (notificationsEnabled=0). Prevents re-subscribing against the user's will. - Changed `persistNotificationsEnabled(false)` to write '0' instead of removing the key — PermissionDeniedBanner needs to detect "was previously enabled" state - Exported `readNotificationsEnabled` for PermissionDeniedBanner and SettingsSheet initial-state logic - Removed dead local usage of `readNotificationsEnabled` (was defined but not in returned interface — the lint hint from the plan) ### Task 2: SettingsSheet + AppNav avatar button **Status:** Completed. Commit: `1de4aa5` Created `apps/pwa/src/components/SettingsSheet.tsx`: - Bottom sheet (role="dialog", aria-modal, aria-label="Settings", borderRadius 12px 12px 0 0, zIndex 301, backdrop 300 click-to-close, Escape closes) - Heading "Settings" + X close button (44px, aria-label="Close settings") - Section label "NOTIFICATIONS" (uppercase, muted, letter-spacing 0.06em) - Bell icon + toggle row: "FamilySync Notifications" / "Reminders, event changes, list updates" - Toggle switch: role="switch", aria-checked, aria-label (on/off variants), 44px touch target - On: track var(--color-member-0) #4A90D9, thumb white - Off: track var(--color-border), thumb white - Disabled (permission denied): opacity 0.5, no pointer events - Loader2 spinner replaces toggle while subscribing - Permission-denied hint: AlertCircle + "Notifications are blocked in your browser settings." + "How to enable" link (shown only when permission === 'denied') - Toggle is wired to `usePushSubscription` — `setEnabled` called on click; initial state from `isSubscribed + permission` Modified `apps/pwa/src/components/AppNav.tsx`: - PhoneNav avatar `div` promoted to `