---
phase: 3
slug: event-write-back-pwa-install
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-05
---

# Phase 3 — UI Design Contract

## Event Write-Back + PWA Install

> Visual and interaction contract for Phase 3. Generated by gsd-ui-researcher.
> Verified by gsd-ui-checker before execution begins.
>
> **Inheritance note:** Phase 3 inherits the full Phase 2 token layer
> (`apps/pwa/src/styles/tokens.css`) without modification. All tokens below
> are already committed in that file. This spec extends the Phase 2 contract
> with write-path UI (EventForm, sync-state feedback, calendar picker), PWA
> install surfaces (iOS walkthrough, Android prompt), and the destructive
> delete confirmation pattern.

---

## Design System

| Property          | Value                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| Tool              | none — CSS custom-property token layer (Phase 2 D-01/D-02)                                                         |
| Preset            | not applicable                                                                                                     |
| Component library | none — custom components against token layer                                                                       |
| Icon library      | lucide-react (already used: MapPin; Phase 3 adds: Edit2, Trash2, X, Check, AlertCircle, Loader2, Smartphone, Plus) |
| Font              | system-ui stack: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`                            |

No `components.json` exists. No shadcn initialization needed — the token layer
is sufficient for Phase 3's form and overlay surfaces.

---

## Token Layer (inherited — no new tokens required)

All tokens live in `apps/pwa/src/styles/tokens.css`. Phase 3 reuses them verbatim.
The one Phase-2-declared-but-unused token now activates:

| Token                 | Hex       | Phase 3 Usage                                             |
| --------------------- | --------- | --------------------------------------------------------- |
| `--color-destructive` | `#DC2626` | Delete button label + icon; destructive confirmation text |

No new CSS custom properties are introduced in Phase 3. Component styles reference
existing `--color-*`, `--space-*`, and `--text-*` tokens only.

---

## Color

_Source: Phase 2 token layer (`tokens.css`), pre-populated — no changes._

| Role            | Token                                                               | Hex                             | Usage                                                                                                                             |
| --------------- | ------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Dominant (60%)  | `--color-surface` / `--color-surface-dim`                           | `#FFFFFF` / `#F7F7F8`           | Page background, form surface, modal backdrop wash                                                                                |
| Secondary (30%) | `--color-surface-raised`, `--color-border`, `--color-border-subtle` | `#FFFFFF`, `#E2E4E9`, `#ECEEF2` | Form card shell, input borders, section dividers, popover shells                                                                  |
| Accent (10%)    | Per-member `--color-member-*` + `--color-shared-family`             | varies                          | Event chip fills, color legend swatches, calendar picker swatch — same as Phase 2. Never used on buttons, headings, or nav chrome |
| Destructive     | `--color-destructive`                                               | `#DC2626`                       | Delete action button label + icon ONLY; delete confirmation dialog text                                                           |
| Focus ring      | `--color-focus-ring`                                                | `#4A90D9`                       | Keyboard focus on all interactive elements                                                                                        |

**Accent reserved for:** event chip fills, color legend swatches, calendar picker color swatches. Accent colors MUST NOT appear on form submit buttons, nav items, headings, input labels, or the iOS/Android install surfaces.

**Primary action button color:** `--color-text-primary` (#111318) background — a dark, neutral filled button. This is intentional: the accent palette is member-color-semantic. CTA buttons use the neutral dark fill, not an accent color.

---

## Spacing Scale

_Source: Phase 2 token layer — inherited unchanged._

| Token    | CSS var      | Value | Phase 3 Usage                                                                   |
| -------- | ------------ | ----- | ------------------------------------------------------------------------------- |
| space-1  | `--space-1`  | 4px   | Icon gap within button labels, tight inline padding                             |
| space-2  | `--space-2`  | 8px   | Input label-to-field gap, compact section dividers                              |
| space-3  | `--space-3`  | 12px  | Form field internal padding (input horizontal), row gaps in install walkthrough |
| space-4  | `--space-4`  | 16px  | Default vertical field gap in EventForm, popover section gap                    |
| space-6  | `--space-6`  | 24px  | EventForm section padding, modal inner padding, install card padding            |
| space-8  | `--space-8`  | 32px  | Layout gaps; gap between EventForm footer buttons                               |
| space-12 | `--space-12` | 48px  | Major section breaks in iOS install walkthrough                                 |

**Exceptions:**

- Touch targets: minimum 44×44px on all interactive elements (iOS HIG). Enforced via `min-height: 44px`. Not a spacing token — a layout constraint.
- Input height: 44px minimum (satisfies touch target + visual weight).
- Delete confirmation dialog action area: min 48px button height (destructive actions warrant extra tap weight).

---

## Typography

_Source: Phase 2 token layer — inherited unchanged._

| Role    | CSS vars           | Size | Weight | Line Height | Phase 3 Usage                                                                                                      |
| ------- | ------------------ | ---- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------ |
| Body    | `--text-body-*`    | 15px | 400    | 1.5         | Form field values, description textarea, install walkthrough body copy, sync-state toast body                      |
| Label   | `--text-label-*`   | 13px | 400    | 1.4         | Input labels, placeholder text, helper text, calendar picker option labels, recurrence preset labels               |
| Heading | `--text-heading-*` | 18px | 600    | 1.25        | EventForm modal title ("New Event" / "Edit Event"), install walkthrough step headings, delete confirmation heading |
| Display | `--text-display-*` | 24px | 600    | 1.2         | Not used in Phase 3 new surfaces (reserved for nav/day-view from Phase 2)                                          |

**Weights declared:** 400 (regular) and 600 (semibold). No other weights.

**Form input text:** 15px body weight (400). This matches the body token and is large enough for comfortable iOS touch input.

**Error/helper text under inputs:** 13px label weight, `--color-destructive` for validation errors, `--color-text-muted` for neutral helpers.

---

## Component Inventory

### EventDetailPopover (extended — Phase 2 component)

Phase 3 activates the reserved footer action area (line 380, `EventDetailPopover.tsx`).

**Footer — read mode:**

- "Edit" button: left-aligned, `--color-text-primary` label, Edit2 icon (16px), 44px touch target, ghost/text style (no fill)
- "Delete" button: right-aligned, `--color-destructive` label, Trash2 icon (16px), 44px touch target, ghost/text style

**Footer layout:** flex row, space-between. Both buttons use the label type (13px/400).

### EventForm (new component)

Mounted as a modal overlay. On phone: full-screen bottom sheet (same pattern as EventDetailPopover). On tablet/desktop: centered dialog (max-width 480px, 8px radius, shadow).

**Fields (in order):**

| Field          | Input type                                                               | Required                  | Placeholder / helper |
| -------------- | ------------------------------------------------------------------------ | ------------------------- | -------------------- |
| Title          | text input                                                               | yes                       | "Event title"        |
| All-day toggle | toggle switch                                                            | —                         | Label: "All day"     |
| Start date     | date input (or date picker)                                              | yes                       | —                    |
| Start time     | time input                                                               | yes (hidden when all-day) | —                    |
| End date       | date input                                                               | yes                       | —                    |
| End time       | time input                                                               | yes (hidden when all-day) | —                    |
| Calendar       | dropdown/select (hidden when member has only 1 writable calendar — D-02) | yes                       | —                    |
| Recurrence     | segmented control / select: None / Daily / Weekly / Monthly / Yearly     | —                         | Default: None        |
| Location       | text input                                                               | no                        | "Add location"       |
| Description    | textarea (3 rows)                                                        | no                        | "Add description"    |

**Field styling:**

- Input border: 1px solid `--color-border`; on focus: 2px `--color-focus-ring`, 2px offset
- Input border-radius: `--space-1` (4px)
- Background: `--color-surface`
- Label: 13px/400, `--color-text-secondary`, `--space-1` below label
- Input text: 15px/400, `--color-text-primary`
- Error state: border color `--color-destructive`; error message 13px `--color-destructive` below field

**Calendar picker (visible only when >1 writable calendar — D-02):**

- Dropdown showing calendar name + color swatch (8px circle, member color)
- Label: "Calendar"
- Options: personal calendar + "Family" (if shared family calendar exists)

**Recurrence picker:**

- Simple segmented select: "None" | "Daily" | "Weekly" | "Monthly" | "Yearly"
- Whole-series only (D-11). No custom RRULE builder in v1.

**Footer buttons:**

- Cancel: ghost button, `--color-text-secondary`, left/secondary position
- Save: filled button, `--color-text-primary` background, `#FFFFFF` label, right/primary position
- Button height: 44px minimum; border-radius: `--space-1`
- In-flight (after Save tapped, before API response): Save button shows Loader2 spinner (16px, `#FFFFFF`), disabled state. Label changes to "Saving…".

### CalendarPicker (within EventForm — conditional, D-02)

Hidden entirely when the member has exactly one writable calendar (prevents the non-technical member from ever seeing a choice that doesn't exist yet). Visible only when the `calendars` API returns more than one writable record.

### SyncStateToast (new component)

Displayed after a write operation is accepted (D-05). NOT a blocking modal.

**Position:** Bottom of screen, above the tab bar / nav area on phone; bottom-right on tablet/desktop. Persists until terminal state is reached.

**States:**

| Status    | Icon                     | Copy                                          | Color                                         |
| --------- | ------------------------ | --------------------------------------------- | --------------------------------------------- |
| `pending` | Loader2 (spinning, 14px) | "Syncing…"                                    | `--color-text-secondary`                      |
| `done`    | Check (14px)             | "Saved"                                       | `#50C878` (member-1 green — success semantic) |
| `failed`  | AlertCircle (14px)       | "Didn't save — [conflict message or generic]" | `--color-destructive`                         |
| `dead`    | AlertCircle (14px)       | "Not saved. Check your connection."           | `--color-destructive`                         |

**Toast styling:**

- Background: `--color-surface-raised`; 1px border `--color-border`; 4px border-radius; subtle shadow
- Padding: `--space-2` vertical, `--space-3` horizontal
- Font: 13px/400 label
- Auto-dismiss on `done` after 2 seconds. `failed`/`dead` states persist until dismissed (requires user tap).
- `failed` / `dead` toast includes an "×" dismiss button (X icon, 16px, 44px touch target).

**Conflict-specific toast (`failed` with 412 cause):**

- Copy: "This event changed elsewhere — review the latest version"
- No auto-dismiss; persists until user taps dismiss

### DeleteConfirmationDialog (new component)

Triggered by the "Delete" button in EventDetailPopover footer.

**Layout:** Centered modal dialog on all breakpoints (max-width 320px). Backdrop: `--color-overlay`.

**Content:**

- Heading (18px/600): "Delete event?"
- Body (15px/400, `--color-text-secondary`): "This will be removed from your Fastmail calendar."
- Actions (flex row, right-aligned):
  - "Cancel" — ghost button, `--color-text-secondary`, 44px height
  - "Delete" — filled button, `--color-destructive` background, `#FFFFFF` label, Trash2 icon (16px), 48px height

No checkbox, no "don't ask again". Every delete requires explicit confirmation (two-person household, accidental deletes are high-cost).

### InstallPrompt (new component)

Single component handling both iOS and Android flows. Renders nothing when already installed (`display-mode: standalone`).

**iOS walkthrough (triggered by `isIOSSafariNonStandalone()`):**

Trigger: First visit when iOS Safari non-standalone detected. A dismissible banner slides down from below the nav bar. Not a blocking modal.

Banner:

- Background: `--color-surface-raised`; 1px bottom border `--color-border`
- Icon: Smartphone (24px, `--color-text-secondary`)
- Heading (13px/600): "Install FamilySync"
- Body (13px/400, `--color-text-secondary`): "Add to your Home Screen for the best experience."
- "How to install" button: text link style, 13px, `--color-focus-ring` blue, opens the full walkthrough sheet
- Dismiss (X icon): right-aligned, 44px touch target; sets `localStorage.installPromptShown = '1'` — banner never shown again

Walkthrough sheet (full-screen bottom sheet on phone):

- Header: "Add to Home Screen" (heading, 18px/600)
- 5 steps with annotated screenshots:
  1. "Open FamilySync in Safari" — Safari icon callout
  2. "Tap the Share button" — annotated iOS screenshot (Share icon highlighted)
  3. "Scroll down and tap 'Add to Home Screen'" — annotated iOS screenshot
  4. "Tap 'Add' in the top right" — annotated iOS screenshot
  5. "Open FamilySync from your Home Screen — it opens without the browser bar"
- Screenshot annotations: orange (#F5A623 — `--color-member-2`) highlight circle / arrow overlay on each screenshot
- "Done" button closes the sheet

**Android install prompt (triggered by `beforeinstallprompt`):**

Shown only when `canInstall === true` (the event has fired and not yet been dismissed).
A banner identical in layout to the iOS banner, but:

- Body: "Install FamilySync to your Home Screen for the best experience."
- Single CTA button: "Install" (filled, `--color-text-primary` background, 44px, replaces "How to install" link)
- Tapping "Install" calls `triggerInstall()` then dismisses banner

---

## Copywriting Contract

| Element                              | Copy                                                                       |
| ------------------------------------ | -------------------------------------------------------------------------- |
| Primary CTA — create                 | "New Event" (+ icon, Plus 16px)                                            |
| Primary CTA — save (create mode)     | "Create Event"                                                             |
| Primary CTA — save (edit mode)       | "Save Changes"                                                             |
| Form title — create mode             | "New Event"                                                                |
| Form title — edit mode               | "Edit Event"                                                               |
| Save in-flight label                 | "Saving…"                                                                  |
| Sync toast — pending                 | "Syncing…"                                                                 |
| Sync toast — done                    | "Saved"                                                                    |
| Sync toast — failed (generic)        | "Didn't save. Try again."                                                  |
| Sync toast — failed (conflict / 412) | "This event changed elsewhere — review the latest version"                 |
| Sync toast — dead                    | "Not saved. Check your connection."                                        |
| Delete button label                  | "Delete"                                                                   |
| Delete confirmation heading          | "Delete event?"                                                            |
| Delete confirmation body             | "This will be removed from your Fastmail calendar."                        |
| Delete confirmation CTA              | "Delete"                                                                   |
| Delete confirmation cancel           | "Cancel"                                                                   |
| Calendar picker label                | "Calendar"                                                                 |
| Recurrence picker label              | "Repeat"                                                                   |
| Recurrence options                   | "None" / "Daily" / "Weekly" / "Monthly" / "Yearly"                         |
| All-day toggle label                 | "All day"                                                                  |
| Title input placeholder              | "Event title"                                                              |
| Location input placeholder           | "Add location"                                                             |
| Description placeholder              | "Add description"                                                          |
| Empty title validation error         | "Title is required"                                                        |
| End-before-start validation error    | "End time must be after start"                                             |
| iOS install banner heading           | "Install FamilySync"                                                       |
| iOS install banner body              | "Add to your Home Screen for the best experience."                         |
| iOS install banner CTA               | "How to install"                                                           |
| Android install banner body          | "Install FamilySync to your Home Screen for the best experience."          |
| Android install banner CTA           | "Install"                                                                  |
| iOS walkthrough sheet heading        | "Add to Home Screen"                                                       |
| iOS walkthrough step 1               | "Open FamilySync in Safari"                                                |
| iOS walkthrough step 2               | "Tap the Share button"                                                     |
| iOS walkthrough step 3               | "Scroll down and tap 'Add to Home Screen'"                                 |
| iOS walkthrough step 4               | "Tap 'Add' in the top right"                                               |
| iOS walkthrough step 5               | "Open FamilySync from your Home Screen — it opens without the browser bar" |
| iOS walkthrough close                | "Done"                                                                     |

**Destructive actions and confirmation patterns:**

| Action       | Trigger                                      | Confirmation approach                             |
| ------------ | -------------------------------------------- | ------------------------------------------------- |
| Delete event | "Delete" button in EventDetailPopover footer | DeleteConfirmationDialog modal — explicit two-tap |

No inline delete (single tap). No "undo" toast. Confirmation dialog is mandatory for all deletes.

---

## Interaction Contract

### EventForm — open / close

- Create: tapped from a "New Event" FAB (floating action button, phone) or a toolbar button (tablet/desktop). Opens as bottom sheet (phone) or centered dialog (tablet/desktop).
- Edit: tapped from "Edit" in EventDetailPopover footer. EventDetailPopover closes, EventForm opens with fields pre-populated.
- Escape key (keyboard) or tap-backdrop: dismisses form. No confirmation required for unsaved new events. No confirmation required for unsaved edits (low-cost; user can re-open).
- Save: calls `POST /api/events/create` or `PATCH /api/events/:uid/edit`. Returns 202 immediately (D-05). SyncStateToast appears. Form closes.

### EventForm — all-day toggle behavior

- Toggling "All day" ON: hides start-time and end-time inputs. End date auto-advances to match start date if end date is before start date.
- Toggling "All day" OFF: shows time inputs with default values (start: 09:00, end: 10:00).
- No animation; show/hide is instantaneous.

### EventForm — calendar picker (conditional, D-02)

- Hidden when member has exactly 1 writable calendar.
- Shown when member has 2 writable calendars (personal + shared Family).
- Default selection: last-used calendar (D-01). First-time default: personal calendar.
- Calendar move (edit mode): if user changes the calendar selection, the API handler produces a delete-from-old + create-on-new pair (D-04). No special UI — the form treats it as a normal save.

### EventForm — recurrence

- Default: "None" (selected).
- Selecting any recurrence preset applies a whole-series RRULE (D-11).
- In edit mode on a recurring event: recurrence field shows the current RRULE preset (if it maps to a simple preset) or "Custom" (read-only, if the existing RRULE doesn't match any preset). Custom RRULE editing is not available in v1.
- No "edit this occurrence / edit all" fork in v1 (D-11 / CAL-09 deferred).

### Sync-state feedback

- After Save: form closes immediately. SyncStateToast shows "Syncing…" with spinner.
- TanStack Query polls `/api/events/sync-status?uid={uid}` at 3-second intervals while status is `pending`.
- On `done`: toast updates to "Saved" (Check icon), auto-dismisses after 2 seconds. `queryClient.invalidateQueries(['events'])` fires to refresh the calendar view.
- On `failed` / `dead`: toast updates to error state, persists until user dismisses. Calendar is NOT refreshed (optimistic event may still show — this is intentional; the user sees what they intended and can retry or dismiss).
- Conflict (`failed` with 412 message): calendar refreshes via `invalidateQueries(['events'])` to show the actual server state. Toast shows conflict copy.

### Delete interaction

1. User taps "Delete" in EventDetailPopover footer.
2. EventDetailPopover remains open; DeleteConfirmationDialog appears above it (z-index higher).
3. User taps "Cancel": dialog closes, popover resumes.
4. User taps "Delete" (red): dialog and popover both close. API call fires (`DELETE /api/events/:uid`). SyncStateToast shows "Syncing…". Calendar optimistically removes the event.
5. On sync `done`: toast auto-dismisses. Event confirmed gone.
6. On sync `failed`: toast shows error. Event MAY reappear in calendar on next refetch (server-authoritative state wins). No silent loss.

### iOS install walkthrough

- Banner is shown once per device per user (localStorage flag `installPromptDismissed`).
- Banner is never shown when `window.matchMedia('(display-mode: standalone)').matches` is true.
- Banner is never shown on non-iOS devices (Android and desktop get the `beforeinstallprompt` flow or nothing).
- Tapping "How to install" opens the walkthrough sheet (full-screen bottom sheet, not a new page).
- Walkthrough sheet has a close button (X, top-right, 44px) and a "Done" button at the bottom.
- Dismissing the banner (X) records the flag and hides the banner permanently. The walkthrough remains accessible from a "?" / "Install" link in AppNav settings (if future phases add a settings surface) — for Phase 3, the banner is the only trigger.

### Android install

- Install banner appears only when `canInstall === true` (the `beforeinstallprompt` event fired).
- Never shown on iOS or desktop.
- Tapping "Install" calls the deferred prompt. On `accepted`: banner disappears permanently, `appinstalled` event fires. On `dismissed`: banner hides for the session (not permanently — the event may re-fire on a future visit).

### Touch targets

All interactive elements in Phase 3 new surfaces: minimum 44×44px. Enforced via `min-height: 44px` and `padding` where needed. Applies to: form buttons, all-day toggle, recurrence options, calendar picker, delete confirmation buttons, install banner buttons, walkthrough step close/done.

### Keyboard / accessibility

- EventForm: focus moves to the Title input when the form opens. Tab order follows DOM order (title → all-day → start date → [start time] → end date → [end time] → [calendar] → recurrence → location → description → cancel → save).
- EventForm: `role="dialog"`, `aria-modal="true"`, `aria-label="New Event"` / `"Edit Event"`.
- Focus trap inside EventForm and DeleteConfirmationDialog while open.
- Escape closes EventForm (no confirmation). Escape closes DeleteConfirmationDialog without deleting.
- All-day toggle: `role="switch"`, `aria-checked`, keyboard-activatable with Space.
- Recurrence picker: `role="radiogroup"` with `role="radio"` options or a `<select>` — either is acceptable.
- Delete button: `aria-label="Delete event"`.
- SyncStateToast: `role="status"` (polite live region) for `pending`/`done`; `role="alert"` (assertive) for `failed`/`dead`.
- Install banner: `role="banner"` (or `role="complementary"`). Dismiss button: `aria-label="Dismiss install prompt"`.

---

## State Management Contract

_Extends Phase 2 contract. Server state in TanStack Query; UI state in Zustand._

| State                    | Owner          | Key                      | Notes                                                         |
| ------------------------ | -------------- | ------------------------ | ------------------------------------------------------------- |
| Event list (read)        | TanStack Query | `['events', start, end]` | Invalidated on `done` sync or conflict re-sync                |
| Sync status (per UID)    | TanStack Query | `['syncStatus', uid]`    | `refetchInterval: 3000` while `pending`; disabled on terminal |
| Current user             | TanStack Query | `['me']`                 | Needed to determine writable calendar set                     |
| Writable calendars       | TanStack Query | `['writableCalendars']`  | Drives calendar picker visibility (D-02)                      |
| EventForm open           | Zustand        | `eventFormOpen`          | boolean                                                       |
| EventForm mode           | Zustand        | `eventFormMode`          | `'create' \| 'edit'`                                          |
| EventForm prefill UID    | Zustand        | `eventFormUid`           | `string \| null` — UID of event being edited                  |
| Delete dialog open       | Zustand        | `deleteDialogOpen`       | boolean                                                       |
| Delete dialog UID        | Zustand        | `deleteDialogUid`        | `string \| null`                                              |
| Last-synced UID          | Zustand        | `lastSyncedUid`          | Drives SyncStateToast display                                 |
| Install prompt dismissed | localStorage   | `installPromptDismissed` | Persistent across sessions                                    |

---

## PWA Manifest Contract

| Field              | Value                                                                    |
| ------------------ | ------------------------------------------------------------------------ |
| `name`             | FamilySync                                                               |
| `short_name`       | FamilySync                                                               |
| `description`      | Family calendar and lists                                                |
| `theme_color`      | `#4A90D9` (matches `--color-focus-ring`)                                 |
| `background_color` | `#FFFFFF`                                                                |
| `display`          | `standalone`                                                             |
| `scope`            | `/`                                                                      |
| `start_url`        | `/`                                                                      |
| `icons`            | 192×192 PNG, 512×512 PNG, 512×512 maskable PNG, 180×180 apple-touch-icon |

**Required HTML `<head>` entries (`apps/pwa/index.html`):**

```html
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="theme-color" content="#4A90D9" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FamilySync" />
```

**Service worker — critical denylist (Gate 2):**
The SW must NEVER intercept the OIDC callback. `navigateFallbackDenylist` must include:

- `/^\/callback/` — OIDC authorization code exchange
- `/^\/api\//` — all API calls
- `/^\/health/` — health endpoint

---

## Registry Safety

| Registry              | Blocks Used                                                                                           | Safety Gate                                                                       |
| --------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| shadcn official       | none — shadcn not initialized                                                                         | not applicable                                                                    |
| lucide-react (npm)    | Edit2, Trash2, X, Check, AlertCircle, Loader2, Smartphone, Plus (new); MapPin already used in Phase 2 | npm package — standard supply chain; no registry vetting required                 |
| vite-plugin-pwa (npm) | VitePWA plugin + workbox-window + workbox-build (peer deps)                                           | npm package — in CLAUDE.md recommended stack; pre-approved; standard supply chain |

No third-party shadcn registries. No registry vetting gate required.

---

## Pre-Population Sources

| Decision                                            | Source                                                                       |
| --------------------------------------------------- | ---------------------------------------------------------------------------- |
| Full token layer (colors, spacing, typography)      | `apps/pwa/src/styles/tokens.css` — codebase scan                             |
| `--color-destructive: #DC2626`                      | `tokens.css` line 52 — pre-declared in Phase 2 for Phase 3 reuse             |
| lucide-react as icon library                        | Phase 2 UI-SPEC §Design System; confirmed in `EventDetailPopover.tsx` import |
| system-ui font stack                                | `tokens.css` `--font-family-base`                                            |
| Bottom sheet / popover responsive pattern           | Phase 2 UI-SPEC §EventDetailPopover; `EventDetailPopover.tsx` implementation |
| 44px touch target requirement                       | Phase 2 UI-SPEC §Interaction Contract; iOS HIG                               |
| Edit/delete surface = EventDetailPopover footer     | CONTEXT.md D-10; Phase 2 D-08                                                |
| Calendar picker hidden when 1 writable calendar     | CONTEXT.md D-02                                                              |
| Default calendar = last-used; first-time = personal | CONTEXT.md D-01                                                              |
| Optimistic accept + SyncStateToast                  | CONTEXT.md D-05/D-06/D-09                                                    |
| Conflict detection + warn (no silent overwrite)     | CONTEXT.md D-08                                                              |
| Polling (not SSE) for sync state                    | CONTEXT.md D-09 (SSE unverified until Phase 4)                               |
| Recurring: whole-series presets only                | CONTEXT.md D-11; REQUIREMENTS.md CAL-07                                      |
| iOS install = first-visit auto-detect banner        | CONTEXT.md §Claude's Discretion; RESEARCH.md Pattern 6                       |
| Android install = `beforeinstallprompt`             | CONTEXT.md §Claude's Discretion; RESEARCH.md Pattern 7                       |
| PWA manifest fields + SW denylist                   | RESEARCH.md Pattern 5                                                        |
| `theme_color: #4A90D9`                              | RESEARCH.md Pattern 5 (matches `--color-member-0`)                           |
| iOS annotated walkthrough 5-step content            | RESEARCH.md Pattern 6                                                        |
| Two-tap delete confirmation                         | Researcher default (destructive, irreversible, two-person household)         |
| `role="status"` / `role="alert"` for toast          | WCAG live region pattern — researcher default                                |

---

## 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
