--- 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. - `` (top bar on phone; left sidebar 240px on tablet/desktop) - `` (Today button, prev/next arrows, date label, view switcher) - `` (member → color; always visible on tablet/desktop; collapsible on phone) - `` (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.