Files
familysync/.planning/phases/02-calendar-display/02-UI-SPEC.md
T

435 lines
19 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: 2
slug: calendar-display
status: draft
shadcn_initialized: false
preset: none
created: 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
```ts
// 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):**
```ts
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
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: FLAG (non-blocking)
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: FLAG (non-blocking)
- [x] Dimension 5 Spacing: PASS
- [x] 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.