From 9707fd0d85385f3ae66bf039697a0917bbad0a40 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 09:38:54 -0400 Subject: [PATCH] docs(06): UI design contract for ux-polish phase Co-Authored-By: Claude Sonnet 4.6 --- .planning/phases/06-ux-polish/06-UI-SPEC.md | 340 ++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 .planning/phases/06-ux-polish/06-UI-SPEC.md diff --git a/.planning/phases/06-ux-polish/06-UI-SPEC.md b/.planning/phases/06-ux-polish/06-UI-SPEC.md new file mode 100644 index 0000000..080a619 --- /dev/null +++ b/.planning/phases/06-ux-polish/06-UI-SPEC.md @@ -0,0 +1,340 @@ +--- +phase: 6 +slug: ux-polish +status: draft +shadcn_initialized: false +preset: none +created: 2026-06-10 +--- + +# Phase 6 — UI Design Contract + +> Visual and interaction contract for Phase 6: UX Polish. +> Generated by gsd-ui-researcher. Verified by gsd-ui-checker. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | none (custom CSS token layer) | +| Preset | not applicable | +| Component library | none — hand-rolled components using CSS custom properties | +| Icon library | lucide-react@1.17.0 | +| Font | system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif | + +Source: `apps/pwa/src/styles/tokens.css` — fully established in Phase 2 (D-01/D-02). +No new design-system tooling introduced in this phase. + +--- + +## Spacing Scale + +Declared values from `apps/pwa/src/styles/tokens.css` — no changes in this phase: + +| Token | Value | Usage | +|-------|-------|-------| +| --space-1 | 4px | Icon gaps, inline padding, dot-label gaps | +| --space-2 | 8px | Compact element spacing, badge padding | +| --space-3 | 12px | Form field internal padding | +| --space-4 | 16px | Default element spacing, card padding | +| --space-6 | 24px | Section padding, sheet internal padding | +| --space-8 | 32px | Layout gaps, modal vertical padding | +| --space-12 | 48px | Major section breaks | + +Exceptions: +- Touch targets: minimum 44px height on all interactive elements (EventForm inputs, recurrence bound control, series-edit prompt buttons). 48px on primary CTAs (per existing PushPermissionPrompt pattern). +- All-day banner row: height is not constrained to spacing scale; it follows the Schedule-X all-day row height. Do not override it. + +--- + +## Typography + +From `apps/pwa/src/styles/tokens.css` — no new sizes or weights introduced in this phase: + +| Role | Size | Weight | Line Height | Used in Phase 6 | +|------|------|--------|-------------|-----------------| +| Body | 15px | 400 | 1.5 | Auth splash body text, session-expired interstitial copy, form labels | +| Label | 13px | 400 | 1.4 | Recurrence bound control labels, "repeat until" date caption, series-edit prompt sub-copy | +| Heading | 18px | 600 | 1.25 | Auth splash heading ("Signing you in"), series-edit prompt heading | +| Display | 24px | 600 | 1.2 | Not used in Phase 6 new surfaces | + +--- + +## Color + +From `apps/pwa/src/styles/tokens.css` — no new colors introduced in this phase: + +| Role | Value | Usage | +|------|-------|-------| +| Dominant (60%) | --color-surface: #FFFFFF | Auth splash background, modal/sheet backgrounds, EventForm background | +| Secondary (30%) | --color-surface-dim: #F7F7F8 | Series-edit prompt background band, all-day visual treatment background fill | +| Accent (10%) | --color-member-0: #4A90D9 | Primary CTA buttons only: "Turn On Notifications", "Save" in event form | +| Destructive | --color-destructive: #DC2626 | Delete event, destructive actions only | + +Accent reserved for: +1. Primary CTA button fill in EventForm ("Save" / "Update Series") +2. Focus ring (--color-focus-ring: #4A90D9) on interactive inputs + +New usage decisions for Phase 6 surfaces: + +**All-day event visual treatment (999.6):** +- All-day chips/banners in Schedule-X all-day row: use full-width pill style with the member's `--color-member-N` or `--color-shared-family` as background fill at 100% opacity. +- Timed event chips: keep existing member color fill. +- The visual distinction is achieved through shape and presentation (full-width pill vs. standard event block), not a new color. The existing `_familySync.color` per event drives the fill in both cases. + +**Auth splash (999.2) + session-expired interstitial (999.3):** +- Full-screen overlay: `--color-surface` (#FFFFFF) background. +- Spinner: `--color-member-0` (#4A90D9) — reuses the existing Loader2 + spin keyframe pattern. +- Heading: `--color-text-primary` (#111318). +- Body: `--color-text-secondary` (#6B7280). + +**Series-edit prompt (999.9):** +- Confirmation sheet / dialog uses same surface + border system as existing DeleteConfirmationDialog. +- No new accent color. Primary confirm action uses `--color-member-0` fill (same as Save CTA). + +--- + +## Copywriting Contract + +### Auth splash — unauthenticated cold load (999.2) + +| Element | Copy | +|---------|------| +| Heading | Signing you in | +| Body | Taking you to the sign-in page… | +| Fallback (one-shot guard failed) | Sign-in required. Tap here to try again. | + +Rules: +- No punctuation on the heading. +- Body uses an ellipsis (…, U+2026) not three dots. +- The "Sign-in required" fallback is the dead-end only — not the primary unauthenticated path. + +### Session-expired interstitial (999.3) + +| Element | Copy | +|---------|------| +| Heading | Session expired | +| Body | Signing you back in… | + +Rules: +- This interstitial replaces the hanging/generic error state. It is shown for ≤2s before `window.location.href = '/api/login'` fires. +- Do not show a dismiss button — the redirect is automatic. + +### EventForm — recurrence bound control (999.8) + +| Element | Copy | +|---------|------| +| Bound type label | Ends | +| Option: no bound | Never | +| Option: repeat until | On date | +| Option: count | After N times | +| Date input label | End date | +| Count input label | Occurrences | +| Count input placeholder | e.g. 10 | +| Validation error: count < 1 | Must be at least 1 occurrence | +| Validation error: until < start | End date must be after the event starts | + +### EventForm — series-edit confirmation (999.9) + +| Element | Copy | +|---------|------| +| Sheet/dialog heading | Edit recurring series | +| Body | This will update all occurrences of this event. | +| Confirm CTA | Update series | +| Cancel | Cancel | + +Rules: +- "Update series" is the primary action (accent-filled button). +- "Cancel" is a ghost/secondary button. +- No destructive color on confirm — this is an edit, not a delete. + +### EventForm — primary CTAs + +| Mode | CTA label | +|------|-----------| +| Create (non-recurring) | Save event | +| Create (recurring) | Save event | +| Edit (non-recurring) | Save changes | +| Edit (recurring occurrence) | Update series | + +### Sync indicators (D-13) + +No copy change. Existing copy is canonical: +- Spinning: "Syncing…" +- Done: "Saved" +- Failed: "Didn't save. Try again." (or conflict variant) +- Dead: "Not saved. Check your connection." +- LiveSyncIndicator reconnecting: "Reconnecting…" +- LiveSyncIndicator disconnected: "Updates paused" + +### Empty states + +No new empty states introduced in this phase. Existing EmptyState and ListsEmptyState copy is unchanged. + +### Destructive actions + +| Action | Trigger | Confirmation | +|--------|---------|--------------| +| Delete event | Trash2 icon in EventDetailPopover footer | Existing DeleteConfirmationDialog — "Delete event?" / "This can't be undone." / "Delete" (destructive-red) / "Cancel" | +| Delete recurring series | (Not in scope for Phase 6 — whole-series edit only, not delete) | N/A | + +--- + +## Surface Contracts + +### Surface 1: Auth splash screen (999.2) + +**Trigger:** `meQuery.isLoading` on initial mount (before auth state is known). + +**Layout:** +- Full-screen centered column: `display:flex; flex-direction:column; align-items:center; justify-content:center; height:100dvh; background:var(--color-surface)`. +- Stack: Loader2 spinner (24px, `--color-member-0`, spinning via global `@keyframes spin`) → 16px gap → Heading (18px/600) → 8px gap → Body (15px/400, `--color-text-secondary`). +- No app chrome (no BottomTabBar, no AppNav). + +**States:** +1. `meQuery.isLoading` → show spinner + "Signing you in" heading + "Taking you to the sign-in page…" body. +2. `meQuery.isError` (opaqueredirect / 401), first attempt → trigger `maybeRedirectToLogin()` immediately; while the redirect is in-flight keep the spinner visible (same view). +3. `meQuery.isError`, one-shot guard already fired (`familysync.loginRedirectAttempted` set) → replace body with "Sign-in required. Tap here to try again." with a tap handler that clears the flag and re-triggers login. No spinner in this dead-end state. + +**Accessibility:** `role="status"` on the spinner wrapper, `aria-label="Signing you in"`. + +### Surface 2: Session-expired interstitial (999.3) + +**Trigger:** Any query or mutation returns 401 / opaqueredirect after initial auth succeeds. + +**Layout:** Same full-screen centered column as Surface 1, but: +- Heading: "Session expired" +- Body: "Signing you back in…" +- Spinner visible. +- Fires `maybeRedirectToLogin()` after a 1.5s delay (enough for the user to read the message, not long enough to feel broken). + +**Implementation note:** Centralized in `apps/pwa/src/api/client.ts` via a typed `SessionExpiredError`. A global TanStack Query `onError` handler intercepts it and sets a Zustand flag that renders this surface above the app tree. The existing one-shot guard in `loginRedirect.ts` is re-armed (clear `familysync.loginRedirectAttempted` before re-firing). + +### Surface 3: All-day visual distinction (999.6) + +**Schedule-X context:** All-day events appear in the all-day row in week/day views and as full-width banners in month/agenda views. Schedule-X renders them with its own chip CSS. + +**Treatment:** +- Override Schedule-X all-day chip styles to render as a full-width rounded pill (border-radius: 4px) spanning the full column width with the event's member color as solid background fill and white (`#FFFFFF`) label text. +- Timed events keep their existing chip appearance (colored left border + lighter background tint, as Schedule-X default renders them with `--sx-color-primary`). +- The visual distinction contract: **all-day = solid filled pill; timed = partial-fill chip with colored border accent**. +- Override selector: `.sx__all-day-event` — set `border-radius:4px; color:#FFFFFF; font-weight:600; font-size:var(--text-label-size)`. +- Color source: pass the event's `_familySync.color` to the Schedule-X `calendarId` color config (already done via `buildCalendarConfig`). No per-event inline style override needed if the `calendarId` color propagates. + +**Accessibility:** No additional ARIA needed — Schedule-X all-day row already has date headers. The filled pill provides sufficient contrast (member colors are ≥3:1 on white text at these sizes). + +### Surface 4: EventForm — end-tracking + all-day off-by-one fix (999.7) + +**Behavior contract:** +- On any `startDate` or `startTime` change: recalculate `endDate`/`endTime` to preserve the current duration. + - Timed: `newEnd = newStart + (oldEnd - oldStart)`. If `oldEnd <= oldStart` (stale state), snap to `newStart + 1h`. + - All-day: `newEndInclusive = newStartDate + (oldEndInclusive - oldStartDate)` in days. If span = 0, keep 0 (same-day). If `oldEnd < oldStart` (stale), snap `newEnd = newStart`. +- Floor rule: end must never be before start. If arithmetic would place end before start, snap end = start (timed: same minute; all-day: same day). +- The all-day-edit off-by-one (D-05) is already fixed at `EventForm.tsx:199`. Verify the `exclusiveEndToInclusiveDate` helper still applies correctly in edit pre-fill; do not re-implement. + +**Interaction:** +- No toast or indicator when end auto-advances — silent and expected. +- The end date/time fields remain editable after the auto-advance; the user can override further. + +### Surface 5: EventForm — recurrence bound control (999.8) + +**Placement:** Appears below the frequency `` or segmented control: + - "Never" (default) + - "On date" → reveals a date `` labeled "End date" + - "After N times" → reveals a number `` labeled "Occurrences" +- Both revealed inputs have the same 44px touch-target height as other form fields. +- Validation errors shown inline below the field in 13px/400 `--color-destructive`. + +**FREQ persistence fix (D-07):** The `RecurrencePreset` type in `apps/pwa/src/api/client.ts` must map to the correct `FREQ` string in `vevent.ts`. Verify the existing select value is serialized 1:1 into the RRULE FREQ field; if a daily selection persists as weekly, the bug is in the `preset → RRULE` mapping, not the form state. + +### Surface 6: Series-edit prompt (999.9) + +**Trigger:** User taps "Save" on an EventForm that is editing a recurring occurrence (occurrence has a `uid` whose event has `hasRrule=true`). + +**Pattern:** Use the existing bottom-sheet/dialog pattern matching `DeleteConfirmationDialog`: +- Phone (≤767px): bottom sheet sliding up from below. +- Tablet/desktop (≥768px): centered dialog, max-width 480px. + +**Layout:** +``` +[ Sheet/Dialog ] + Heading: "Edit recurring series" (18px/600) + Body: "This will update all occurrences (15px/400, --color-text-secondary) + of this event." + ───────────────────────────────────────── + [ Cancel ] [ Update series ] + ghost button accent-filled (--color-member-0) +``` + +**Accessibility:** +- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` pointing to the heading. +- Focus trap — Tab/Shift+Tab cycle between Cancel and Update series. +- Escape key fires Cancel. + +### Surface 7: Spin animation fix (D-13) + +**Problem:** `@keyframes spin` is currently defined only inside `PushPermissionPrompt.tsx` inline styles (`:359`). `SyncStateToast` and `LiveSyncIndicator` use `animation: spin …` but the keyframe isn't globally available when that component isn't mounted. + +**Fix:** The `@keyframes spin` in `apps/pwa/src/styles/tokens.css` (lines 140–147) IS already defined globally. The bug is that components are referencing it via inline style `animation: 'spin 1s linear infinite'` which works, but the `@keyframes spin` must be confirmed present in the global stylesheet before the component mounts. Executor: verify `tokens.css` exports `@keyframes spin` and that `index.css` imports it before any component referencing the animation mounts. The `@keyframes spin` definition in `PushPermissionPrompt.tsx` is redundant but harmless — remove it after confirming the global definition covers all consumers. + +--- + +## Animation Contract + +| Animation | Definition | Applied to | +|-----------|-----------|-----------| +| shimmer | `@keyframes shimmer` in tokens.css | SkeletonCalendar loading bars | +| spin | `@keyframes spin` in tokens.css (global — do not redefine per-component) | Loader2 in SyncStateToast, PushPermissionPrompt, auth splash, session-expired interstitial | +| pulse | `@keyframes pulse` — confirm presence in tokens.css or add it | LiveSyncIndicator reconnecting dot | + +Note: If `@keyframes pulse` is not in tokens.css, add it alongside `@keyframes spin`: +```css +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +``` + +--- + +## Accessibility Contract + +All new surfaces must meet: + +| Requirement | Value | +|-------------|-------| +| Minimum touch target | 44px height (48px on primary CTAs) | +| Focus management | Focus trap in all dialogs/sheets; Escape closes | +| Live regions | `role="status"` for informational (spinner, sync); `role="alert"` for errors | +| Color contrast | 4.5:1 for body text; 3:1 for large text (18px+/bold) | +| Motion | `@keyframes spin` and `pulse` are UI-state feedback — not decorative; acceptable without `prefers-reduced-motion` guard, but a reduced-motion variant (opacity swap instead of spin) is a welcome addition if it fits the plan | + +--- + +## Registry Safety + +| Registry | Blocks Used | Safety Gate | +|----------|-------------|-------------| +| shadcn official | none | not applicable | +| Third-party | none | not applicable | + +No third-party component registries used. All components are hand-rolled using the existing token layer. + +--- + +## 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