---
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)_
