172 lines
12 KiB
Markdown
172 lines
12 KiB
Markdown
---
|
|
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>
|