---
phase: 06-ux-polish
verified: 2026-06-10T20:37:54Z
status: human_needed
score: 12/12
overrides_applied: 0
human_verification:
  - test: "iOS/standalone cold-load and OIDC redirect (D-10/D-11)"
    expected: "PWA installed to iOS Home Screen cold-loads to the AuthSplash 'Signing you in' splash; Authelia redirects correctly in standalone mode; session-expiry interstitial fires and navigates back to /api/login without a hang."
    why_human: "iOS Safari standalone OIDC redirect behavior is explicitly excluded from playwright-cli scope (CLAUDE.md convention; cannot simulate Safari standalone mode in desktop Chromium). Per 06-VALIDATION.md Manual-Only table."
  - test: "PushPermissionPrompt spinner on iOS device (CP-04.3)"
    expected: "The Loader2 spinner in PushPermissionPrompt rotates using the global @keyframes spin from tokens.css after the local redundant redefinition was removed."
    why_human: "PushPermissionPrompt only renders inside an installed iOS/standalone PWA. Desktop Chromium never surfaces the component. The global keyframe resolves correctly per code inspection but a real device spot-check was not run (documented residual in 06-04-SUMMARY.md)."
---

# Phase 06: UX Polish Verification Report

**Phase Goal:** Smooth the rough edges surfaced during live use — clearer all-day events, saner event-form date/recurrence behavior, recurring-series editing, and auth-flow polish — so the app feels slick for the non-technical Apple member (hard UX constraint).
**Verified:** 2026-06-10T20:37:54Z
**Status:** human_needed
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Moving an event's start moves its end preserving duration; end never strands behind start (D-03/D-04) | VERIFIED | `computeNewTimedEnd` + `computeNewAllDayEnd` exported from `eventDateTime.ts`; wired in `EventForm.tsx` start `onChange` handlers at lines 729-760; 6 unit tests green; EventForm tests D-04 timed + all-day pass |
| 2 | A recurring series can be bounded via "Ends: Never / On date / After N times" (D-06) | VERIFIED | `assembleRruleString` in `outboxWorker.ts`; `recurrenceUntil`/`recurrenceCount` Zod fields in both `events.ts` and `outboxWorker.ts`; "Ends" control in `EventForm.tsx` (state at lines 214-216, rendered at line 888+); 7 assembleRruleString tests green; playwright-cli verified |
| 3 | Editing a recurring occurrence prompts "Edit recurring series" before saving (D-08/D-09) | VERIFIED | `SeriesEditPrompt.tsx` created with `role="dialog"`, `aria-modal`, focus trap, Escape=cancel, correct copy; `EventForm.tsx` gates Save on `occurrence?.hasRrule === true` at line 419; `hasRrule` populated in `expand.ts` + mirrored in `client.ts`; playwright-cli verified |
| 4 | All-day events visually distinct from timed events at a glance (999.6/D-12) | VERIFIED | `.sx__date-grid .sx__date-grid-event` and `.sx__month-grid-day__events .sx__month-grid-event:not(:has(.sx__month-grid-event-time))` CSS rules in `index.css` (lines 125-140) with `border-radius:4px`, `font-weight:600`, `border-inline-start:none`; real Schedule-X v4.6.0 selectors (not the non-existent `.sx__all-day-event`) verified correct after follow-up fix 6dbb166; playwright-cli verified |
| 5 | All-day edit off-by-one stays fixed — re-editing does not grow event by a day (D-05) | VERIFIED | `exclusiveEndToInclusiveDate` pre-fill at EventForm reset line intact; D-05 round-trip test in EventForm.test.tsx passes; playwright-cli verified |
| 6 | Unauthenticated cold load shows only the neutral "Signing you in" splash — no calendar/skeleton/alert flash (D-10) | VERIFIED | `CalendarShell.tsx` returns `<AuthSplash state="loading" />` on `meQuery.isLoading` before any calendar content (line 269); `AuthSplash.tsx` created with `role="status"`, correct copy, full-screen centered layout; playwright-cli checkpoint PASS for desktop Chromium |
| 7 | A session that expires mid-use shows "Session expired" interstitial and cleanly redirects (D-11) | VERIFIED | `SessionExpiredError` class in `client.ts`; `handleAuthResponse` covers all 7 fetch wrappers; `QueryCache`/`MutationCache` `onError` in `main.tsx` (not `defaultOptions.onError`); `sessionExpired` flag in `calendarStore.ts`; CalendarShell renders `<AuthSplash state="redirecting" />` on `sessionExpired=true` with 1.5s redirect; dead-end state reachable when guard exhausted (follow-up fix e392c69); playwright-cli checkpoint PASS for desktop Chromium |
| 8 | Sync indicators actually animate — SyncStateToast spinner spins and LiveSyncIndicator reconnecting dot pulses (D-13) | VERIFIED | `@keyframes pulse` added to `tokens.css` at line 149 (0%,100% opacity:1; 50% opacity:0.4); redundant local `@keyframes spin` block removed from `PushPermissionPrompt.tsx` (confirmed absent); playwright-cli checkpoint PASS — both `animationName` values non-'none' in desktop Chromium |
| 9 | Nav chrome persists on /lists — BottomTabBar does not overlap Settings on desktop (UAT fixes FIX-3/FIX-4) | VERIFIED | `AppNav` lifted to `App.tsx` as a persistent sibling of `<Routes>` (outside any Route, line 112); `BottomTabBar` returns `null` on desktop via `isPhone()` guard (line 56); AppNav persistence test and BottomTabBar hidden-on-desktop test both green |
| 10 | D-04 floor rule: end snaps to newStart+1h (timed) / same day (all-day) when old end was already behind start | VERIFIED | `deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60*60*1000` in `computeNewTimedEnd`; `Math.max(0, dateDiffDays(...))` in `computeNewAllDayEnd`; two floor-rule unit tests green |
| 11 | RRULE UNTIL value-type matches DTSTART — DATE form for all-day, DATETIME UTC for timed (D-06, RFC 5545) | VERIFIED | `assembleRruleString`: all-day emits `UNTIL=YYYYMMDD`, timed emits `UNTIL=YYYYMMDDTHHMMSSZ (T235959Z)`; three vevent.test.ts serialization assertions + five assembleRruleString unit tests green |
| 12 | FREQ=DAILY regression locked (D-07) | VERIFIED | `FREQ persistence (D-07 regression)` test in `outboxWorker.test.ts` asserts daily-recurrence payload emits `RRULE:FREQ=DAILY`; green |

**Score:** 12/12 truths verified

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/pwa/src/lib/eventDateTime.ts` | `computeNewTimedEnd` + `computeNewAllDayEnd` exports with floor rules; no `toISOString().slice` | VERIFIED | Both functions exported at lines 114/142; WR-05 compliance confirmed — no `toISOString().slice` in helper code |
| `apps/pwa/src/lib/eventDateTime.test.ts` | 6 new tests: 3 timed + 3 all-day end-tracking, RED→GREEN | VERIFIED | 6 tests present in two describe blocks; `computeNewTimedEnd` + `computeNewAllDayEnd` imported; all pass |
| `apps/api/src/broker/outboxWorker.ts` | `assembleRruleString` exported; `recurrenceUntil`/`recurrenceCount` in `outboxPayloadSchema` | VERIFIED | `assembleRruleString` exported at line 114; both fields at lines 83-84 |
| `apps/api/src/routes/events.ts` | `eventFieldsSchema` accepts `recurrenceUntil` + `recurrenceCount` | VERIFIED | Both fields at lines 111-112 |
| `apps/api/tests/broker/vevent.test.ts` | UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions | VERIFIED | 3 assertions match verified ical.js 2.2.1 output strings |
| `apps/api/tests/broker/outboxWorker.test.ts` | assembleRruleString describe + FREQ persistence test | VERIFIED | Both describe blocks present; 7+1 tests pass |
| `apps/api/src/broker/expand.ts` | `hasRrule: boolean` on `CalendarOccurrence`; populated from `event.isRecurring()` in both push sites | VERIFIED | Field at line 68; `const isRecurring` capture at line 224; both push sites at lines 261/308 |
| `apps/api/tests/broker/expand.test.ts` | `hasRrule` true/false assertions + bounded COUNT=3 invariant | VERIFIED | `hasRrule` describe with 2 tests + `Bounded RRULE` describe with 3 tests; all pass |
| `apps/api/tests/fixtures/weekly-count3.ics` | Bounded fixture for COUNT=3 test | VERIFIED | File exists at `apps/api/tests/fixtures/weekly-count3.ics` |
| `apps/pwa/src/styles/tokens.css` | `@keyframes pulse` added globally | VERIFIED | Present at line 149; exactly once |
| `apps/pwa/src/components/PushPermissionPrompt.tsx` | Redundant `@keyframes spin` `<style>` block removed | VERIFIED | `grep -q '@keyframes spin'` returns nothing |
| `apps/pwa/src/api/client.ts` | `SessionExpiredError`; `handleAuthResponse`; `redirect:'manual'` on all wrappers; `hasRrule` on `CalendarOccurrence`; `recurrenceUntil`/`recurrenceCount` on `CreateEventPayload` | VERIFIED | All present: `class SessionExpiredError` at line 33; `handleAuthResponse` at line 51; 7 `handleAuthResponse` call sites; `hasRrule` at line 131; `recurrenceUntil` at line 190 |
| `apps/pwa/src/api/client.test.ts` | SessionExpiredError detection tests (opaqueredirect + 401 per wrapper; 500 = generic Error) | VERIFIED | 40 tests pass; opaqueredirect/401/500 cases for fetchEvents, createEvent, updateEvent, deleteEvent, fetchMe |
| `apps/pwa/src/components/AuthSplash.tsx` | Full-screen interstitial; loading/redirecting/dead-end states; `role="status"` | VERIFIED | File created; `AuthSplashState` type at line 27; `role="status"` at line 61; all three states handled |
| `apps/pwa/src/components/CalendarShell.tsx` | `meQuery.isLoading` → AuthSplash loading; `meQuery.isError` → AuthSplash redirecting/dead-end; content only on `isSuccess`; `sessionExpired` interstitial wiring | VERIFIED | Lines 269-281 gate render; `sessionExpired` effect at lines 231-243; `enabled: meQuery.isSuccess` at line 120 |
| `apps/pwa/src/main.tsx` | `QueryCache`/`MutationCache` `onError` (NOT `defaultOptions.onError`) routing `SessionExpiredError` to `setSessionExpired` | VERIFIED | `QueryCache` at line 37; `MutationCache` at line 38; no `defaultOptions.onError` in file |
| `apps/pwa/src/store/calendarStore.ts` | `sessionExpired: boolean` + `setSessionExpired` action | VERIFIED | `sessionExpired: false` default at line 159; `setSessionExpired` at line 187 |
| `apps/pwa/src/components/EventForm.tsx` | Start onChange handlers call `computeNewTimedEnd`/`computeNewAllDayEnd`; recurrenceBound state + "Ends" control; hasRrule gates SeriesEditPrompt; payload sends `recurrenceUntil`/`recurrenceCount` | VERIFIED | `computeNewTimedEnd` wired at lines 732/755; `computeNewAllDayEnd` at line 730; recurrenceBound state at line 214; "Ends" control at line 888; `hasRrule` gate at line 419; payload spread at lines 399-403 |
| `apps/pwa/src/components/SeriesEditPrompt.tsx` | Bottom-sheet/dialog; focus trap; Escape=cancel; exact UI-SPEC copy; accent-filled "Update series"; ghost "Cancel" | VERIFIED | File created; `role="dialog"`, `aria-modal` at lines 135-136; "Edit recurring series" at line 154; "Update series" at line 217 |
| `apps/pwa/src/styles/index.css` | `.sx__date-grid-event` + `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` all-day pill overrides (real v4.6.0 selectors) | VERIFIED | Both rules at lines 125/134 with `border-radius:4px`, `font-weight:600`, `border-inline-start:none` |
| `apps/pwa/src/App.tsx` | AppNav as persistent sibling of `<Routes>` (FIX 3) | VERIFIED | `<AppNav>` rendered at line 112, outside `<Routes>` which starts at line 121 |
| `apps/pwa/src/components/BottomTabBar.tsx` | Returns `null` on desktop (FIX 4) | VERIFIED | `if (!isPhone()) return null` at line 56 |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `EventForm.tsx` | `eventDateTime.ts` | start onChange → `computeNewTimedEnd` / `computeNewAllDayEnd` | WIRED | Both imports at lines 45-46; both calls in onChange handlers at lines 730, 732, 755 |
| `EventForm.tsx` | `client.ts` | payload carries `recurrenceUntil`/`recurrenceCount`; `occurrence.hasRrule` gates prompt | WIRED | `recurrenceUntil` spread at line 399; `recurrenceCount` spread at line 402; `hasRrule` check at line 419 |
| `main.tsx` | `calendarStore.ts` | `QueryCache`/`MutationCache` `onError` → `setSessionExpired(true)` on `SessionExpiredError` | WIRED | `useCalendarStore.getState().setSessionExpired(true)` at line 32; imperative store access confirmed |
| `CalendarShell.tsx` | `AuthSplash.tsx` | `meQuery.isLoading`/`isError` and `sessionExpired` flag render AuthSplash | WIRED | Imports at line 50; `<AuthSplash state="loading" />` at line 270; `<AuthSplash state="redirecting" />` at lines 281/291 |
| `outboxWorker.ts` | `vevent.ts` | `assembleRruleString` result passed to `buildVeventString` | WIRED | `assembleRruleString` called at lines 360/371/451/462; result flows as `rruleString` into the dispatch path |
| `events.ts` | `outboxWorker.ts` | `recurrenceUntil`/`recurrenceCount` in enqueued payload | WIRED | Zod schema accepts fields in both `eventFieldsSchema` (events.ts:111-112) and `outboxPayloadSchema` (outboxWorker.ts:83-84) |
| `expand.ts` | `client.ts` (mirror) | `CalendarOccurrence.hasRrule` server source-of-truth mirrored | WIRED | `hasRrule: boolean` at expand.ts line 68 (authoritative); mirrored at client.ts line 131 with explicit comment |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `EventForm.tsx` | `computeNewTimedEnd` result → `endDate`/`endTime` state | `eventDateTime.ts` pure functions over form state (no network) | Yes — deterministic math, no network fetch, no empty source | FLOWING |
| `EventForm.tsx` | `recurrenceUntil`/`recurrenceCount` → submit payload | User input (controlled form state) | Yes — user input flows directly to payload spread | FLOWING |
| `EventForm.tsx` | `occurrence.hasRrule` gate | `CalendarOccurrence` from parent prop (occurrence fetched from API via `fetchEvents`) | Yes — `hasRrule` populated server-side in `expandOccurrences` from `event.isRecurring()` | FLOWING |
| `CalendarShell.tsx` | `meQuery.isLoading`/`isError` | TanStack Query `['me']` query → `fetchMe()` → `/api/me` | Yes — real API call with `redirect:'manual'`; auth gating is live | FLOWING |
| `CalendarShell.tsx` | `sessionExpired` | Zustand store, set by `QueryCache`/`MutationCache` `onError` on real `SessionExpiredError` | Yes — fires on real 401/opaqueredirect from any query/mutation | FLOWING |

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `computeNewTimedEnd` + `computeNewAllDayEnd` exported | `node -e "import('./src/lib/eventDateTime.ts').then(m => console.log(typeof m.computeNewTimedEnd, typeof m.computeNewAllDayEnd))"` | `function function` | PASS |
| D-04 end-tracking unit tests pass | `pnpm --filter @familysync/pwa test -- run lib/eventDateTime` | 191/191 pass | PASS |
| SessionExpiredError detection tests pass | `npx vitest run src/api/client.test.ts` (apps/pwa) | 40/40 pass | PASS |
| assembleRruleString + FREQ persistence tests pass | `npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` (apps/api) | 39/39 pass | PASS |
| hasRrule + bounded RRULE tests pass | `npx vitest run tests/broker/expand.test.ts` (apps/api) | 10/10 pass | PASS |
| Full PWA test suite green | `pnpm --filter @familysync/pwa test -- run` | 191/191 pass (17 files) | PASS |
| All phase-06 API broker + events tests green | `npx vitest run tests/broker/ tests/routes/events.test.ts` (apps/api) | 114/114 pass (9 files) | PASS |

Note: `tests/routes/lists.test.ts` and `tests/routes/push.test.ts` fail with `ER_ACCESS_DENIED_ERROR` (MariaDB not running with password in current dev environment). These are pre-existing integration-test DB-connectivity failures, not regressions introduced by phase 06. All broker tests that phase 06 modified or created are green.

---

### Probe Execution

No probe scripts found (`scripts/*/tests/probe-*.sh` absent). Step 7c: SKIPPED.

---

### Requirements Coverage

No `v1` REQ-IDs were assigned to this phase (confirmed by phase description and plan frontmatter — `requirements: []` in all plans). Phase is tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13. All backlog items verified via truth/artifact checks above.

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `SeriesEditPrompt.tsx` | 86 | `if (!open) return null` | Info | Correct conditional render guard — component is fully substantive when `open === true`; not a stub |

No TBD, FIXME, or XXX markers found in any phase-06-modified file. No unreferenced debt markers.

---

### Human Verification Required

#### 1. iOS/Standalone Cold-Load and OIDC Redirect (D-10/D-11)

**Test:** Install the PWA to iOS Home Screen. Cold-load with no session cookie. Confirm the first painted frame is the neutral "Signing you in" splash (not the calendar shell or "Sign-in required" alert), and that Authelia redirect completes correctly in standalone mode. Then simulate a session expiry to confirm the "Session expired / Signing you back in..." interstitial appears and redirects to /api/login without hanging.

**Expected:** Splash shown on cold-load; Authelia round-trip succeeds; mid-use 401 shows interstitial then redirects within ~2s; no redirect loop.

**Why human:** iOS Safari standalone OIDC redirect behavior cannot be driven by playwright-cli. This is the documented CLAUDE.md exception (standalone-mode OIDC redirect, `window.location.href` cross-origin fallback behavior). Recorded in 06-VALIDATION.md Manual-Only table. Desktop Chromium checkpoints already PASS (playwright-cli verified in 06-05).

#### 2. PushPermissionPrompt Spinner on iOS Device (CP-04.3)

**Test:** On a real iOS device with the PWA installed as a standalone app, trigger the push permission prompt and confirm the Loader2 spinner rotates.

**Expected:** Spinner rotates using the global `@keyframes spin` from `tokens.css` (the redundant local redefinition was removed in commit `81f2678`).

**Why human:** `PushPermissionPrompt` only surfaces inside an installed iOS/standalone PWA. No desktop Chromium path to the component. Code-confirmed: the inline `animation: 'spin 1s linear infinite'` is still present on the Loader2 element and resolves to the global keyframe. A real-device spot-check is required for confidence. Documented residual in 06-04-SUMMARY.md.

---

### Notable Deviations from Plan (Not Gaps)

The following deviations were auto-fixed during execution and do not constitute gaps:

1. **Schedule-X CSS selector correction (06-06):** Plan assumed `.sx__all-day-event` but Schedule-X v4.6.0 does not emit that class. Executor discovered and fixed in commits `6dbb166` + `5620261` using real v4.6.0 selectors. playwright-cli re-verified PASS.

2. **AuthSplash dead-end state + redirect guard persistence (06-05):** Initial implementation had the dead-end state unreachable and the one-shot guard cleared prematurely. Found during playwright-cli checkpoint; fixed in commits `36ef7a0` + `e392c69`. Re-verified PASS.

3. **`hasExplicitRecurrence` precedence bug (06-02):** `recurrence:'none'` with `_preservedRrule` present incorrectly fell through to emit an RRULE. Fixed in GREEN commit `d2abb91`. Regression test `CR-01` confirms the fix.

4. **`EventDetailPopover.test.tsx` fixture update (06-06):** Existing test fixtures omitted the new required `hasRrule` field. Mechanical fix in commit `69e5ae8`.

5. **Dev-seed gap:** The dev-bypass user (id 1) has no CalDAV credential/calendars (those belong to user 2), so live event-create-via-form could not be exercised end-to-end against Fastmail. Server logic and form UI verified via route-mocks and direct DB occurrence inserts. Not a code defect.

---

### Gaps Summary

No gaps. All 12 truths are VERIFIED. The two human verification items are device-only constraints (iOS/standalone behavior) that were explicitly pre-classified as manual checkpoints in 06-VALIDATION.md before execution began.

---

_Verified: 2026-06-10T20:37:54Z_
_Verifier: Claude (gsd-verifier)_
