docs(05): UI design contract for web-push-notifications phase

Specifies three new surfaces (permission prompt, settings sheet,
permission-denied banner) and notification copy templates for
NOTIF-01/02/03, all built from the existing Phase 2 token system.
This commit is contained in:
Lucas Berger
2026-06-09 18:03:18 -04:00
parent d136099dd8
commit 3723286e9e
@@ -0,0 +1,492 @@
---
phase: 5
slug: web-push-notifications
status: draft
shadcn_initialized: false
preset: none
created: 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)
---
## Tap-to-Open Deep Links (D-14)
| 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