19 KiB
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-12used 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 intokens.css. No Schedule-X default stylesheet bleeds through. - Per-calendar color is first-class (
calendarsconfig withlightColors.main / container / onContainer) — maps directly to per-memberusers.colorand the shared-family rose. onRangeUpdatecallback fires when the user navigates, enabling TanStack Query to fetch only the visible window from/api/events?start=&end=.- Custom
eventModalcomponent viacustomComponentsprop — 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); unmaintainedmoment/date-fnslocalization 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
containercolor (15% opacity) - Text: member
onContainercolor, 13px, weight 400, single line, truncated with ellipsis - Left 3px solid border: member
maincolor (theusers.colorhex 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
isLoadingfor 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-dimbackground; 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: trueflag - 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-visibleonly) - 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: 2for 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}"+titleto 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.