Files
familysync/.planning/milestones/v1.0-phases/02-calendar-display/02-UI-SPEC.md
T
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

24 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
2 calendar-display draft false none 2026-06-04

Phase 2 — UI Design Contract

Calendar Display

Visual and interaction contract for Phase 2. Generated by gsd-ui-researcher. Verified by gsd-ui-checker before execution begins.


Design System

Property Value
Tool none (shadcn not yet initialized)
Preset not applicable
Component library none — custom components against token layer
Icon library lucide-react (lightweight, tree-shakeable, first-party React SVGs; consistent stroke style)
Font system-ui stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif

Note: No components.json exists in the PWA app. The Phase 1 shell uses inline styles. Phase 2 introduces a CSS custom-property token layer (see § Token Layer below) as the primary design system primitive. shadcn may be added in Phase 3 when form components are needed.


Token Layer

This is the load-bearing deliverable of Phase 2 (D-01, D-02). All component styles MUST be written against these tokens. No hard-coded hex values or px measurements in component files.

File location

apps/pwa/src/styles/tokens.css       — CSS custom properties (the "clean" theme)
apps/pwa/src/styles/tokens.ts        — TypeScript token object (mirrors tokens.css)
apps/pwa/src/styles/index.css        — imports tokens.css; global resets; base body styles

Import tokens.css once in main.tsx. Components import from tokens.ts for inline style props; they use var(--token-name) in CSS Modules or className strings.

Calendar config constant

// apps/pwa/src/lib/calendarConfig.ts
export const WEEK_START_DAY = 0; // 0 = Sunday; flip to 1 = Monday with one edit

Pass to Schedule-X's firstDayOfWeek option. Do not hardcode 0 anywhere else.


Color Tokens

Base palette

Token Hex Role
--color-surface #FFFFFF Page background, calendar grid cells
--color-surface-dim #F7F7F8 Off-white wash: week/day off-hours bands, modal backdrop
--color-surface-raised #FFFFFF Cards, popovers (shadow provides elevation)
--color-border #E2E4E9 Grid lines, dividers, input borders
--color-border-subtle #ECEEF2 Secondary separators
--color-text-primary #111318 Body text, event titles
--color-text-secondary #6B7280 Meta text: times, locations, legend labels
--color-text-muted #9CA3AF Placeholder, empty-state body, disabled
--color-focus-ring #4A90D9 Keyboard focus outline (3px, 2px offset)
--color-overlay rgba(0,0,0,0.32) Popover backdrop scrim

Semantic calendar colors

These are the ONLY colors used for event fills. All are derived from member records (users.color) or the reserved shared-family constant.

Token Hex Assigned to Source
--color-member-0 #4A90D9 Lucas (member 1) Phase-1 users.color
--color-member-1 #50C878 Wife (member 2) Phase-1 users.color
--color-member-2 #F5A623 Slot 3 (future) Phase-1 palette
--color-member-3 #9B59B6 Slot 4 (future) Phase-1 palette
--color-member-4 #E67E22 Slot 5 (future) Phase-1 palette
--color-member-5 #1ABC9C Slot 6 (future) Phase-1 palette
--color-shared-family #F25C7A Shared-family calendar (ALL members) Confirmed by user

Implementation note: The calendars configuration object passed to Schedule-X is built dynamically at runtime by mapping users.color values to Schedule-X lightColors.main. The --color-member-* tokens are the canonical source; the Schedule-X config derives from them. The shared-family calendar always uses #F25C7A regardless of any user row.

Color derivation rule for event chips

For each member color MAIN, derive:

Sub-token suffix Derivation Usage
container MAIN at 15% opacity over white Event chip background
onContainer MAIN darkened 40% Event chip text, passed to Schedule-X

These need not be pre-declared for every slot — compute them with a small utility function (colorTokens.ts) at runtime using CSS Color Level 4 or a tiny LCH/hex math helper.

60 / 30 / 10 split

Band Tokens Approximate coverage
60% dominant (surface) --color-surface, --color-surface-dim Calendar grid, page background
30% secondary (structure) --color-surface-raised, --color-border, --color-border-subtle Cards, nav bar, header, popover shells
10% accent --color-shared-family + per-member fills Event chips only

Accent reserved for: event chip fills and the color legend swatches. Accent colors MUST NOT appear on buttons, nav items, headings, or any chrome element.

Destructive

Token Hex Usage
--color-destructive #DC2626 Not used in Phase 2 (read-only). Token declared for Phase 3 reuse.

Spacing Scale

All values are multiples of 4px. Use tokens; never write raw px values in components.

Token Value CSS var Usage
space-1 4px --space-1 Icon gap, badge dot, tight inline padding
space-2 8px --space-2 Event chip inner padding (vertical), color legend row gap
space-3 12px --space-3 Event chip inner padding (horizontal), compact cell padding
space-4 16px --space-4 Default element spacing, popover section gap
space-6 24px --space-6 Section padding, nav bar height rhythm
space-8 32px --space-8 Layout gaps, popover width gutter
space-12 48px --space-12 Major section breaks

Exceptions:

  • Touch targets: minimum 44px height/width on interactive elements (iOS HIG). This is a layout constraint, not a spacing token. Apply via min-height: 44px.
  • Calendar header row height: 48px (--space-12 used as a layout constant).
  • View toolbar height: 48px on phone; 56px on tablet/desktop.

Typography

Font family token: --font-family-base: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif

Role Token Size Weight Line Height Usage
Body --text-body 15px 400 1.5 Popover description, agenda location lines
Label --text-label 13px 400 1.4 Event chip text, time labels, legend labels, secondary meta
Heading --text-heading 18px 600 1.25 Popover title, view section headers (month name + year)
Display --text-display 24px 600 1.2 App name in nav bar (desktop), day number in day-view header

Weights declared: 400 (regular) and 600 (semibold). No other weights permitted.

Day-number size in month grid: 13px label weight. Current day: semibold (600) + colored dot.


Breakpoints

These are the only three breakpoints. Reference them by name in code, never by raw px value.

Name Token Min width Default view Notes
phone --bp-phone 0px Agenda Stacked single-column layout
tablet --bp-tablet 768px Month Two-column possible; nav becomes persistent sidebar
desktop --bp-desktop 1280px Month Full grid width

View default logic (D-05):

const isMobile = window.matchMedia('(max-width: 767px)').matches;
const defaultView = isMobile ? 'month-agenda' : 'month-grid';

Last-used view is persisted in Zustand and localStorage, keyed by breakpoint group ('phone' | 'tablet-desktop').


Calendar Rendering Library

Selected: Schedule-X (@schedule-x/react + @schedule-x/calendar)

Rationale:

  • Supports all four required views natively: createViewDay, createViewWeek, createViewMonthGrid, createViewMonthAgenda (agenda).
  • Theming via CSS custom properties — its --sx-color-* vars are overridden by mapping to this spec's token values in tokens.css. No Schedule-X default stylesheet bleeds through.
  • Per-calendar color is first-class (calendars config with lightColors.main / container / onContainer) — maps directly to per-member users.color and the shared-family rose.
  • onRangeUpdate callback fires when the user navigates, enabling TanStack Query to fetch only the visible window from /api/events?start=&end=.
  • Custom eventModal component via customComponents prop — the read-only detail popover is fully owned by this codebase and reusable as the Phase-3 edit surface (D-08).
  • React adapter ships as a first-class package; no wrapper hacks needed.
  • Active maintenance; Temporal-polyfill based (aligns with modern date handling).

Rejected alternatives:

  • react-big-calendar: opinionated CSS (hard to token-ify without !important fights); unmaintained moment / date-fns localization coupling; weak agenda view.
  • FullCalendar: commercial license for premium features; React package adds ~140 KB gzip.
  • Custom grid: correct for simple month-only, but building reliable day/week/agenda from scratch in one phase introduces unacceptable schedule risk.

Schedule-X CSS override strategy: Import @schedule-x/theme-default/dist/index.css, then immediately override all --sx-color-* vars in tokens.css to match this spec's surface/border/text tokens. Result: Schedule-X internal layout engine works; all colors come from this spec's tokens.


Component Inventory

CalendarShell

Top-level layout wrapper.

  • <AppNav> (top bar on phone; left sidebar 240px on tablet/desktop)
  • <ViewToolbar> (Today button, prev/next arrows, date label, view switcher)
  • <ColorLegend> (member → color; always visible on tablet/desktop; collapsible on phone)
  • <ScheduleXCalendar> (fills remaining space)

AppNav

  • Phone: top bar, 48px height, app name left, user avatar/color swatch right
  • Tablet/Desktop: left sidebar, 240px width; app name + color legend + (future) nav items

ViewToolbar

  • Buttons: Today | < | > | [Day] [Week] [Month] [Agenda]
  • Font: 13px label weight
  • Active view button: --color-member-0 (Lucas, current user) background at 12% opacity, semibold label. (Accent not used — active state uses a subtle surface tint.)
  • Touch targets: 44px minimum height

ColorLegend

  • One row per member: color swatch (12px circle) + display name
  • Shared-family row: rose swatch + "Family" label
  • Font: 13px label weight, --color-text-secondary
  • Always rendered; never interactive in Phase 2 (show/hide filter deferred)

EventChip (month grid)

  • Rounded pill, 4px radius
  • Background: member container color (15% opacity)
  • Text: member onContainer color, 13px, weight 400, single line, truncated with ellipsis
  • Left 3px solid border: member main color (the users.color hex directly)
  • Minimum height: 20px; minimum tap target area: 44px via transparent padding

EventBlock (week / day view)

  • Rectangular block, 4px radius
  • Same fill/border as EventChip
  • Displays: title (13px, weight 600) + start time (13px, weight 400) stacked
  • Overflow clips; no ellipsis in short blocks (too short = just color)

AgendaRow

  • Date group header: heading weight (18px/600), --color-text-primary
  • Event row: time (13px, muted) | title (15px, primary) | location (13px, secondary, italic)
  • Left 4px border strip: member color
  • Tap target: full row, min 44px height

EventDetailPopover (read-only in Phase 2; reused as edit surface in Phase 3)

  • Modal-style overlay on phone (full bottom sheet, slides up)
  • Popover anchored to event on tablet/desktop (max-width 360px, 8px radius, shadow)
  • Sections:
    • Color chip + title (heading, 18px/600)
    • Date/time line (label, 13px, secondary)
    • Location line, if present (label, 13px, secondary, with location icon)
    • Description block, if present (body, 15px, primary, max 4 lines before scroll)
    • Calendar name + owner color swatch (label, 13px, muted)
  • Close: X button top-right, 44px touch target; tapping backdrop dismisses
  • Phase 3 note: add edit/delete actions in the footer area (reserved but empty in Phase 2)

SkeletonCalendar

  • Month skeleton: 6×7 grid of rounded rect placeholders, animated shimmer (background: linear-gradient(90deg, --color-surface-dim, --color-border-subtle, --color-surface-dim))
  • Agenda skeleton: 4 date-group blocks, 23 rows each, varying widths (6090% of row)
  • Displayed when TanStack Query isLoading for initial fetch
  • No spinner; shimmer only (matches Fantastical-style)

EmptyState (no events in range)

  • Centered in the calendar viewport
  • Icon: lucide-react CalendarDays (32px, --color-text-muted)
  • Heading + body copy (see § Copywriting)
  • Only shown when fetch succeeded AND zero events returned for the visible window

View Layout Specification

Month view (default: tablet/desktop)

  • 7-column grid, column headers: SunSat (3-letter, label weight)
  • Day cells: 4px border, corner shows day number (13px label)
  • Today's cell: --color-surface-dim background; day number has filled dot indicator
  • Up to 3 event chips per cell; if more: "+N more" label (13px, muted, tappable → day view)
  • All-day events: span full cell width as a chip, no time shown, allDay: true flag
  • Off-month days: day number in --color-text-muted; cells at 60% opacity

Week view

  • Time column 48px wide; columns for each day
  • Current time indicator: 2px --color-member-0 (current user's color) horizontal line
  • All-day banner row at top, above time grid: full-width event blocks
  • Hours displayed: 00:0023:00 (full 24h); scroll to 08:00 on open
  • Event blocks overlap-handled by Schedule-X internals

Day view

  • Same layout as week, single day column (full width minus time column)
  • Date in header: --text-display (24px/600)

Agenda view (default: phone)

  • Chronological list, grouped by date
  • Infinite scroll or paginated by month (Schedule-X createViewMonthAgenda)
  • Past events: not shown; starts at today
  • No empty date rows; date headers only when events exist on that date

Copywriting Contract

Element Copy
Primary CTA (Phase 2) None — read-only phase; no create action
Empty state heading "Nothing here"
Empty state body "No events in this period. Try a different date or switch views."
Loading state (No text — skeleton shimmer only)
Error state heading "Couldn't load events"
Error state body "Check your connection and try again."
Error action "Retry" (taps queryClient.refetchQueries(['events']))
"+N more" label "+{N} more" (month grid overflow)
Popover close "×" (aria-label="Close")
Today button "Today"
Color legend — shared "Family"
Nav bar — app name "FamilySync"

Destructive actions in Phase 2: None. Phase 2 is read-only.


Interaction Contract

Navigation

  • Prev/next: advance by one unit of current view (day/week/month)
  • Today: jump to today's date, preserve current view
  • View switch: instant; no animation (avoid jank on low-end Android WebViews)
  • All transitions: no slide animations; content replaces in-place

Touch (iOS PWA)

  • All tap targets: minimum 44×44px (enforced via min-height / padding)
  • No hover states on touch devices (use :focus-visible only)
  • Swipe left/right on calendar grid: advance/retreat by one unit (Schedule-X built-in)
  • Tap event chip: open EventDetailPopover
  • Tap backdrop / swipe down: close EventDetailPopover (bottom sheet on phone)

Keyboard / accessibility

  • View toolbar buttons: focusable, role="button", keyboard activated with Enter/Space
  • Event chips: role="button", aria-label="{title}, {date}, {time}"
  • Popover: focus trap while open; Escape closes; focus returns to triggering element
  • Color legend swatches: aria-label="{name}: {color hex}"
  • Month grid cells: role="gridcell", aria-label="{date}"
  • Skeleton: aria-busy="true" on calendar root during loading

Error / retry

  • TanStack Query retry: 2 for events query; after exhaustion show error state
  • Error state replaces calendar grid (not a toast); "Retry" button triggers manual refetch

State Management Contract

State Owner Key Notes
Visible event list TanStack Query ['events', start, end] Invalidated on range change
Current user (/api/me) TanStack Query ['me'] Used for color derivation
Selected view Zustand + localStorage calendarView.{breakpointGroup} Persisted per device category
Selected date (nav) Zustand calendarSelectedDate ISO string; not persisted
Open popover event ID Zustand openEventId null when closed
Visible range Zustand calendarRange { start: string, end: string } — drives Query key

Server events NEVER enter Zustand. Zustand holds only UI-shape state.


Registry Safety

Registry Blocks Used Safety Gate
shadcn official none — shadcn not initialized in Phase 2 not applicable
schedule-x (npm) @schedule-x/react, @schedule-x/calendar, @schedule-x/theme-default npm package — no registry vetting gate required; standard npm supply chain
lucide-react (npm) icon components npm package — standard

No third-party shadcn registries in Phase 2.


Pre-Population Sources

Decision Source
Shared-family color = #F25C7A User-confirmed in phase prompt
WEEK_START_DAY = 0 (Sunday) User-confirmed in phase prompt
Token-layer architecture (D-01/D-02) CONTEXT.md §Theming
Clean theme only (D-02) CONTEXT.md §Theming
All four views (D-04) CONTEXT.md §Views, REQUIREMENTS.md CAL-03
Phone→Agenda / tablet→Month default (D-05) CONTEXT.md §Views
Per-member color from users.color (D-06) CONTEXT.md §Color, CLAUDE.md schema
Color legend, no show/hide filter (D-07) CONTEXT.md §Color
Informational density + tap-to-expand (D-08) CONTEXT.md §Event detail
Server-side recurrence expansion (D-09) CONTEXT.md §Recurrence, CLAUDE.md
Single local timezone, no shift for all-day (D-10) CONTEXT.md §Recurrence
TanStack Query = server state, Zustand = UI state CLAUDE.md, CONTEXT.md §Code patterns
React 19 + Vite stack CLAUDE.md §Recommended Stack
No shadcn yet (Phase 3 adoption) Codebase scan (no components.json)
Schedule-X as rendering library Researcher decision (D-Claude); see §Rendering Library

Checker Sign-Off

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: FLAG (non-blocking)
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: FLAG (non-blocking)
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS

Approval: VERIFIED 2026-06-04 (gsd-ui-checker — 4 PASS / 2 non-blocking FLAG)

Post-Verification Reviewer Notes (non-blocking — apply during implementation)

  • Visuals (apply): Declare the calendar grid (agenda list on phone) as the primary visual focal point, ViewToolbar/AppNav as secondary chrome. Add aria-label="{member name}" + title to the phone-nav avatar/color swatch so the icon-only affordance has a text fallback.
  • Typography (accepted as-is): 13px label / 15px body are 2px apart; keeping 13px (separation carried by weight + context; 13px is also the EventBlock title size). If perceptual blur appears in implementation, drop labels to 12px (chip text, time meta, legend labels) without adding a 5th size.