diff --git a/apps/pwa/src/components/AppNav.tsx b/apps/pwa/src/components/AppNav.tsx
index faa3cc2..42b156a 100644
--- a/apps/pwa/src/components/AppNav.tsx
+++ b/apps/pwa/src/components/AppNav.tsx
@@ -19,25 +19,42 @@ interface AppNavProps {
members?: LegendMember[]
currentUserColor?: string
currentUserName?: string
+ /** Called when the user avatar is tapped — opens the Settings sheet. */
+ onOpenSettings?: () => void
}
-export function AppNav({ members = [], currentUserColor, currentUserName }: AppNavProps) {
+export function AppNav({ members = [], currentUserColor, currentUserName, onOpenSettings }: AppNavProps) {
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
if (isMobile) {
- return
+ return (
+
+ )
}
- return
+ return (
+
+ )
}
/** Phone: 48px top bar — app name left, user avatar right */
function PhoneNav({
currentUserColor,
currentUserName,
+ onOpenSettings,
}: {
currentUserColor?: string
currentUserName?: string
+ onOpenSettings?: () => void
}) {
const displayName = currentUserName ?? 'User'
const color = currentUserColor ?? 'var(--color-member-0)'
@@ -69,43 +86,50 @@ function PhoneNav({
FamilySync
- {/* User color avatar — aria-label + title per reviewer note */}
-
+
)
}
-/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend */
-function DesktopNav({ members }: { members: LegendMember[] }) {
+/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend + avatar */
+function DesktopNav({
+ members,
+ currentUserColor,
+ currentUserName,
+ onOpenSettings,
+}: {
+ members: LegendMember[]
+ currentUserColor?: string
+ currentUserName?: string
+ onOpenSettings?: () => void
+}) {
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
display: 'flex',
alignItems: 'center',
@@ -183,6 +207,50 @@ function DesktopNav({ members }: { members: LegendMember[] }) {
Calendars
+
+ {/* User avatar — at bottom of sidebar, opens Settings sheet (D-09) */}
+
+
+
)
}
diff --git a/apps/pwa/src/components/SettingsSheet.tsx b/apps/pwa/src/components/SettingsSheet.tsx
new file mode 100644
index 0000000..62a2689
--- /dev/null
+++ b/apps/pwa/src/components/SettingsSheet.tsx
@@ -0,0 +1,344 @@
+/**
+ * SettingsSheet — master notifications toggle (D-09).
+ *
+ * A bottom sheet opened by tapping the user avatar in AppNav.
+ * Contains a single master on/off toggle for all FamilySync push notifications.
+ *
+ * UI-SPEC §Surface 2:
+ * - Bottom sheet, role="dialog", aria-modal, zIndex 301 (backdrop 300)
+ * - Heading "Settings" + X close button
+ * - Section label "Notifications" (uppercase, muted)
+ * - Bell icon + toggle row
+ * - Permission-denied hint when Notification.permission === 'denied'
+ *
+ * Toggle behavior:
+ * on → off: DELETE subscription + localStorage.notificationsEnabled='0'
+ * off → on (permission granted): silently subscribe (no OS dialog)
+ * off → on (permission default): triggers tap-gated subscribe() (OS dialog)
+ * off → on (permission denied): no-op, shows permission-denied hint inline
+ *
+ * Accessibility: role="switch", aria-checked, 44px touch targets, Escape closes.
+ * Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
+ */
+
+import { useEffect, useRef, useState } from 'react'
+import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'
+import { usePushSubscription } from '../hooks/usePushSubscription.js'
+
+interface SettingsSheetProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
+ const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription()
+ const [isTogglingOn, setIsTogglingOn] = useState(false)
+ const closeButtonRef = useRef(null)
+
+ // Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
+ // on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed
+ const isOn = permission === 'granted' && isSubscribed
+
+ // Escape key listener (CreateListSheet pattern)
+ useEffect(() => {
+ if (!isOpen) return
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [isOpen, onClose])
+
+ // Focus close button on open (a11y)
+ useEffect(() => {
+ if (isOpen && closeButtonRef.current) {
+ closeButtonRef.current.focus()
+ }
+ }, [isOpen])
+
+ if (!isOpen) return null
+
+ const handleToggle = async () => {
+ if (permission === 'denied') return // no-op — show hint below
+
+ if (isOn) {
+ // on → off: unsubscribe
+ await setEnabled(false)
+ } else if (permission === 'granted') {
+ // off → on, permission already granted: silent subscribe
+ setIsTogglingOn(true)
+ try {
+ await setEnabled(true)
+ } finally {
+ setIsTogglingOn(false)
+ }
+ } else {
+ // off → on, permission 'default': needs tap-gated subscribe with OS dialog
+ // The toggle click IS the tap gesture — call subscribe() directly here.
+ setIsTogglingOn(true)
+ try {
+ const registration = await navigator.serviceWorker?.ready
+ if (registration) {
+ await subscribe(registration)
+ }
+ } catch {
+ // Permission denied by OS or error — permission state will update reactively
+ } finally {
+ setIsTogglingOn(false)
+ }
+ }
+ }
+
+ const isDisabled = permission === 'denied'
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Sheet */}
+
+ {/* Heading row */}
+
+
+ Settings
+
+
+
+
+ {/* Section label */}
+
+ Notifications
+
+
+ {/* Toggle row */}
+
+
+
+ {/* Label column */}
+
+
+ FamilySync Notifications
+
+
+ Reminders, event changes, list updates
+
+
+
+ {/* Toggle switch or spinner */}
+ {isTogglingOn ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Permission-denied hint — only when OS permission === 'denied' */}
+ {permission === 'denied' && (
+
+
+
+
+ Notifications are blocked in your browser settings.{' '}
+
+
+
+
+ )}
+
+ >
+ )
+}
diff --git a/apps/pwa/src/styles/tokens.css b/apps/pwa/src/styles/tokens.css
index ea05fb4..b48fd6f 100644
--- a/apps/pwa/src/styles/tokens.css
+++ b/apps/pwa/src/styles/tokens.css
@@ -136,3 +136,12 @@
background-position: 200% 0;
}
}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}