--- 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 `` 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 `` 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 `` (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` `