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

16 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
6 ux-polish draft false none 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:

@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