Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
34 KiB
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-primarylabel, Edit2 icon (16px), 44px touch target, ghost/text style (no fill) - "Delete" button: right-aligned,
--color-destructivelabel, 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-1below label - Input text: 15px/400,
--color-text-primary - Error state: border color
--color-destructive; error message 13px--color-destructivebelow 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-primarybackground,#FFFFFFlabel, 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-2vertical,--space-3horizontal - Font: 13px/400 label
- Auto-dismiss on
doneafter 2 seconds.failed/deadstates persist until dismissed (requires user tap). failed/deadtoast 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-destructivebackground,#FFFFFFlabel, Trash2 icon (16px), 48px height
- "Cancel" — ghost button,
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-ringblue, 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:
- "Open FamilySync in Safari" — Safari icon callout
- "Tap the Share button" — annotated iOS screenshot (Share icon highlighted)
- "Scroll down and tap 'Add to Home Screen'" — annotated iOS screenshot
- "Tap 'Add' in the top right" — annotated iOS screenshot
- "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-primarybackground, 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/createorPATCH /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 ispending. - 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 (
failedwith 412 message): calendar refreshes viainvalidateQueries(['events'])to show the actual server state. Toast shows conflict copy.
Delete interaction
- User taps "Delete" in EventDetailPopover footer.
- EventDetailPopover remains open; DeleteConfirmationDialog appears above it (z-index higher).
- User taps "Cancel": dialog closes, popover resumes.
- User taps "Delete" (red): dialog and popover both close. API call fires (
DELETE /api/events/:uid). SyncStateToast shows "Syncing…". Calendar optimistically removes the event. - On sync
done: toast auto-dismisses. Event confirmed gone. - 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)').matchesis true. - Banner is never shown on non-iOS devices (Android and desktop get the
beforeinstallpromptflow 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(thebeforeinstallpromptevent fired). - Never shown on iOS or desktop.
- Tapping "Install" calls the deferred prompt. On
accepted: banner disappears permanently,appinstalledevent fires. Ondismissed: 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"withrole="radio"options or a<select>— either is acceptable. - Delete button:
aria-label="Delete event". - SyncStateToast:
role="status"(polite live region) forpending/done;role="alert"(assertive) forfailed/dead. - Install banner:
role="banner"(orrole="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