Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md
T
Lucas Berger 6f5110266d docs(03): UI design contract
Establishes Phase 3 visual and interaction contract for event write-back
(EventForm, SyncStateToast, DeleteConfirmationDialog, calendar picker) and
PWA install surfaces (iOS A2HS walkthrough, Android beforeinstallprompt).
Inherits Phase 2 token layer in full; activates --color-destructive for
delete actions.
2026-06-05 16:39:15 -04:00

25 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
3 event-write-back-pwa-install draft false none 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):

<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