Files
familysync/.planning/milestones/v1.0-phases/05-web-push-notifications/05-UI-SPEC.md
T

19 KiB

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
5 web-push-notifications draft false none 2026-06-09

Phase 5 — UI Design Contract

Visual and interaction contract for Phase 5: Web Push Notifications. Generated by gsd-ui-researcher. Verified by gsd-ui-checker.

Brownfield note: All tokens, patterns, and idioms are carried from the established design system in apps/pwa/src/styles/tokens.css (Phase 2). This phase adds three new UI surfaces — permission prompt, settings toggle sheet, and permission-denied banner — all built from existing tokens. No new visual language is introduced.


Design System

Property Value
Tool none (custom CSS tokens, no shadcn)
Preset not applicable
Component library none (inline styles via CSS custom properties)
Icon library lucide-react (existing; already used in InstallPrompt, AppNav, BottomTabBar)
Font system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif (var(--font-family-base))

Source: apps/pwa/src/styles/tokens.css, apps/pwa/src/styles/tokens.ts


Spacing Scale

Declared values (multiples of 4 — carried verbatim from tokens.css):

Token Value Usage
--space-1 4px Icon gaps, border-radius on buttons
--space-2 8px Label-to-input gap, compact element spacing
--space-3 12px Banner internal padding, step gaps
--space-4 16px Default element padding, input/button padding
--space-6 24px Sheet padding, section gaps
--space-8 32px Larger section breaks
--space-12 48px Major section breaks

Exceptions:

  • Touch targets: minimum 44px height/width on all interactive elements (matches InstallPrompt pattern)
  • Bottom sheet border-radius: 12px 12px 0 0 (matches WalkthroughSheet and CreateListSheet pattern)

Source: apps/pwa/src/styles/tokens.css — unchanged from Phase 2.


Typography

Carried verbatim from tokens.css — no new type roles added:

Role Size Weight Line Height Usage in Phase 5
Body 15px (var(--text-body-size)) 400 (var(--text-body-weight)) 1.5 (var(--text-body-line-height)) Permission prompt explainer text, settings row description
Label 13px (var(--text-label-size)) 400 (var(--text-label-weight)) 1.4 (var(--text-label-line-height)) Banner subtitle, toggle state label, secondary notification copy
Heading 18px (var(--text-heading-size)) 600 (var(--text-heading-weight)) 1.25 (var(--text-heading-line-height)) Settings sheet heading, permission prompt heading
Display 24px (var(--text-display-size)) 600 (var(--text-display-weight)) 1.2 (var(--text-display-line-height)) App name in nav (unchanged)

Source: apps/pwa/src/styles/tokens.css — unchanged from Phase 2.


Color

Carried from tokens.css — no new colors added:

Role Value Usage
Dominant (60%) #FFFFFF (var(--color-surface)) Sheet background, banner background, page background
Secondary (30%) #F7F7F8 (var(--color-surface-dim)) Toggle track (off state), sheet backdrop dim
Accent (10%) #4A90D9 (var(--color-member-0)) Enable Notifications CTA button, toggle track (on state), "How to enable" link text
Destructive #DC2626 (var(--color-destructive)) Permission-denied banner icon, "Notifications blocked" state

Accent reserved for:

  • The "Enable Notifications" primary action button in the permission prompt
  • The toggle track/thumb in the on state (notifications-enabled)
  • The "How to enable" inline link in the permission-denied banner

Not used on: navigation chrome, sheet headers, settings row labels, or secondary text.

Additional semantic tokens used (not new — from tokens.css):

  • --color-text-primary (#111318): all primary text
  • --color-text-secondary (#6B7280): secondary/helper text, dismiss icons
  • --color-text-muted (#9CA3AF): toggle state label when off
  • --color-border (#E2E4E9): banner bottom border, sheet borders, toggle border
  • --color-overlay (rgba(0,0,0,0.32)): sheet backdrop (matches WalkthroughSheet)
  • --color-focus-ring (#4A90D9): focus outline on all interactive elements

Source: apps/pwa/src/styles/tokens.css.


UI Surfaces

Three new surfaces for this phase. All built from existing tokens and idioms.

Surface 1: Post-Install Permission Prompt (D-08)

What it is: A bottom sheet that appears immediately after PWA install is confirmed (or on first standalone launch). It replaces or follows the install walkthrough. Its job is to explain push notifications and trigger the OS permission request on a user tap.

Location: Rendered inside InstallPrompt.tsx (or a sibling mounted in the same location) — conditional on isInstalled() === true and Notification.permission === 'default'.

Layout pattern: Same bottom sheet as WalkthroughSheet in InstallPrompt.tsx:

  • position: fixed; bottom: 0; left: 0; right: 0
  • background: var(--color-surface)
  • borderRadius: 12px 12px 0 0
  • padding: var(--space-6)
  • boxShadow: 0 -4px 24px rgba(0,0,0,0.15)
  • zIndex: 1000
  • Backdrop: position: fixed; inset: 0; background: var(--color-overlay); zIndex: 999
  • Backdrop click does NOT dismiss (permission UX must be explicit — tap or dismiss button)

Contents:

[Bell icon, 24px, --color-text-secondary]
[Heading] "Stay in the loop"
[Body]    "Get notified when events are coming up or your family makes changes."
[Primary CTA] "Enable Notifications" — full-width, 48px min-height
[Secondary]   "Not now" — ghost text button, 44px min-height

States:

  • Default: heading + body + Enable button + Not now button
  • Loading (after tap, awaiting OS dialog): "Enable Notifications" button shows Loader2 spinner (20px, lucide-react), disabled, no label change
  • Granted (OS resolved granted): sheet closes immediately, no toast
  • Denied (OS resolved denied): sheet closes, permission-denied banner appears (Surface 3)

Accessibility:

  • role="dialog", aria-modal="true", aria-label="Enable push notifications"
  • Focus trap: first focusable element is "Enable Notifications" button
  • Dismiss via "Not now" button only (no backdrop dismiss — intentional)
  • Persisted: localStorage.pushPermissionDismissed = '1' when "Not now" tapped

localStorage keys: pushPermissionDismissed — "Not now" persists, prompt does not re-show on next launch if dismissed. (Re-shows only if permission goes from denied → re-granted externally; silent re-subscribe handles that case per D-10.)


Surface 2: Notification Settings Toggle (D-09)

What it is: A single master on/off toggle for all FamilySync push notifications. Accessible from the user avatar in AppNav.tsx (both phone and desktop).

Location trigger: The user avatar (div with role="img", currently 44px tap area in PhoneNav) is promoted to a <button> that opens a Settings bottom sheet. On desktop the same avatar in DesktopNav opens the sheet.

Settings sheet layout:

  • Same bottom sheet idiom as CreateListSheet and WalkthroughSheet
  • role="dialog", aria-modal="true", aria-label="Settings"
  • position: fixed; bottom: 0; left: 0; right: 0
  • background: var(--color-surface-raised) (#FFFFFF)
  • borderRadius: 12px 12px 0 0
  • padding: var(--space-6)
  • zIndex: 301 (matches CreateListSheet z-index layer)
  • Backdrop at zIndex: 300, click to close

Sheet contents:

[Heading row]
  "Settings"  (font: heading 18px/600)
  [X button, 44px touch target, aria-label="Close settings"]

[Section label]
  "Notifications"  (font: label 13px/600, --color-text-muted, uppercase, letter-spacing 0.06em)
  (matches the "Calendars" section label idiom in DesktopNav)

[Toggle row]
  [Bell icon, 20px, --color-text-secondary, aria-hidden]
  [Column]
    "FamilySync Notifications"  (font: body 15px/400, --color-text-primary)
    "Reminders, event changes, list updates"  (font: label 13px/400, --color-text-secondary)
  [Toggle switch, right-aligned]
    on:  track #4A90D9 (var(--color-member-0)), thumb #FFFFFF, 44px touch target
    off: track #E2E4E9 (var(--color-border)), thumb #FFFFFF
    disabled (when Notification.permission === 'denied'): track #E2E4E9, opacity 0.5
    aria-checked, role="switch", aria-label="FamilySync Notifications"

[Permission-denied hint — only when Notification.permission === 'denied']
  [AlertCircle icon, 16px, --color-destructive]
  "Notifications are blocked in your browser settings."  (font: label 13px/400, --color-text-secondary)
  "How to enable"  (inline link, --color-focus-ring, underline, opens OS settings or shows instructions)

Toggle behavior:

  • on → off: calls DELETE /api/push/subscription (unregisters VAPID subscription), sets localStorage.notificationsEnabled = '0'
  • off → on (permission = 'default'): triggers Notification.requestPermission() + pushManager.subscribe() in the tap handler. On grant: registers subscription. On deny: shows permission-denied hint.
  • off → on (permission = 'granted'): silently calls pushManager.subscribe() + POST /api/push/subscription. No OS dialog.
  • off → on (permission = 'denied'): toggle does not toggle — shows permission-denied hint inline. The toggle is visually disabled (opacity 0.5).

Toggle initial state on open:

  • on when localStorage.notificationsEnabled !== '0' AND Notification.permission === 'granted' AND a valid subscription exists
  • off in all other cases

Surface 3: Permission-Denied Banner (D-10)

What it is: A persistent non-dismissible inline banner shown at the top of the app (below AppNav/BottomTabBar, above content) when Notification.permission === 'denied' and the user previously had notifications enabled.

When shown: Only when OS permission is 'denied' AND localStorage.notificationsEnabled was previously '1'. Silent re-subscribe covers expired subscriptions (D-10) — this banner is ONLY for the OS-revoked case.

Layout: Same banner idiom as the install prompt banner in InstallPrompt.tsx:

  • role="alert" (assertive — permission loss is high-priority)
  • display: flex; alignItems: center; gap: var(--space-3)
  • padding: var(--space-3) var(--space-4)
  • background: var(--color-surface-raised)
  • borderBottom: 1px solid var(--color-border)
  • fontFamily: var(--font-family-base)

Contents:

[AlertCircle icon, 24px, --color-destructive, aria-hidden]
[Column, flex: 1]
  "Notifications blocked"  (font: label 13px/600, --color-text-primary)
  "Re-enable in your browser settings."  (font: label 13px/400, --color-text-secondary)
    + " How to enable"  (inline button/link, --color-focus-ring, underline)

No dismiss button — the banner persists until OS permission is restored. (The user cannot dismiss it since it represents a broken system state that needs resolution.)

"How to enable" link behavior:

  • iOS: opens a bottom sheet with step-by-step instructions (same WalkthroughSheet idiom):
    1. Open Settings on your iPhone
    2. Scroll down and tap Safari
    3. Tap Notifications
    4. Allow notifications for FamilySync
  • Android/Chrome: links to chrome://settings/content/notifications cannot be linked directly; show a sheet with instructions to open Chrome Settings → Site Settings → Notifications → Allow FamilySync.

Notification Content Contract

This is not a UI surface but defines the exact string templates that the push notification payload must match. The executor must use these templates verbatim in the server-side push dispatch.

Event reminder (NOTIF-01)

title: "{EventTitle}"
body:  "Starts in 15 min"
tag:   "reminder-{eventUid}"
data:  { url: "/calendar?date={YYYY-MM-DD}&event={eventUid}" }

Example:

title: "Dentist"
body:  "Starts in 15 min"

Event change — new event (NOTIF-03, new)

title: "{ActorName} added an event"
body:  "{EventTitle} · {formattedTime}"
tag:   "event-change-{eventUid}"
data:  { url: "/calendar?date={YYYY-MM-DD}&event={eventUid}" }

Example:

title: "Lucas added an event"
body:  "Soccer practice · Wed 3 pm"

Event change — modified event (NOTIF-03, change)

title: "{ActorName} updated an event"
body:  "{EventTitle} · {formattedTime}"
tag:   "event-change-{eventUid}"
data:  { url: "/calendar?date={YYYY-MM-DD}&event={eventUid}" }

Example:

title: "Lucas updated an event"
body:  "Dentist · moved to Thu 2 pm"

For deletion:

title: "{ActorName} removed an event"
body:  "{EventTitle}"
tag:   "event-change-{eventUid}"
data:  { url: "/calendar" }

List change (NOTIF-02, coalesced per D-01/D-02/D-03)

title: "{ActorName} updated {ListName}"
body:  "{N} change{s}"
tag:   "list-change-{listId}"
data:  { url: "/lists/{listId}" }

Examples:

title: "Lucas updated Groceries"
body:  "3 changes"

title: "Lucas updated Groceries"
body:  "1 change"

Time format rule

{formattedTime} uses the member's local timezone.

  • Same-day timed events: "{DayAbbr} {H}:{MM} {am/pm}" — e.g. "Wed 3:00 pm"
  • All-day events: never appear in reminder or change notifications (D-07)

Notification type Tap destination
Event reminder /calendar?date={YYYY-MM-DD}&event={eventUid}
Event change (new/modified) /calendar?date={YYYY-MM-DD}&event={eventUid}
Event deletion /calendar
List change /lists/{listId}

The notificationclick service-worker handler calls clients.openWindow(event.notification.data.url).


Copywriting Contract

Element Copy
Permission prompt heading "Stay in the loop"
Permission prompt body "Get notified when events are coming up or your family makes changes."
Permission prompt primary CTA "Enable Notifications"
Permission prompt secondary "Not now"
Settings sheet heading "Settings"
Settings section label "Notifications"
Settings toggle label "FamilySync Notifications"
Settings toggle sublabel "Reminders, event changes, list updates"
Settings toggle on label (aria) "FamilySync Notifications, on"
Settings toggle off label (aria) "FamilySync Notifications, off"
Permission-denied banner heading "Notifications blocked"
Permission-denied banner body "Re-enable in your browser settings."
Permission-denied inline link "How to enable"
iOS re-enable step 1 "Open Settings on your iPhone"
iOS re-enable step 2 "Scroll down and tap Safari"
iOS re-enable step 3 "Tap Notifications"
iOS re-enable step 4 "Allow notifications for FamilySync"
Android re-enable step 1 "Open Chrome on your phone"
Android re-enable step 2 "Tap the three-dot menu → Settings"
Android re-enable step 3 "Tap Site Settings → Notifications"
Android re-enable step 4 "Find FamilySync and tap Allow"
Notification body — reminder "Starts in 15 min"
Notification title — new event "{ActorName} added an event"
Notification title — updated event "{ActorName} updated an event"
Notification title — deleted event "{ActorName} removed an event"
Notification title — list change "{ActorName} updated {ListName}"
Notification body — list change (1) "1 change"
Notification body — list change (N) "{N} changes"

No destructive actions in this phase. The toggle is not destructive — it silently unregisters the push subscription without a confirmation dialog.


Interaction States

Permission prompt

State Visual
Default "Enable Notifications" active (--color-member-0 bg, white text)
Tapping "Enable Notifications" Button shows Loader2 spinner, disabled
OS granted Sheet closes, no toast
OS denied Sheet closes, permission-denied banner appears
"Not now" tapped Sheet closes, localStorage flag set, no banner

Settings toggle

State Visual
On Track: --color-member-0, thumb: white
Off Track: --color-border, thumb: white
Disabled (permission denied) Track: --color-border, opacity 0.5, no pointer events
Toggling on (awaiting subscribe) Loader2 spinner replaces toggle, 20px
Toggling off Immediate visual, subscribe DELETE in background

Permission-denied banner

State Visual
Shown Always visible below AppNav when permission === 'denied' and was previously enabled
"How to enable" tapped Opens OS-specific instruction sheet
Permission restored externally Banner disappears on next Notification.permission check

Z-Index Layering

Matches existing layers (from component audit):

Layer z-index Surface
Bottom tab bar 200 BottomTabBar (existing)
Backdrop 300 CreateListSheet, Settings sheet backdrop
Sheet / Dialog 301 CreateListSheet, Settings sheet, permission prompt sheet
Overlay dialogs 1000 WalkthroughSheet (existing), permission prompt (matches WalkthroughSheet)

Permission prompt uses zIndex: 999 for backdrop, zIndex: 1000 for sheet — matching the existing WalkthroughSheet in InstallPrompt.tsx.


Component Inventory

New components this phase:

Component File Reuses
PushPermissionPrompt apps/pwa/src/components/PushPermissionPrompt.tsx WalkthroughSheet layout, InstallPrompt token pattern
SettingsSheet apps/pwa/src/components/SettingsSheet.tsx CreateListSheet layout, AppNav avatar trigger
NotificationToggle inside SettingsSheet.tsx inline — no separate file needed
PermissionDeniedBanner apps/pwa/src/components/PermissionDeniedBanner.tsx InstallPrompt banner layout
usePushSubscription apps/pwa/src/hooks/usePushSubscription.ts new hook — manages subscribe/unsubscribe lifecycle

Modified components:

  • apps/pwa/src/components/InstallPrompt.tsx — add PushPermissionPrompt trigger after install confirms (D-08)
  • apps/pwa/src/components/AppNav.tsx — promote user avatar div to <button> opening SettingsSheet
  • apps/pwa/src/App.tsx — mount PermissionDeniedBanner and SettingsSheet

No new routes. No new tabs in BottomTabBar. Settings is a sheet, not a route.


Accessibility Requirements

Surface Requirement
PushPermissionPrompt role="dialog", aria-modal="true", focus trap on open
SettingsSheet role="dialog", aria-modal="true", Escape key to close, backdrop click to close
NotificationToggle role="switch", aria-checked, aria-label, 44px touch target
PermissionDeniedBanner role="alert" (assertive live region)
All buttons minHeight: 44px, minWidth: 44px for touch targets
All icon-only buttons aria-label present, icon has aria-hidden="true"
Push notifications (OS) tag field set to prevent duplicate stacking

Registry Safety

Registry Blocks Used Safety Gate
shadcn official none not applicable — no shadcn
Third-party none not applicable

No third-party component registries. All components use the existing inline-style pattern from the established codebase.


Checker Sign-Off

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: PASS
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: PASS
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS

Approval: pending