diff --git a/.planning/phases/17-ui-optimization-polish/17-REVIEW.md b/.planning/phases/17-ui-optimization-polish/17-REVIEW.md new file mode 100644 index 0000000..45b74ff --- /dev/null +++ b/.planning/phases/17-ui-optimization-polish/17-REVIEW.md @@ -0,0 +1,145 @@ +--- +phase: 17-ui-optimization-polish +reviewed: 2026-06-18T00:00:00Z +depth: standard +files_reviewed: 13 +files_reviewed_list: + - apps/pwa/src/App.tsx + - apps/pwa/src/components/BrandSlot.tsx + - apps/pwa/src/components/CalendarShell.tsx + - apps/pwa/src/components/CredentialSheet.tsx + - apps/pwa/src/components/SettingsSheet.tsx + - apps/pwa/src/components/InstructionSheet.test.tsx + - apps/pwa/src/routes/AdminPage.tsx + - apps/pwa/src/styles/tokens.css + - apps/pwa/index.html + - apps/pwa/vite.config.ts + - apps/pwa/pwa-assets.config.ts + - apps/pwa/e2e/admin.spec.ts + - apps/pwa/e2e/layout.spec.ts +findings: + critical: 0 + warning: 6 + info: 7 + total: 13 +status: issues_found +--- + +# Phase 17: Code Review Report + +**Reviewed:** 2026-06-18 +**Depth:** standard +**Files Reviewed:** 13 +**Status:** issues_found + +## Summary + +Phase 17 is a UI optimization/polish pass: themeable `tokens.css` layer with a `--bottom-chrome-h` chrome token, brand logo via `BrandSlot`, desktop-centered vs phone bottom-sheet branches across the credential/settings/admin sheets, a Sign-out control, admin success toasts, and a two-tab ARIA strip on `/admin`. The intentional design decisions (amber `--color-member-0`, single shipped light theme, `logo.svg`) were treated as approved and not flagged. + +No BLOCKER-class correctness or security defects were found — the password fields use `new-password` autocomplete and are never pre-filled, copy is plain-text JSX (no `dangerouslySetInnerHTML`), and the external links carry `rel="noopener noreferrer"`. The substantive findings are a cluster of accessibility and robustness regressions introduced by the new desktop-modal branches and the responsive `isPhone()` snapshots, plus a stale test mock that no longer matches the API contract. None are data-loss or auth-bypass risks, so all land at WARNING/INFO. + +## Warnings + +### WR-01: Dialog `phone` branch is a render-time snapshot — resize/rotation desyncs modal vs bottom-sheet layout + +**File:** `apps/pwa/src/components/SettingsSheet.tsx:135`, `apps/pwa/src/components/CredentialSheet.tsx:156`, `apps/pwa/src/routes/AdminPage.tsx:59,1378` +**Issue:** Every new desktop-centered-vs-bottom-sheet branch computes `const phone = window.matchMedia('(max-width: 767px)').matches;` once during render with no `matchMedia` change listener and no resize subscription. `App.tsx` (`isPhone()` at line 64/84) and `CalendarShell.tsx` (line 74/252) have the same pattern. On a viewport resize or device rotation that crosses the 767px breakpoint, the component does not re-render, so the modal keeps its stale geometry: a desktop window narrowed below 768px keeps a centered modal (correct target is a bottom sheet) and vice-versa. The FAB offset and `paddingBottom: var(--bottom-chrome-h)` in `App.tsx` also stay on the wrong branch until an unrelated state change forces a re-render. This is a real layout regression on tablets/foldables and desktop-window-resize, which the phase explicitly set out to fix. +**Fix:** Centralize a `useIsPhone()` hook that subscribes to the media query and triggers re-render: +```ts +function useIsPhone() { + const [phone, setPhone] = useState( + () => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches, + ); + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + const onChange = (e: MediaQueryListEvent) => setPhone(e.matches); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, []); + return phone; +} +``` +Use it in App.tsx, CalendarShell, and all four sheet variants instead of the one-shot snapshot. + +### WR-02: New desktop modals have no focus trap and do not restore focus — keyboard/AT regression + +**File:** `apps/pwa/src/components/SettingsSheet.tsx:199` (ChangePasswordSheet `:667`, LinkOidcSheet `:979`), `apps/pwa/src/routes/AdminPage.tsx:1420` (ResetPasswordSheet) +**Issue:** All of these are `role="dialog" aria-modal="true"` but nothing constrains Tab focus inside the dialog — pressing Tab walks into the page content behind the backdrop. `aria-modal="true"` is a promise to AT that the rest of the page is inert; without a focus trap (and the backgrounded content lacks `inert`/`aria-hidden`), screen-reader and keyboard users can land on occluded controls. Additionally, `ChangePasswordSheet` and `LinkOidcSheet` (opened from `SettingsSheet`) never return focus to the row button that opened them on close — `handleClose`/`onClose` just unmount. `CredentialSheet` and `ResetPasswordSheet` do restore focus via `triggerRef`, so the omission in the SettingsSheet sub-sheets is an inconsistent regression. `SettingsSheet` itself also only focuses the close button on open but never restores focus to the avatar trigger on close. +**Fix:** Add a shared focus-trap (cycle Tab/Shift+Tab within the dialog, e.g. via a `useFocusTrap(ref)` hook) and capture/restore `document.activeElement` on open/close for every dialog. At minimum, give `ChangePasswordSheet`/`LinkOidcSheet` a `triggerRef` and refocus it on close to match the established CredentialSheet pattern. + +### WR-03: Modal dialogs do not lock background scroll — desktop centered modal scrolls the page behind it + +**File:** `apps/pwa/src/components/SettingsSheet.tsx:199`, `apps/pwa/src/components/CredentialSheet.tsx:173`, `apps/pwa/src/routes/AdminPage.tsx:1420` +**Issue:** When a centered desktop modal (or bottom sheet) is open, scrolling with wheel/trackpad scrolls the document body behind the backdrop because no scroll lock (`overflow: hidden` on `body`/root, or `overscroll-behavior`) is applied while the dialog is mounted. On `/admin` the content column is long (member form + calendar picker + timezone combobox), so this is reachable in normal use: opening the credential sheet and scrolling moves the admin page underneath. This undermines the "modal" contract and the polish goal. +**Fix:** While any dialog is open, set `document.body.style.overflow = 'hidden'` in a `useEffect` and restore on unmount. Centralize this with the dialog/focus-trap helper so all sheets get consistent behavior. + +### WR-04: `InstructionSheet.test.tsx` mock is stale — `fetchLinkOidc` shape no longer matches the real contract + +**File:** `apps/pwa/src/components/InstructionSheet.test.tsx:43` +**Issue:** The mock declares `fetchLinkOidc: vi.fn().mockResolvedValue({ redirectUrl: '/oidc' })`, but the real `fetchLinkOidc` (api/client.ts:247) resolves `{ signedState, authorizationUrl }`, and `LinkOidcSheet` reads `data.authorizationUrl` (SettingsSheet.tsx:947). The mock returns the wrong key entirely. The test happens to pass today only because permission is `'denied'` (`hasLocalCredential:false` keeps the OIDC row from rendering, so the mutation is never exercised), but the mock encodes an incorrect contract and will silently mask a real regression if the test surface expands to the link flow. The mock also omits `fetchLocalLogout` and `fetchAdminResetPassword`, which `SettingsSheet`/`AdminPage` import — fine for this isolated render, but the file's "mock client so the test doesn't make real network calls" claim is only partial. +**Fix:** Update the mock to the real shape: `fetchLinkOidc: vi.fn().mockResolvedValue({ signedState: 's', authorizationUrl: '/oidc' })`, and add `fetchLocalLogout: vi.fn().mockResolvedValue(undefined)` so future expansion of the suite does not hit `undefined is not a function`. + +### WR-05: Timezone combobox blur timer can fire after unmount / tab switch — setState on stale closure + +**File:** `apps/pwa/src/routes/AdminPage.tsx:104,859-865` +**Issue:** `onBlur` schedules `tzBlurTimer.current = setTimeout(() => { setTzOpen(false); setTzSearch(null); }, 120)`. This timer is cleared in `selectTimezone` and `onFocus`, but there is no cleanup `useEffect` that clears it on unmount. If the admin blurs the timezone field and then immediately switches tabs or the AdminPage unmounts within 120ms, the timer fires `setTzOpen`/`setTzSearch` on an unmounted component (React warns, and in strict mode this is a leak). The two-tab strip uses `hidden` (the Settings panel stays mounted), which reduces but does not eliminate the unmount path (navigating away from `/admin`). +**Fix:** Add a cleanup effect: +```ts +useEffect(() => () => { if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current); }, []); +``` + +### WR-06: `LinkOidcSheet` "Continue with OIDC" stays enabled after success during the redirect window + +**File:** `apps/pwa/src/components/SettingsSheet.tsx:942-958,1100-1125` +**Issue:** On `onSuccess` the mutation sets `window.location.href = data.authorizationUrl` and calls `onClose()`. The button is disabled only while `linkMutation.isPending`; once the mutation settles (success), `isPending` is false again. Between `onSuccess` firing and the browser actually committing the top-level navigation, the sheet is closed so this is largely shielded — but if `authorizationUrl` is null the sheet stays open with the button re-enabled and a generic error, which is correct. The latent issue is the success path: there is no guard preventing a second `mutate()` if the navigation is slow and the user re-taps before unload. A double POST to `/api/me/link-oidc` would mint a second `signedState`, and (as the App.tsx OIDC-state-cookie comment at CalendarShell.tsx:110-120 documents) concurrent state issuance is exactly the class of bug that produces `OAUTH_INVALID_RESPONSE`. +**Fix:** Keep the button disabled through the redirect: track a local `redirecting` flag set in `onSuccess` before assigning `window.location.href`, and include it in the `disabled` expression (`disabled={linkMutation.isPending || redirecting}`). + +## Info + +### IN-01: Decorative logo gives screen readers no accessible product mark, relies solely on `