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.
493 lines
19 KiB
Markdown
493 lines
19 KiB
Markdown
---
|
|
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
|