diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx
index 59d6d46..6812027 100644
--- a/apps/pwa/src/App.tsx
+++ b/apps/pwa/src/App.tsx
@@ -15,21 +15,36 @@
* navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
* /lists/* is served from cache correctly.
+ *
+ * Phase 5 additions:
+ * - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10)
+ * - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09)
*/
+import { useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
import { CalendarShell } from './components/CalendarShell.js'
import { ListsIndex } from './routes/ListsIndex.js'
import { ListDetail } from './routes/ListDetail.js'
import { BottomTabBar } from './components/BottomTabBar.js'
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
+import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'
+import { SettingsSheet } from './components/SettingsSheet.js'
export default function App() {
+ const [settingsOpen, setSettingsOpen] = useState(false)
+
return (
+ {/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
+
+
} />
- } />
+ setSettingsOpen(true)} />}
+ />
} />
} />
@@ -37,6 +52,9 @@ export default function App() {
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
and Notification.permission === 'default' and not dismissed */}
+
+ {/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
+ setSettingsOpen(false)} />
)
}
diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx
index 1d58635..542d417 100644
--- a/apps/pwa/src/components/CalendarShell.tsx
+++ b/apps/pwa/src/components/CalendarShell.tsx
@@ -72,7 +72,7 @@ function isPhone(): boolean {
// ── Component ──────────────────────────────────────────────────────────────
-export function CalendarShell() {
+export function CalendarShell({ onOpenSettings }: { onOpenSettings?: () => void } = {}) {
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
// Without selectors, any popover open/close triggers a full re-render here,
// which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B).
@@ -358,6 +358,7 @@ export function CalendarShell() {
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
+ onOpenSettings={onOpenSettings}
/>
{calendarContent}
@@ -421,6 +422,7 @@ export function CalendarShell() {
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
+ onOpenSettings={onOpenSettings}
/>
{/* Main content area */}
diff --git a/apps/pwa/src/components/PermissionDeniedBanner.tsx b/apps/pwa/src/components/PermissionDeniedBanner.tsx
new file mode 100644
index 0000000..c51e500
--- /dev/null
+++ b/apps/pwa/src/components/PermissionDeniedBanner.tsx
@@ -0,0 +1,280 @@
+/**
+ * PermissionDeniedBanner — persistent OS-revoked notification banner (D-10).
+ *
+ * Shown ONLY when:
+ * - Notification.permission === 'denied' (OS revoked), AND
+ * - localStorage.notificationsEnabled was previously '1' (user had it on)
+ *
+ * Silent re-subscribe (D-10) covers the expired-subscription case. This banner
+ * is strictly for the OS-revoked case — the one case the app cannot silently fix.
+ *
+ * UI-SPEC §Surface 3:
+ * - role="alert" (assertive live region — permission loss is high-priority)
+ * - AlertCircle (--color-destructive) + "Notifications blocked" heading
+ * - "Re-enable in your browser settings." + "How to enable" inline button
+ * - No dismiss — persists until OS permission is restored
+ * - "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step)
+ *
+ * Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
+ */
+
+import { useState } from 'react'
+import { AlertCircle, X } from 'lucide-react'
+import { readNotificationsEnabled } from '../hooks/usePushSubscription.js'
+
+// ── OS detection ──────────────────────────────────────────────────────────
+
+function isIOS(): boolean {
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) &&
+ !(window as unknown as { MSStream?: unknown }).MSStream
+}
+
+// ── Instruction steps ────────────────────────────────────────────────────
+
+const IOS_STEPS = [
+ 'Open Settings on your iPhone',
+ 'Scroll down and tap Safari',
+ 'Tap Notifications',
+ 'Allow notifications for FamilySync',
+]
+
+const ANDROID_STEPS = [
+ 'Open Chrome on your phone',
+ 'Tap the three-dot menu → Settings',
+ 'Tap Site Settings → Notifications',
+ 'Find FamilySync and tap Allow',
+]
+
+// ── Instruction sheet ────────────────────────────────────────────────────
+
+interface InstructionSheetProps {
+ onClose: () => void
+}
+
+function InstructionSheet({ onClose }: InstructionSheetProps) {
+ const steps = isIOS() ? IOS_STEPS : ANDROID_STEPS
+ const platform = isIOS() ? 'iOS' : 'Android'
+
+ return (
+
{
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
+ {/* Header */}
+
+
+ How to enable notifications
+
+
+
+
+
+
+ {/* Steps */}
+
+ {steps.map((step, i) => (
+
+
+ {i + 1}
+
+
+ {step}
+
+
+ ))}
+
+
+ {/* Done button */}
+
+ Done
+
+
+
+ )
+}
+
+// ── PermissionDeniedBanner ────────────────────────────────────────────────
+
+export function PermissionDeniedBanner() {
+ const [instructionsOpen, setInstructionsOpen] = useState(false)
+
+ // Only show when OS permission is 'denied' AND the user previously had notifications on.
+ // This is the OS-revoked case (D-10). Silent re-subscribe handles the expired-sub case.
+ const permissionDenied =
+ typeof Notification !== 'undefined' && Notification.permission === 'denied'
+ const wasEnabled = readNotificationsEnabled()
+
+ if (!permissionDenied || !wasEnabled) return null
+
+ return (
+ <>
+
+
+
+
+
+ Notifications blocked
+
+
+ Re-enable in your browser settings.{' '}
+ setInstructionsOpen(true)}
+ style={{
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ fontSize: 'var(--text-label-size, 13px)',
+ color: 'var(--color-focus-ring, #4A90D9)',
+ textDecoration: 'underline',
+ fontFamily: 'inherit',
+ }}
+ >
+ How to enable
+
+
+
+
+
+ {instructionsOpen && (
+ setInstructionsOpen(false)} />
+ )}
+ >
+ )
+}