Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
22 KiB
phase, slug, status, shadcn_initialized, preset, created
| phase | slug | status | shadcn_initialized | preset | created |
|---|---|---|---|---|---|
| 4 | shared-lists-live-sync | draft | false | none | 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.csswithout 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):
- Active tab indicator (bottom tab bar selected state)
- FAB background ("New List" button on list index)
- Checked checkbox fill
- "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) andList(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 usereact-beautiful-dnd(deprecated). Do NOT use HTML5 drag API directly (poor mobile). - Drag handle:
GripVerticallucide 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-0renders between candidate drop positions. - On drop: optimistic reorder (item snaps to new position immediately). PATCH
/api/list-items/:idwith 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 (
transform150ms 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
completedsection 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): showLiveSyncIndicator"Updates paused" state. React QueryrefetchInterval: 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
ListDetailheader. OR: swipe-reveal "Delete" button onListCardinListsIndex. - Dialog: reuse
DeleteConfirmationDialogpattern (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-destructivebg). - On confirm: optimistic — navigate back to
/listsimmediately, 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
ItemRowreveals a red delete zone (full row height,--color-destructivebackground, white "Delete" label orTrash2icon). Tap the zone to delete. Swipe right or tap elsewhere to cancel reveal. - Desktop: hover on
ItemRowreveals aTrash2button (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
CreateListSheetand accessible viaListDetailkebab 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