diff --git a/.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md b/.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md new file mode 100644 index 0000000..e85674b --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md @@ -0,0 +1,478 @@ +--- +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 `