From 79a18b9ea2d3dae30d365c731168b66894ca2f15 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 4 Jun 2026 14:21:57 -0400 Subject: [PATCH] docs(02): add UI design contract for calendar display phase Specifies the token-layer architecture, Schedule-X as the rendering library (all four views, CSS-var theming, per-calendar color), per-member event colors, shared-family rose (#F25C7A), spacing/typography/breakpoints, component inventory, and copywriting contract for Phase 2. --- .../phases/02-calendar-display/02-UI-SPEC.md | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 .planning/phases/02-calendar-display/02-UI-SPEC.md diff --git a/.planning/phases/02-calendar-display/02-UI-SPEC.md b/.planning/phases/02-calendar-display/02-UI-SPEC.md new file mode 100644 index 0000000..13d25ac --- /dev/null +++ b/.planning/phases/02-calendar-display/02-UI-SPEC.md @@ -0,0 +1,429 @@ +--- +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 + +- [ ] 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