---
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'
---

<objective>
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.
</objective>

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

<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
</context>

<tasks>

<task type="auto">
  <name>Task 1: usePushSubscription health-check + permission state (D-10)</name>
  <read_first>
    - 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)
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>cd apps/pwa && grep -q "getSubscription" src/hooks/usePushSubscription.ts && grep -q "permission" src/hooks/usePushSubscription.ts && pnpm build 2>&1 | tail -2</automated>
  </verify>
  <acceptance_criteria>
    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.
  </acceptance_criteria>
  <done>Silent dead-subscription recovery implemented (D-10 reliability half).</done>
</task>

<task type="auto">
  <name>Task 2: SettingsSheet (master toggle) + AppNav avatar button</name>
  <read_first>
    - 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)
  </read_first>
  <action>
    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 <button onClick={onOpenSettings} aria-label={`${displayName} — open settings`}> with a 44px target wrapping the 32px color circle (per PATTERNS AppNav section). Thread an onOpenSettings prop.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    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.
  </acceptance_criteria>
  <done>Master notifications toggle (D-09) live; avatar opens settings.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: PermissionDeniedBanner + App mount + desktop verification</name>
  <read_first>
    - 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
  </read_first>
  <what-built>
    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.
  </what-built>
  <action>
    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.
  </action>
  <how-to-verify>
    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.
  </how-to-verify>
  <resume-signal>Type "approved" or describe what failed</resume-signal>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    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.
  </acceptance_criteria>
  <done>Permission-denied banner + settings mounted; opt-out + reliability surface complete on desktop.</done>
</task>

</tasks>

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

<verification>
- `pnpm --filter @familysync/pwa build` green.
- Desktop playwright-cli: banner shows on revoke, hidden when granted; settings toggle drives subscribe/unsubscribe.
</verification>

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

<output>
Create `.planning/phases/05-web-push-notifications/05-08-SUMMARY.md` when done.
</output>
