---
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` `<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 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
