---
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, 2–3 rows each, varying widths (60–90% 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: Sun–Sat (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:00–23: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.
