Files
familysync/.planning/phases/06-ux-polish/06-UI-SPEC.md
T
2026-06-10 09:38:54 -04:00

341 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 `<select>` in the recurrence section, shown only when recurrence ≠ "None".
**Layout:**
- Label: "Ends" (13px/400, `--color-text-secondary`).
- Three-option `<select>` or segmented control:
- "Never" (default)
- "On date" → reveals a date `<input type="date">` labeled "End date"
- "After N times" → reveals a number `<input type="number" min="1">` 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 140147) 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