From 492b85e9dc333270f09f7213144761392c3149e2 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 09:43:32 -0400 Subject: [PATCH] docs(04): UI design contract for shared lists + live sync Co-Authored-By: Claude Sonnet 4.6 --- .../04-shared-lists-live-sync/04-UI-SPEC.md | 476 ++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md diff --git a/.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md b/.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md new file mode 100644 index 0000000..4e320e3 --- /dev/null +++ b/.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md @@ -0,0 +1,476 @@ +--- +phase: 4 +slug: shared-lists-live-sync +status: draft +shadcn_initialized: false +preset: none +created: 2026-06-09 +--- + +# Phase 4 — UI Design Contract + +> Visual and interaction contract for the Shared Lists + Live Sync phase. +> Generated by gsd-ui-researcher. Verified by gsd-ui-checker. +> +> **Design approach:** Extend the established token system from `apps/pwa/src/styles/tokens.css` +> without reinventing it. The lists surface must feel like a first-class sibling of the calendar — +> same font, same spacing scale, same surface/border/text palette. + +--- + +## Design System + +| Property | Value | Source | +|----------|-------|--------| +| Tool | none (custom CSS token layer) | tokens.css — established Phase 2 | +| Preset | not applicable | — | +| Component library | none (hand-built inline-style React components) | existing pattern | +| Icon library | lucide-react 1.17.0 | package.json | +| Font | system-ui / -apple-system stack | `--font-family-base` in tokens.css | + +**shadcn gate result:** `components.json` not found. Project uses a custom CSS custom-property token +system (`apps/pwa/src/styles/tokens.css`). This is an established pattern across all Phase 2–3 +components. Do NOT introduce shadcn or any Radix primitives in Phase 4 — extend the existing +token system and inline-style component convention. + +--- + +## Spacing Scale + +Declared values — multiples of 4px only. Inherited from `tokens.css`; do not declare new tokens. + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-1` | 4px | Icon gaps, badge padding, inline micro-gaps | +| `--space-2` | 8px | Compact padding (chip inner padding, tight row gaps) | +| `--space-3` | 12px | Dialog inner spacing, button horizontal padding | +| `--space-4` | 16px | Default horizontal padding (list cards, input fields) | +| `--space-6` | 24px | Section gaps, card padding top/bottom | +| `--space-8` | 32px | Layout gap between list cards | +| `--space-12` | 48px | Empty-state vertical padding, bottom-tab-bar height | + +**Exceptions:** +- Touch targets: minimum 44px height/width on all interactive elements (tap targets, checkboxes, + drag handles, delete buttons). This is a hard constraint for the non-technical Apple member. +- Bottom tab bar: 56px height on phone (aligns with iOS safe-area; provides 44px touch target with + padding). Use `env(safe-area-inset-bottom)` to push content above the home indicator. +- FAB ("New List"): 56px diameter on phone. +- Checkbox tap area: 44px × 44px min; visual checkbox can be 20px × 20px centered inside. + +--- + +## Typography + +Inherited from `tokens.css`. Use existing CSS custom properties — no new sizes. + +| Role | Token | Size | Weight | Line Height | Usage in Lists | +|------|-------|------|--------|-------------|----------------| +| Body | `--text-body-*` | 15px | 400 | 1.5 | Item text (active and completed), list description, confirmation body | +| Label | `--text-label-*` | 13px | 400 | 1.4 | Item metadata, "Completed" section header, badge counts, tab labels, timestamp | +| Heading | `--text-heading-*` | 18px | 600 | 1.25 | List name (in list detail), dialog heading ("Delete list?"), section separator | +| Display | `--text-display-*` | 24px | 600 | 1.2 | App name in AppNav (no change); NOT used inside list surfaces | + +**Weight contract:** 400 (regular) and 600 (semibold) only. No 500 or 700. + +**Completed items:** render at body size/weight but at `--color-text-muted` color with +`text-decoration: line-through`. Do NOT reduce font size for completed items. + +--- + +## Color + +Inherited palette from `tokens.css`. No new hex values introduced in Phase 4. + +| Role | Token / Value | Usage | +|------|---------------|-------| +| Dominant (60%) | `--color-surface` `#FFFFFF` | App background, list-detail content area, input backgrounds | +| Secondary (30%) | `--color-surface-dim` `#F7F7F8` | List cards on the list index, completed-section background, bottom tab bar background | +| Accent (10%) | `--color-member-0` `#4A90D9` | **Reserved exclusively for:** active tab indicator, FAB background, checkbox fill when checked, primary "Add Item" confirm button | +| Destructive | `--color-destructive` `#DC2626` | List delete button, item delete button (if surfaced as icon), "Delete" in confirmation dialog — destructive actions only | + +**Accent reserved for (complete list — nothing else uses accent):** +1. Active tab indicator (bottom tab bar selected state) +2. FAB background ("New List" button on list index) +3. Checked checkbox fill +4. "Add item" primary action button background + +**Secondary semantic colors (non-accent, non-destructive):** +- Live sync connected indicator: `--color-member-1` `#50C878` (green dot — reuses the + calendar's existing green; no new token needed) +- Live sync disconnected indicator: `--color-destructive` `#DC2626` (reuses existing destructive) +- Completed item text: `--color-text-muted` `#9CA3AF` +- Drag handle: `--color-text-muted` `#9CA3AF` + +**Border, text, focus ring:** use existing `--color-border`, `--color-text-*`, `--color-focus-ring` +tokens unchanged. + +--- + +## Layout: App Shell Changes + +Phase 4 restructures `App.tsx` to add routing and a bottom tab bar (D-16, D-17). + +### Bottom Tab Bar (phone, ≤767px) + +``` +┌───────────────────────────────────────────────┐ +│ ┌─────────────────┐ ┌─────────────────────┐ │ +│ │ 📅 Calendar │ │ 📋 Lists │ │ +│ │ (tab label) │ │ (tab label) │ │ +│ └─────────────────┘ └─────────────────────┘ │ +└───────────────────────────────────────────────┘ + height: 56px + env(safe-area-inset-bottom) + background: --color-surface-dim + border-top: 1px solid --color-border + active tab: icon + label in --color-member-0 (accent), underline 2px accent + inactive tab: icon + label in --color-text-muted +``` + +- Tab icons: `CalendarDays` (Calendar tab) and `List` (Lists tab) from lucide-react. +- Tab labels: 13px / 400 / `--text-label-*`. +- Active indicator: 2px bottom border on the tab in `--color-member-0`. Icon and label both take + accent color when active. +- Touch target: full tab cell (≥44px height guaranteed by 56px bar). + +### Desktop / Tablet (≥768px) — Left Sidebar Navigation + +On desktop the existing `AppNav` sidebar (240px) gains a "Lists" nav link below "Calendars". +No bottom tab bar on desktop. Use `react-router` `` for both Calendar and Lists links. + +### Routing (D-17) + +| Path | Component | +|------|-----------| +| `/` or `/calendar` | `CalendarShell` (existing) | +| `/lists` | `ListsIndex` — lists overview | +| `/lists/:listId` | `ListDetail` — single list items | + +`react-router` `` wraps `App.tsx`. Back button and PWA deep-links must work. + +--- + +## Component Inventory + +### New Components for Phase 4 + +All components follow the established inline-style pattern (no Tailwind, no CSS modules, no +shadcn). All text rendered as plain-text JSX children — no `dangerouslySetInnerHTML`. + +#### `BottomTabBar` + +``` +props: { activeTab: 'calendar' | 'lists' } +layout: fixed bottom, full-width, 56px + safe-area-inset-bottom +background: --color-surface-dim +border-top: 1px solid --color-border +tabs: 2 equal-width flex items, each min 44px height +icon size: 22px (lucide-react) +label size: --text-label-* (13px/400) +active: accent color + 2px top border-bottom on tab cell +inactive: --color-text-muted +z-index: 200 (below dialogs at 300) +``` + +#### `ListsIndex` + +``` +layout: full-height scrollable column with 16px horizontal padding +header: "Lists" heading (--text-display-* on desktop; --text-heading-* on phone) + + FAB ("+ New List") in top-right corner +list of cards: ListCard components in vertical stack, gap --space-4 +empty state: ListsEmptyState component (see Copywriting) +FAB position: phone — fixed bottom-right above tab bar, 56px circle, --color-member-0 bg + desktop — top-right inline button, not FAB +``` + +#### `ListCard` + +``` +layout: rounded card, padding --space-4 --space-6, background --color-surface + border: 1px solid --color-border + border-radius: --space-2 (8px) + box-shadow: 0 1px 3px rgba(0,0,0,0.06) +content: + - List name: --text-heading-* (18px/600), --color-text-primary + - Item count badge: "N items" or "N active · M done" at --text-label-* / --color-text-muted + - Sharing indicator: "Shared" pill (--color-surface-dim bg, --color-text-secondary text, + --space-1 --space-2 padding) or nothing for private + - Chevron right: lucide ChevronRight 16px, --color-text-muted, right edge + - Long-press / swipe-reveal on phone: reveal "Delete" button (--color-destructive) + - Tap: navigates to /lists/:listId +touch target: min 56px row height +``` + +#### `ListDetail` + +``` +layout: full-height flex column +header row: back arrow (ChevronLeft 20px) + list name (--text-heading-*) + kebab menu (MoreVertical) + Sharing badge: "Shared" or "Private" pill next to list name +active items section: scrollable list of ItemRow components +completed section: collapsible section header "Completed (N)" at --text-label-* / --color-text-muted + collapses/expands on tap; completed ItemRow components below +live sync indicator: top-right or header-right area — small colored dot (8px) + + "Live" label at --text-label-* or "Disconnected" on backoff-exhaust +add-item input: sticky bottom input above keyboard — full-width text input + "Add" button + (see Input Contract below) +``` + +#### `ItemRow` + +``` +layout: horizontal flex, min 44px height, padding --space-2 --space-4 +left: checkbox (20px visual, 44px touch area) — unchecked: --color-border ring; + checked: --color-member-0 fill, checkmark in white +center: item text — body size/weight for active; body size + line-through + --color-text-muted + for completed +right: drag handle (GripVertical 16px, --color-text-muted) — only on active items + hidden on completed items (completed items not reorderable) +delete: swipe-left reveals red delete zone on phone; hover shows X button on desktop + individual item delete is instant — no confirmation (D-06) +optimistic: item appears immediately on add; briefly dims (opacity 0.6) while server confirms; + rolls back (removes) if server rejects +drag-active: 4px drop-target line indicator between rows (--color-member-0); + dragged item shows 0.8 opacity with slight scale-down (0.98) +``` + +#### `AddItemInput` + +``` +position: sticky bottom of ListDetail, above keyboard on mobile +layout: horizontal flex — text input (flex:1) + "Add" button +input: --text-body-*, background --color-surface, border 1px --color-border, + border-radius --space-1, padding --space-2 --space-4, min-height 44px + placeholder: "Add an item…" + focus: border-color --color-focus-ring, outline none (custom ring) +button: "Add" label, background --color-member-0, color #fff, + --text-label-* / weight 600, border-radius --space-1, + min-height 44px, padding 0 --space-4 + disabled (empty input): opacity 0.5, cursor not-allowed +submit: Enter key OR tap "Add" button +``` + +#### `ListsEmptyState` + +``` +center-aligned in the list-index scrollable area +icon: ClipboardList (lucide-react, 32px, --color-text-muted) +heading: "No lists yet" (--text-heading-* / --color-text-primary) +body: "Tap + to create your first shared list — Groceries, Gift Ideas, or anything else." + (--text-body-* / --color-text-muted, max-width 280px) +``` + +#### `ListEmptyState` (used inside ListDetail when list has no items) + +``` +center-aligned in items area +icon: ListPlus (lucide-react, 32px, --color-text-muted) +heading: "Nothing here yet" (--text-heading-*) +body: "Add your first item below." (--text-body-* / --color-text-muted) +``` + +#### `CreateListSheet` (new list creation) + +``` +mobile: bottom sheet — slides up from bottom, 50vh height, backdrop overlay +desktop: inline modal — centered, max-width 360px +content: + - Heading: "New list" (--text-heading-*) + - Name input: required, --text-body-*, placeholder "e.g. Groceries" + - Sharing toggle: "Shared" (default) / "Private" — segmented control or toggle + shared = default (D-01), clearly labeled + - "Create" button: full-width, --color-member-0 bg, white text, 48px height + - Cancel: ghost text button above or below Create +focus: Name input auto-focuses on sheet open +validation: "Create" disabled while name is empty; no inline error until submit attempt + if blank submit attempted: input border turns --color-destructive, no toast +``` + +#### `LiveSyncIndicator` + +``` +position: right end of ListDetail header row +states: + connected: 8px filled circle in --color-member-1 (#50C878), no label (accessible via aria-label) + reconnecting: 8px pulsing circle in --color-text-muted + "Reconnecting…" label at --text-label-* + disconnected: 8px filled circle in --color-destructive + "Updates paused" label at --text-label-* +aria-label: "Live sync connected" / "Reconnecting" / "Updates paused — tap to retry" +visible: only inside ListDetail (not on ListsIndex) +``` + +--- + +## Interaction Contracts + +### Drag-to-Reorder (LIST-03, D-13) + +- **Library:** `@dnd-kit/core` + `@dnd-kit/sortable` (install in Phase 4; not yet in package.json). + Do NOT use `react-beautiful-dnd` (deprecated). Do NOT use HTML5 drag API directly (poor mobile). +- Drag handle: `GripVertical` lucide icon (16px), visible at all times in active-item rows. + Touch: drag initiates after 200ms long-press on the handle; prevents accidental drags. + Mouse: drag initiates on mousedown on the handle immediately. +- During drag: dragged item floats with `box-shadow: 0 4px 12px rgba(0,0,0,0.15)`, opacity 0.9. + Drop target gap: 3px line in `--color-member-0` renders between candidate drop positions. +- On drop: optimistic reorder (item snaps to new position immediately). PATCH `/api/list-items/:id` + with new fractional rank. On server rejection: animate item back to original position. +- Completed items: no drag handle, not reorderable. Only active items have drag affordance. +- Remote reorder (D-14): when an SSE event carries a position change, animate the affected item + sliding to its new position using a CSS transition (`transform` 150ms ease-out). + Do NOT hard-snap remote reorders — animate them. + +### Optimistic Updates (D-07) + +- Add item: item appears immediately at bottom of active list, with a loading state (opacity 0.6). + Snaps to full opacity on server confirm. Rolls back (removes with a brief flash) on rejection. +- Check off item: item moves immediately to completed section with animation (height collapse in + active list, height expand in completed section). CSS transition 200ms ease. Rolls back on + server rejection. +- Reorder: immediate snap to new order as described above. +- Delete item: item disappears immediately. No rollback — delete-wins (D-09). + +### Checked-Off Sink Behavior (D-05) + +- Active items occupy the top section, ordered by fractional rank. +- On check: item animates from active section → completed section. + Animation: height-collapse from active (200ms) + height-expand into completed (200ms staggered). + The `completed` section is always present at bottom; its header shows count ("Completed (3)"). +- On uncheck: reverses — item moves from completed → top of active section (append to bottom of + active, not restored to original rank position). +- The completed section header is a tappable toggle to collapse/expand the completed list. + Default state: expanded. + +### Live Sync + Reconnect (D-10, D-11, D-12) + +- SSE connection established on mount of `ListDetail`. One SSE stream per user session. + Events scoped to lists the member has access to (D-04 — no leakage of other members' private lists). +- On SSE event received: `queryClient.invalidateQueries({ queryKey: ['list', listId] })` triggers + a background refetch. Do NOT patch local cache manually — full refetch is the reconciliation + strategy (D-10). +- Reconnect backoff: `250ms → 500ms → 1000ms → 2000ms → 4000ms → cap 8000ms`. + Silent during backoff — no indicator while attempts remain. + After backoff exhausted (≥6 failed attempts): show `LiveSyncIndicator` "Updates paused" state. + React Query `refetchInterval: 30000` (D-12 polling fallback) activates when SSE disconnects. +- On reconnect: full refetch of active list(s), clear "Updates paused" indicator, show brief + "Connected" indicator (2s flash of green dot), return to normal state. +- SSE stream auth: inherited from existing `/api/*` OIDC middleware — same auth as all other routes. + +### List Delete (D-06) + +- Trigger: kebab menu (MoreVertical) → "Delete list" option in `ListDetail` header. + OR: swipe-reveal "Delete" button on `ListCard` in `ListsIndex`. +- Dialog: reuse `DeleteConfirmationDialog` pattern (same layout, backdrop, focus trap). + Heading: "Delete list?" + Body: ""{list name}" and all its items will be permanently removed." + Buttons: "Cancel" (ghost) + "Delete" (destructive, `--color-destructive` bg). +- On confirm: optimistic — navigate back to `/lists` immediately, list card disappears. + On server rejection (rare): toast "Couldn't delete. Try again." (same toast pattern as SyncStateToast). + +### Item Delete (D-06 — no confirmation for individual items) + +- Phone: swipe-left on `ItemRow` reveals a red delete zone (full row height, `--color-destructive` + background, white "Delete" label or `Trash2` icon). Tap the zone to delete. Swipe right or tap + elsewhere to cancel reveal. +- Desktop: hover on `ItemRow` reveals a `Trash2` button (16px, `--color-destructive`) at right edge. + Click to delete immediately. +- No confirmation dialog. Delete is instant and final (delete-wins, D-09). + +### Sharing Toggle (D-01, D-02) + +- Inside `CreateListSheet` and accessible via `ListDetail` kebab menu → "Edit list". +- Two-state toggle: "Shared" (default) | "Private". +- Visual: segmented control or labeled toggle — "Shared" selected by default, clearly labeled. +- Shared lists show a "Shared" pill badge on `ListCard`. Private lists show nothing. +- v1 only: "Shared" means shared with all other household members (no per-recipient picker). + +--- + +## Copywriting Contract + +| Element | Copy | Source | +|---------|------|--------| +| Primary CTA (new list) | "New List" (FAB label + sheet heading "New list") | D-01, default | +| Primary CTA (add item) | "Add" (button in AddItemInput) | D-02, default | +| Lists tab label | "Lists" | D-16, default | +| Calendar tab label | "Calendar" | D-16, default | +| Lists index empty heading | "No lists yet" | default | +| Lists index empty body | "Tap + to create your first shared list — Groceries, Gift Ideas, or anything else." | REQUIREMENTS LIST-01 + default | +| List detail empty heading | "Nothing here yet" | default | +| List detail empty body | "Add your first item below." | default | +| Add item placeholder | "Add an item…" | default | +| New list name placeholder | "e.g. Groceries" | default | +| Completed section header | "Completed ({N})" | D-05 | +| List delete dialog heading | "Delete list?" | D-06, matches Phase 3 pattern | +| List delete dialog body | ""{list name}" and all its items will be permanently removed." | D-06 | +| List delete confirm button | "Delete" | D-06, matches Phase 3 pattern | +| Item delete (swipe zone) | "Delete" | D-06, default | +| Live sync connected | aria-label: "Live sync connected" (no visible label) | D-11 | +| Live sync reconnecting | "Reconnecting…" | D-11 | +| Live sync disconnected | "Updates paused" | D-11 per CONTEXT.md "backoff-then-pause" | +| SSE error toast | "Couldn't load updates. Retrying…" | D-12 | +| List delete failure toast | "Couldn't delete. Try again." | D-06, matches SyncStateToast pattern | +| "Shared" sharing badge | "Shared" | D-01 | +| Create list button | "Create" | default | +| Sharing toggle labels | "Shared" / "Private" | D-01 | +| New list sheet cancel | "Cancel" | default, matches Phase 3 pattern | + +**Destructive action confirmation matrix:** + +| Action | Confirmation approach | +|--------|-----------------------| +| Delete a whole list | `DeleteConfirmationDialog` modal — explicit two-tap confirmation (D-06) | +| Delete an individual item | Instant on swipe-confirm / click — no dialog (D-06) | + +--- + +## Registry Safety + +No shadcn registry initialized. Registry safety gate: not applicable. + +| Package | Source | Safety Note | +|---------|--------|-------------| +| `@dnd-kit/core` + `@dnd-kit/sortable` | npm (open source, MIT) | New dependency; add to `apps/pwa/package.json`. No third-party registry. Standard npm vetting applies. | +| `react-router` (v7.x) | npm (open source, MIT) | New dependency for D-17 routing. No third-party registry. | +| All other libs | Existing in package.json | No change | + +--- + +## Accessibility Baseline + +All new components must meet these minimums (consistent with Phase 2–3 patterns): + +| Requirement | Specification | +|-------------|---------------| +| Touch targets | min 44px × 44px on ALL tappable elements | +| Focus ring | visible on all interactive elements; use `--color-focus-ring` (#4A90D9) | +| Keyboard nav | Tab order follows DOM order; dialogs trap focus; Escape closes dialogs/sheets | +| ARIA roles | `role="dialog"` + `aria-modal="true"` on sheets/dialogs; `role="list"` + `role="listitem"` on item lists | +| Drag-and-drop | Keyboard reorder fallback via arrow keys (dnd-kit provides this); ARIA announcement on drop | +| Live regions | `role="status"` for sync indicator changes; `role="alert"` for disconnected state | +| Empty states | `aria-live="polite"` on the list container so screen readers announce when items arrive | +| Checkboxes | `role="checkbox"`, `aria-checked`, `aria-label` with item text | + +--- + +## Security Notes + +Consistent with Phase 3 threat model: + +| Threat | Control | +|--------|---------| +| XSS via list/item names | All list names and item text rendered as plain-text JSX children — no `dangerouslySetInnerHTML` | +| SSE fan-out leak | Server MUST scope SSE events to members with list access (D-04); never broadcast to all connections | +| Delete-without-auth | All list/item routes behind OIDC middleware; identity resolved from session, not client payload | +| Private list leakage | `GET /api/lists` returns only lists owned by or shared with the current member | + +--- + +## 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