Files
familysync/.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md
T

477 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 23
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` `<NavLink>` 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` `<BrowserRouter>` 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 23 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