Files
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

27 KiB
Raw Permalink Blame History

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