10 Commits
Author SHA1 Message Date
Lucas Berger b8d4a69b73 docs(phase-17): add security threat verification (9 closed, 0 open)
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Failing after 1m41s
CI / api (pull_request) Successful in 2m2s
CI / security (pull_request) Successful in 54s
CI / gate (pull_request) Has been cancelled
CI / harness (pull_request) Has been cancelled
2026-06-18 14:33:10 -04:00
Lucas Berger 881f2d2d18 test(17): complete UAT - 9 passed, 0 issues (playwright-cli verified) 2026-06-18 14:31:32 -04:00
Lucas Berger 6fa6725fe8 docs(17): add code review fix report (auto-loop, 3 iterations) 2026-06-18 14:06:03 -04:00
Lucas Berger 287ecae2f7 fix(17): CR-01 make focus-trap visibility filter tolerant of jsdom; IN-01 drop inert containment guard 2026-06-18 14:04:33 -04:00
Lucas Berger 89dee4f586 fix(17): IN-03 render visible Redirecting status during OIDC redirect 2026-06-18 13:57:31 -04:00
Lucas Berger f7575ea2c3 fix(17): IN-02 document hand-maintained favicon.ico coupling 2026-06-18 13:56:48 -04:00
Lucas Berger 7578d48d3d fix(17): IN-01 pull focus back into dialog when activeElement escapes 2026-06-18 13:56:32 -04:00
Lucas Berger 5b4625b41d fix(17): WR-01 exclude hidden/zero-size nodes from focus-trap boundaries 2026-06-18 13:56:23 -04:00
Lucas Berger 4bc1e2a820 fix(17): IN-05 normalize AdminPage JSX formatting with Prettier 2026-06-18 13:48:34 -04:00
Lucas Berger 11b6b36cb9 fix(17): IN-07 memoize Intl.DateTimeFormat timezone resolution 2026-06-18 13:47:37 -04:00
14 changed files with 1142 additions and 394 deletions
@@ -0,0 +1,125 @@
---
phase: 17-ui-optimization-polish
fixed_at: 2026-06-18T00:00:00Z
review_path: .planning/phases/17-ui-optimization-polish/17-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 17: Code Review Fix Report
**Fixed at:** 2026-06-18T00:00:00Z
**Source review:** .planning/phases/17-ui-optimization-polish/17-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope: all — includes Info)
- Fixed: 15
- Skipped: 0
All fixes were verified with `tsc --noEmit` (clean) and `eslint --max-warnings 0`
(clean) on every touched file; the PWA also builds (`vite build` succeeds). A new
shared hook `apps/pwa/src/hooks/useIsPhone.ts` was created to back WR-05/IN-03.
## Fixed Issues
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** fb30800
**Applied fix:** Reused the existing `useFocusTrap(dialogRef)` hook (already used by EventForm/SeriesEditPrompt). Added a `dialogRef` + `onKeyDown={handleDialogKeyDown}` to every modal sheet that asserts `aria-modal="true"`: CredentialSheet, SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and ResetPasswordSheet. Tab/Shift-Tab now cycle within the dialog instead of escaping to occluded background controls.
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Rewrote `handleTabKeyDown` to the full WAI-ARIA tabs pattern: ArrowLeft/Right now wrap around the ends using modular arithmetic over the `['members','settings']` order, and Home/End jump to the first/last tab.
### WR-03: Success toast does not re-announce repeated identical messages
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Changed toast state from `string | null` to `{ id: number; msg: string } | null` with a `showToast(msg)` helper that mints a fresh `id` (Date.now()) per call. The rendered toast `<div>` is now keyed on `toast.id` so an identical repeated message remounts and `aria-live` re-announces it; the auto-dismiss effect depends on the fresh object reference so the 3s timer restarts.
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Removed `whiteSpace: 'nowrap'` from the toast style so a longer/localized message wraps within `maxWidth: 90vw` instead of overflowing `documentElement.scrollWidth` (which would trip the layout suite's no-horizontal-overflow rule).
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**Files modified:** `apps/pwa/src/hooks/useIsPhone.ts` (new), `apps/pwa/src/App.tsx`, `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** a4a7438
**Applied fix:** Added a resize-aware `useMediaQuery`/`useIsPhone` hook backed by `matchMedia.addEventListener('change', …)`. Replaced all six synchronous `matchMedia('(max-width: 767px)')` render-time reads with `useIsPhone()`. Hook calls were placed before any early `return null` to respect the Rules of Hooks. Components now re-render when the 767px breakpoint is crossed (iPad rotation, desktop resize).
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Derived a clamped `tzActiveIndexClamped = Math.min(tzActiveIndex, max(0, filteredZones.length - 1))` in render and used it for `aria-activedescendant`, the Enter-to-commit lookup, and the visual highlight (`i === tzActiveIndexClamped`). ArrowUp/ArrowDown clamp the current index before moving so they never start from a stale position past the end of a freshly-shrunk list.
**Note:** Combobox interaction logic — recommend a quick manual/keyboard pass (type to filter, arrow, Enter) to confirm behavior.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Added a `Tab` branch to the combobox `onKeyDown` that commits the highlighted option WITHOUT `preventDefault` (focus still advances to Save). Added an unmount cleanup effect that clears `tzBlurTimer`. Reduced the blur-close `setTimeout` from 120ms to 0ms now that options `preventDefault()` on `onMouseDown` (so a click never blurs the input first).
**Note:** Interaction logic — recommend a manual check that tabbing out of the open listbox commits the highlighted zone and that clicking an option still selects it.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**Files modified:** `apps/pwa/scripts/copy-pwa-icons.mjs` (new), `apps/pwa/package.json`, `apps/pwa/vite.config.ts`
**Commit:** dd0b761
**Applied fix:** Replaced the five-`cp` Unix-only chain with a cross-platform Node script (`fs.copyFileSync`) that maps each generated filename to its stable manifest name and fails loudly with a named error if a generated file is missing (generator rename guard). Added a discoverability comment beside the manifest `icons` array in `vite.config.ts` pointing at the script's COPIES table.
### IN-01: `OidcRedirect` navigates as a render-phase side effect
**Files modified:** `apps/pwa/src/App.tsx`
**Commit:** a4a7438
**Applied fix:** Moved `window.location.replace('/api/login')` into a `useEffect(() => {...}, [])` so the navigation is no longer a render-phase side effect.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 3f4b7ea
**Applied fix:** Wrapped `handleClose` in `useCallback` in CredentialSheet and ChangePasswordSheet, added it to the Escape effect's dependency array, and removed the `// eslint-disable-line react-hooks/exhaustive-deps` comments — matching the LinkOidc/Reset sheet pattern.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**Files modified:** (same as WR-05)
**Commit:** a4a7438
**Applied fix:** Resolved together with WR-05 — the single `useIsPhone()` hook now backs all call sites, and the `(max-width: 767px)` query lives in one place (`PHONE_MAX_QUERY` in the hook). The old standalone `isPhone()` helpers in App.tsx and CalendarShell.tsx were deleted.
### IN-04: Dead placeholder brand tokens retained
**Files modified:** `apps/pwa/src/styles/tokens.css`
**Commit:** 2317833
**Applied fix:** Removed the unused `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` declarations (verified via grep that nothing references them); left a short comment explaining the removal and that BrandSlot only reads `--brand-logo-size`/`--brand-logo-border-radius`.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 4bc1e2a
**Applied fix:** Ran Prettier (project `.prettierrc`) over AdminPage.tsx, normalizing the members-panel indentation and the rest of the file's drift; `prettier --check` now passes on the file. The stacked `marginBottom: var(--space-8)` on the last panel section was left intentionally — the review flagged it only as a minor cosmetic note, and changing section spacing risks a visual regression outside the finding's scope.
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Raised the toast `zIndex` from 300 to 400 so it always paints above sheet backdrops (300) and sheets (301), removing the DOM-order-dependent paint ambiguity.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 11b6b36
**Applied fix:** Wrapped both `Intl.DateTimeFormat().resolvedOptions().timeZone` reads in `useMemo(…, [])``displayTimeZone` in CalendarShell (feeds the stable `useCalendarApp` config) and `detectedTz` in AdminPage.
---
_Fixed: 2026-06-18T00:00:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,125 @@
---
phase: 17-ui-optimization-polish
fixed_at: 2026-06-18T00:00:00Z
review_path: .planning/phases/17-ui-optimization-polish/17-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 17: Code Review Fix Report
**Fixed at:** 2026-06-18T00:00:00Z
**Source review:** .planning/phases/17-ui-optimization-polish/17-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope: all — includes Info)
- Fixed: 15
- Skipped: 0
All fixes were verified with `tsc --noEmit` (clean) and `eslint --max-warnings 0`
(clean) on every touched file; the PWA also builds (`vite build` succeeds). A new
shared hook `apps/pwa/src/hooks/useIsPhone.ts` was created to back WR-05/IN-03.
## Fixed Issues
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** fb30800
**Applied fix:** Reused the existing `useFocusTrap(dialogRef)` hook (already used by EventForm/SeriesEditPrompt). Added a `dialogRef` + `onKeyDown={handleDialogKeyDown}` to every modal sheet that asserts `aria-modal="true"`: CredentialSheet, SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and ResetPasswordSheet. Tab/Shift-Tab now cycle within the dialog instead of escaping to occluded background controls.
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Rewrote `handleTabKeyDown` to the full WAI-ARIA tabs pattern: ArrowLeft/Right now wrap around the ends using modular arithmetic over the `['members','settings']` order, and Home/End jump to the first/last tab.
### WR-03: Success toast does not re-announce repeated identical messages
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Changed toast state from `string | null` to `{ id: number; msg: string } | null` with a `showToast(msg)` helper that mints a fresh `id` (Date.now()) per call. The rendered toast `<div>` is now keyed on `toast.id` so an identical repeated message remounts and `aria-live` re-announces it; the auto-dismiss effect depends on the fresh object reference so the 3s timer restarts.
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Removed `whiteSpace: 'nowrap'` from the toast style so a longer/localized message wraps within `maxWidth: 90vw` instead of overflowing `documentElement.scrollWidth` (which would trip the layout suite's no-horizontal-overflow rule).
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**Files modified:** `apps/pwa/src/hooks/useIsPhone.ts` (new), `apps/pwa/src/App.tsx`, `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`, `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** a4a7438
**Applied fix:** Added a resize-aware `useMediaQuery`/`useIsPhone` hook backed by `matchMedia.addEventListener('change', …)`. Replaced all six synchronous `matchMedia('(max-width: 767px)')` render-time reads with `useIsPhone()`. Hook calls were placed before any early `return null` to respect the Rules of Hooks. Components now re-render when the 767px breakpoint is crossed (iPad rotation, desktop resize).
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Derived a clamped `tzActiveIndexClamped = Math.min(tzActiveIndex, max(0, filteredZones.length - 1))` in render and used it for `aria-activedescendant`, the Enter-to-commit lookup, and the visual highlight (`i === tzActiveIndexClamped`). ArrowUp/ArrowDown clamp the current index before moving so they never start from a stale position past the end of a freshly-shrunk list.
**Note:** Combobox interaction logic — recommend a quick manual/keyboard pass (type to filter, arrow, Enter) to confirm behavior.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 1c0f357
**Applied fix:** Added a `Tab` branch to the combobox `onKeyDown` that commits the highlighted option WITHOUT `preventDefault` (focus still advances to Save). Added an unmount cleanup effect that clears `tzBlurTimer`. Reduced the blur-close `setTimeout` from 120ms to 0ms now that options `preventDefault()` on `onMouseDown` (so a click never blurs the input first).
**Note:** Interaction logic — recommend a manual check that tabbing out of the open listbox commits the highlighted zone and that clicking an option still selects it.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**Files modified:** `apps/pwa/scripts/copy-pwa-icons.mjs` (new), `apps/pwa/package.json`, `apps/pwa/vite.config.ts`
**Commit:** dd0b761
**Applied fix:** Replaced the five-`cp` Unix-only chain with a cross-platform Node script (`fs.copyFileSync`) that maps each generated filename to its stable manifest name and fails loudly with a named error if a generated file is missing (generator rename guard). Added a discoverability comment beside the manifest `icons` array in `vite.config.ts` pointing at the script's COPIES table.
### IN-01: `OidcRedirect` navigates as a render-phase side effect
**Files modified:** `apps/pwa/src/App.tsx`
**Commit:** a4a7438
**Applied fix:** Moved `window.location.replace('/api/login')` into a `useEffect(() => {...}, [])` so the navigation is no longer a render-phase side effect.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
**Files modified:** `apps/pwa/src/components/CredentialSheet.tsx`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 3f4b7ea
**Applied fix:** Wrapped `handleClose` in `useCallback` in CredentialSheet and ChangePasswordSheet, added it to the Escape effect's dependency array, and removed the `// eslint-disable-line react-hooks/exhaustive-deps` comments — matching the LinkOidc/Reset sheet pattern.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**Files modified:** (same as WR-05)
**Commit:** a4a7438
**Applied fix:** Resolved together with WR-05 — the single `useIsPhone()` hook now backs all call sites, and the `(max-width: 767px)` query lives in one place (`PHONE_MAX_QUERY` in the hook). The old standalone `isPhone()` helpers in App.tsx and CalendarShell.tsx were deleted.
### IN-04: Dead placeholder brand tokens retained
**Files modified:** `apps/pwa/src/styles/tokens.css`
**Commit:** 2317833
**Applied fix:** Removed the unused `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` declarations (verified via grep that nothing references them); left a short comment explaining the removal and that BrandSlot only reads `--brand-logo-size`/`--brand-logo-border-radius`.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 4bc1e2a
**Applied fix:** Ran Prettier (project `.prettierrc`) over AdminPage.tsx, normalizing the members-panel indentation and the rest of the file's drift; `prettier --check` now passes on the file. The stacked `marginBottom: var(--space-8)` on the last panel section was left intentionally — the review flagged it only as a minor cosmetic note, and changing section spacing risks a visual regression outside the finding's scope.
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** f601c0c
**Applied fix:** Raised the toast `zIndex` from 300 to 400 so it always paints above sheet backdrops (300) and sheets (301), removing the DOM-order-dependent paint ambiguity.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`, `apps/pwa/src/routes/AdminPage.tsx`
**Commit:** 11b6b36
**Applied fix:** Wrapped both `Intl.DateTimeFormat().resolvedOptions().timeZone` reads in `useMemo(…, [])``displayTimeZone` in CalendarShell (feeds the stable `useCalendarApp` config) and `detectedTz` in AdminPage.
---
_Fixed: 2026-06-18T00:00:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,56 @@
---
phase: 17-ui-optimization-polish
fixed_at: 2026-06-18T14:04:00Z
review_path: .planning/phases/17-ui-optimization-polish/17-REVIEW.md
iteration: 3
findings_in_scope: 2
fixed: 2
skipped: 0
status: all_fixed
---
# Phase 17: Code Review Fix Report (Iteration 3)
**Fixed at:** 2026-06-18T14:04:00Z
**Source review:** .planning/phases/17-ui-optimization-polish/17-REVIEW.md
**Iteration:** 3
**Summary:**
- Findings in scope: 2 (fix_scope: all — includes Info)
- Fixed: 2
- Skipped: 0
**Gate status after fixes (all pass):**
- `pnpm --filter @familysync/pwa test` → pass (22 files, 266 passed / 0 failed)
- `pnpm --filter @familysync/pwa typecheck` → pass (tsc + e2e tsconfig)
- `pnpm --filter @familysync/pwa lint` → pass (eslint `--max-warnings 0`)
## Fixed Issues
### CR-01: `useFocusTrap` visibility filter excluded all focusables under jsdom (CI gate failed)
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts`
**Commit:** 287ecae
**Applied fix:** The prior iter-3 auto-fix (WR-01) rejected every focusable under jsdom because there `getBoundingClientRect()` returns all-zero geometry and `offsetParent` is `null` for every node, which short-circuited the trap (`focusable.length === 0`) and broke the two pre-existing WR-07 focus-trap regression tests — making `pnpm test` (a CI gate) fail at 2 failed / 264 passed.
Made the visibility heuristic tolerant of a non-layout environment: it now derives `hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null`, and when there is no evidence of a layout engine (jsdom) it treats the node as visible instead of filtering it. Only when a real layout exists does it apply the `offsetParent === null` / zero-geometry exclusion, so genuinely hidden/collapsed nodes are still excluded in a real browser. The `hidden`-attribute exclusion is unambiguous regardless of layout, so it was hoisted out and kept unconditional. Result: all 266 PWA tests pass, including both WR-07 cases.
### IN-01: Focus-trap containment guard was unreachable as wired (harmless dead branch)
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts`
**Commit:** 287ecae
**Applied fix:** The handler is wired only to each dialog's own `onKeyDown`, so it can only run while focus is already inside the dialog subtree; the `!dialogRef.current.contains(document.activeElement)` containment branch could therefore never evaluate true and delivered no actual containment guarantee. Per the review's recommendation, removed the inert branch and replaced its misleading comment with an accurate note: this is a deliberate boundary-only trap (a `document`-level `keydown`/`focusin` listener would be required for true containment, and is unnecessary for the current always-focus-the-heading-on-open flows). No behavior change in any real scenario — it only removes a comment that implied a guarantee the wiring cannot provide.
## Skipped Issues
None.
## Prior Iterations
Iterations 1 and 2 fixed the earlier batches of findings (15 in iter-1, then the iter-3 review's WR-01/IN-02/IN-03 set). The IN-02 (favicon.ico coupling) and IN-03 (OidcRedirect visible status) fixes were confirmed clean by the final re-review. This iteration-3 report supersedes those and records the final state: the WR-01 regression (CR-01) and its inert containment guard (IN-01) are now resolved, with all CI gates green.
---
_Fixed: 2026-06-18T14:04:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 3_
@@ -0,0 +1,167 @@
---
phase: 17-ui-optimization-polish
reviewed: 2026-06-18T00:00:00Z
depth: deep
files_reviewed: 13
files_reviewed_list:
- apps/pwa/e2e/admin.spec.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/index.html
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- 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/routes/AdminPage.tsx
- apps/pwa/src/styles/tokens.css
- apps/pwa/vite.config.ts
findings:
critical: 0
warning: 8
info: 7
total: 15
status: issues_found
---
# Phase 17: Code Review Report
**Reviewed:** 2026-06-18T00:00:00Z
**Depth:** deep
**Files Reviewed:** 13
**Status:** issues_found
## Summary
Phase 17 is UI optimization/polish: brand logo swap, PWA manifest/icon hand-maintenance, an admin two-tab ARIA strip, a success toast, and a searchable timezone combobox, plus structural layout/admin Playwright suites. No structural-findings pre-pass was provided.
The code is generally careful — XSS surfaces are plain-text JSX, password fields use `new-password` autocomplete and are never pre-filled, the OIDC `authorizationUrl` null is guarded before navigation, and touch targets are consistently ≥44px. I found **no BLOCKERs** (no injection, no secret leakage, no data-loss path, no crash on the happy path).
There are real correctness/robustness defects worth fixing before ship: the **admin tab keyboard handler half-implements the WAI-ARIA tabs pattern** (no Home/End, no wrap); the **timezone combobox `aria-activedescendant`/highlight can desync after filtering** and **drops Tab-to-commit**; the **success toast does not re-announce** repeated identical messages and its `whiteSpace: nowrap` is a latent Rule-2 horizontal-overflow hazard against the project's own layout suite; **none of the modal dialogs trap focus** despite `aria-modal="true"`; and several `window.matchMedia` reads at render time **do not react to resize/orientation**, a stale-UI class this project explicitly cares about (iPad rotation).
## Warnings
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**File:** `apps/pwa/src/components/CredentialSheet.tsx:173-176`, `apps/pwa/src/components/SettingsSheet.tsx:199-202` (plus ChangePasswordSheet ~667-701 and LinkOidcSheet ~979-1013), `apps/pwa/src/routes/AdminPage.tsx:1420-1423` (ResetPasswordSheet)
**Issue:** Every sheet sets `role="dialog"` + `aria-modal="true"` and focuses the heading/close button on open, but none implements a focus trap. Tab/Shift-Tab can move focus out of the dialog to content behind the backdrop (still in the DOM). `aria-modal="true"` asserts to assistive tech that focus is contained — it is not. The app's stated UX hard-constraint is "slick and low-friction for a non-technical Apple member"; VoiceOver/keyboard users will escape the dialog silently and interact with occluded background controls.
**Fix:** Add a focus trap — capture Tab/Shift-Tab in the dialog keydown handler and cycle between first/last focusable descendants, ideally as a shared `useFocusTrap(ref)` hook reused by all sheets:
```tsx
onKeyDown={(e) => {
if (e.key !== 'Tab') return;
const f = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])');
if (!f?.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}}
```
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
**File:** `apps/pwa/src/routes/AdminPage.tsx:205-225`
**Issue:** `handleTabKeyDown` handles only `ArrowRight`/`ArrowLeft`, and the next/prev computation is a two-state toggle that does not wrap (ArrowRight on the Settings tab is a no-op rather than wrapping to Members). The WAI-ARIA tabs pattern requires `Home`/`End` to jump to first/last tab. `admin.spec.ts` (lines 134-152) only exercises the Arrow keys, so this gap is untested and ships a half-pattern.
**Fix:** Handle `Home`/`End` and decide wrap behavior explicitly:
```tsx
const order = ['members', 'settings'] as const;
const idx = order.indexOf(current);
let next: typeof order[number] | null = null;
if (e.key === 'ArrowRight') next = order[(idx + 1) % order.length];
else if (e.key === 'ArrowLeft') next = order[(idx - 1 + order.length) % order.length];
else if (e.key === 'Home') next = order[0];
else if (e.key === 'End') next = order[order.length - 1];
if (next) { e.preventDefault(); setActiveTab(next); /* focus #admin-tab-${next} */ }
```
### WR-03: Success toast does not re-announce repeated identical messages
**File:** `apps/pwa/src/routes/AdminPage.tsx:62-69`, `1028-1065`
**Issue:** The toast is a single `role="status" aria-live="polite"` region rendering the `toast` string. If the same message fires twice (two password resets, two "Member added.") and the second `setToast('…')` lands before the first cleared, React's state-equality short-circuit means the DOM text does not change, so `aria-live` does not re-announce — the second success is silent to screen-reader users, and the 3s auto-dismiss timer (keyed on `toast` identity) does not reset for an identical string.
**Fix:** Make each toast a distinct value and remount it so AT re-announces and the timer resets:
```tsx
const [toast, setToast] = useState<{ id: number; msg: string } | null>(null);
const show = (msg: string) => setToast({ id: Date.now(), msg });
// effect dep: [toast?.id]; render: <div key={toast.id} role="status" ...>{toast.msg}</div>
```
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression against Rule 2
**File:** `apps/pwa/src/routes/AdminPage.tsx:1054-1055`
**Issue:** The toast sets `whiteSpace: 'nowrap'` with `maxWidth: '90vw'`. `nowrap` + `maxWidth` does not shrink text; it overflows. A longer/localized toast on a 390px viewport will exceed 90vw and, because the toast is `position: fixed`, contribute to `documentElement.scrollWidth` — violating `layout.spec.ts` Rule 2 (lines 176-200), which asserts no horizontal overflow on `/calendar` and `/lists`. Current strings are short, so the bug is latent, not active, but it is a direct hazard to the project's own quality bar.
**Fix:** Remove `whiteSpace: 'nowrap'` (let it wrap), or bound the width and use `overflow:hidden; text-overflow:ellipsis`. Wrapping is safer for a toast that may localize.
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**File:** `apps/pwa/src/App.tsx:64-66` (`isPhone()`), `apps/pwa/src/components/CalendarShell.tsx:74-76`, `apps/pwa/src/components/SettingsSheet.tsx:135`, `apps/pwa/src/components/CredentialSheet.tsx:156`, `apps/pwa/src/routes/AdminPage.tsx:59`, `1378-1379`
**Issue:** These components compute `phone` once per render via synchronous `matchMedia('(max-width: 767px)').matches`, with no `change` listener. Rotating an iPad across 767px (or resizing a desktop window across the breakpoint) does not trigger a re-render, so the layout (FAB vs toolbar button in `CalendarShell`, bottom-sheet vs centered modal in the sheets, content `paddingBottom` in `App`) stays stale until an unrelated state change forces a re-render. The developer profile explicitly flags resize/orientation correctness; iPad rotation is a realistic trigger for this cross-ecosystem app.
**Fix:** Use a `useMediaQuery` hook backed by `matchMedia.addEventListener('change', …)` so components re-render on breakpoint crossing; share a single `phone` value through context/hook so all call sites stay consistent.
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**File:** `apps/pwa/src/routes/AdminPage.tsx:819-821`, `833-837`, `839-845`, `912-919`
**Issue:** `onChange` resets `tzActiveIndex` to 0 while `ArrowDown` clamps against `filteredZones.length - 1` from the *current render closure*. With batched updates, interleavings exist where `tzActiveIndex` (and thus `aria-activedescendant={tz-opt-${tzActiveIndex}}`, line 820) references an option index that no longer exists after the filtered list shrinks (e.g., active 12, then a keystroke filters to 3 rows before re-clamp). Separately, the visual highlight uses `i === tzActiveIndex` (line 913) while `aria-selected` uses `tz === effectiveTimezoneInput` (line 919) — two different bases, so the highlighted row and the AT-announced row can disagree.
**Fix:** Derive a clamped active index in render and use it everywhere (visual + `aria-activedescendant`): `const activeIndex = Math.min(tzActiveIndex, Math.max(0, filteredZones.length - 1))`, or reset `tzActiveIndex` to 0 in a `useEffect` keyed on `tzSearch`.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**File:** `apps/pwa/src/routes/AdminPage.tsx:838-865`, `927`
**Issue:** (1) `onKeyDown` handles ArrowUp/Down/Enter/Escape but not `Tab`. Tabbing out with the listbox open and an option highlighted moves focus to Save without committing — the input/`effectiveTimezoneInput` still holds the raw search text, so the admin can attempt to save a partial string (server 400s, but the UX is a confusing failure). (2) The `onBlur` 120ms `setTimeout` to let an option's `onClick` fire is a race; since options already `onMouseDown` `preventDefault()` (line 927), blur won't fire on option click, so the 120ms hack may be unnecessary. The `tzBlurTimer` is cleared on focus/select but not on unmount.
**Fix:** Commit the active option on `Tab` (without `preventDefault`, so focus still advances); clear `tzBlurTimer` in an unmount cleanup effect; reassess/remove the 120ms blur delay now that `onMouseDown` preventDefault is in place.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**File:** `apps/pwa/package.json:16`
**Issue:** `pwa:icons` chains the assets generator with five `cp` commands. (1) `cp` is Unix-only — breaks on Windows contributors and minimal CI containers. (2) It hard-codes the generator's output names (`pwa-192x192.png`, `maskable-icon-512x512.png`, `apple-touch-icon-180x180.png`); a generator version bump that renames outputs breaks it with an opaque `cp: cannot stat`. (3) The manifest icon entries in `vite.config.ts:38-42` (`/icon-192.png`, etc.) only stay in sync because of these manual renames — an invisible coupling with no test. Regenerating icons without running the full script leaves the manifest referencing stale files.
**Fix:** Configure the generator to emit the final filenames directly, or replace the `cp` chain with a small cross-platform Node script (`fs.copyFileSync`). At minimum, add a comment in `vite.config.ts` by the icon entries pointing at the `pwa:icons` rename step so the coupling is discoverable.
## Info
### IN-01: `OidcRedirect` navigates as a render-phase side effect
**File:** `apps/pwa/src/App.tsx:77-80`
**Issue:** `OidcRedirect` calls `window.location.replace('/api/login')` directly in the function body (render phase). React may render a component more than once (StrictMode double-invoke in dev, concurrent re-renders); side effects in render are an anti-pattern. It works because `replace` is idempotent and the page unloads, but it is fragile.
**Fix:** Move the navigation into `useEffect(() => { window.location.replace('/api/login'); }, [])` and render the placeholder.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
**File:** `apps/pwa/src/components/CredentialSheet.tsx:95`, `apps/pwa/src/components/SettingsSheet.tsx:605`
**Issue:** The Escape `useEffect` disables `react-hooks/exhaustive-deps` (because `handleClose` is referenced but not listed), while the `LinkOidcSheet`/`ResetPasswordSheet` versions list `[isOpen, onClose]` with no disable. The blanket disable also hides any future missing dep added to that effect.
**Fix:** Wrap `handleClose` in `useCallback` and add it to the dep array, removing the disable; make the pattern consistent across all sheets.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**File:** `apps/pwa/src/App.tsx:64-66`, `CalendarShell.tsx:74-76`, `SettingsSheet.tsx:135`, `CredentialSheet.tsx:156`, `AdminPage.tsx:59`, `1378-1379`
**Issue:** The same breakpoint check is reimplemented in two spellings (`isPhone()` helper vs inline `matchMedia`), and the JS hard-codes `767` while `tokens.css` declares `--bp-tablet: 768px`. Drift risk if the breakpoint changes.
**Fix:** Extract one `useIsPhone()` hook (ideally the resize-aware one from WR-05) and import it everywhere; reference the breakpoint in a single place.
### IN-04: Dead placeholder brand tokens retained
**File:** `apps/pwa/src/styles/tokens.css:103-104,107`
**Issue:** `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` are leftovers from the Phase 19 "FS initials circle." BrandSlot now renders `logo.svg` and reads only `--brand-logo-size`/`--brand-logo-border-radius`; `--brand-app-name` is commented "drives doc only — not used as CSS content." These are dead declarations.
**Fix:** Remove them, or add a comment that they're retained intentionally for a planned fallback.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**File:** `apps/pwa/src/routes/AdminPage.tsx:364-418`
**Issue:** Inside `admin-panel-members`, `<section aria-label="Members">` and its children are indented inconsistently (section at one level, children shallower), and both the Members and Local Accounts sections carry `marginBottom: var(--space-8)`, adding trailing space at the panel boundary. Cosmetic, but will trip future edits.
**Fix:** Reformat the panel JSX — Prettier should normalize it. Confirm `pnpm --filter @familysync/pwa lint`/format was run (a recurring pre-push gate on this project).
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**File:** `apps/pwa/src/routes/AdminPage.tsx:1033` (toast 300) vs `CredentialSheet.tsx:169,189` (backdrop 300 / sheet 301), ResetPasswordSheet (300/301)
**Issue:** The toast shares `zIndex: 300` with the sheet backdrops. If a toast lingers while a sheet opens within the 3s window, paint order becomes DOM-order-dependent and the toast can render under the backdrop dim. Low likelihood, but the z-index scale is not cleanly layered.
**Fix:** Put the toast above dialogs (e.g. `zIndex: 400`) and document a named z-index scale (backdrop/sheet/toast) in `tokens.css`.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**File:** `apps/pwa/src/components/CalendarShell.tsx:159`, `apps/pwa/src/routes/AdminPage.tsx:146`
**Issue:** `Intl.DateTimeFormat().resolvedOptions().timeZone` is called inline in render. Cheap, but in `CalendarShell` it feeds `useCalendarApp` config, whose stability the file's own comments warn about. (Flagged as a note, not a perf-scope item, because it touches the calendar-app config the code explicitly tries to keep stable.)
**Fix:** `const displayTimeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, [])`.
---
_Reviewed: 2026-06-18T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
@@ -0,0 +1,95 @@
---
phase: 17-ui-optimization-polish
reviewed: 2026-06-18T00:00:00Z
depth: deep
files_reviewed: 16
files_reviewed_list:
- apps/pwa/e2e/admin.spec.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/index.html
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- apps/pwa/scripts/copy-pwa-icons.mjs
- 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/hooks/useFocusTrap.ts
- apps/pwa/src/hooks/useIsPhone.ts
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/styles/tokens.css
- apps/pwa/vite.config.ts
findings:
critical: 0
warning: 1
info: 3
total: 4
status: issues_found
---
# Phase 17: Code Review Report (Re-Review After Auto-Fix)
**Reviewed:** 2026-06-18T00:00:00Z
**Depth:** deep
**Files Reviewed:** 16
**Status:** issues_found
## Summary
This is a re-review of Phase 17 (UI optimization/polish) after auto-fixes were applied to the prior 15 findings (8 warnings, 7 info). I re-read every listed file at deep depth, traced the just-changed code (focus-trap wiring across the 5 sheets, the new `useIsPhone`/`useFocusTrap` hooks, admin tab keyboard handling, the toast re-announce/wrapping changes, the timezone combobox active-index/Tab-commit logic, and the cross-platform icon-copy script), and confirmed the fixes against the surrounding call sites for regressions.
**All 8 prior warnings and all 7 prior info items are correctly resolved.** Both local gates pass clean: `pnpm --filter @familysync/pwa typecheck` (tsc + e2e tsconfig) and `pnpm --filter @familysync/pwa lint` (eslint `--max-warnings 0`) both succeed with no output. I found **no BLOCKERs** and **no regressions** introduced by the fixes.
What the fixes got right and why they don't regress:
- **Focus trap** (`useFocusTrap`) is wired into all five dialogs. The child sheets (`ChangePasswordSheet`/`LinkOidcSheet`) are rendered as DOM **siblings** of the `SettingsSheet` dialog div (after the `</div>` at SettingsSheet.tsx:563), not descendants, so the parent trap's `querySelectorAll` cannot capture child-sheet focusables and there is no double-trap conflict — each sheet owns its own trap.
- **Combobox desync** is genuinely fixed: `tzActiveIndexClamped` now drives the visual highlight (AdminPage.tsx:955), `aria-activedescendant` (:838), and the Enter/Tab commit (:879/:887) from one clamped source. `aria-selected` correctly stays bound to `effectiveTimezoneInput` (:961) — that is the right ARIA distinction (selected value vs. active option), not a residual bug.
- **Tab-to-commit** commits the clamped option without `preventDefault`, the blur timer is now `setTimeout(…, 0)` and is cleared on focus, on select, and on unmount (AdminPage.tsx:115-119) — no setState-after-unmount path remains.
- **Toast** is keyed on a unique `{id, msg}` so identical repeats remount and `aria-live` re-announces; `whiteSpace: nowrap` is removed so it wraps within `maxWidth: 90vw` (no Rule 2 overflow hazard); z-index raised to 400, above all sheet backdrops (max 303), resolving the prior overlap.
- **`useIsPhone`/`useMediaQuery`** subscribe via `addEventListener('change', …)` and are now used at every former inline `matchMedia` site (App, CalendarShell, all sheets, AdminPage), so iPad rotation across 767px reflows correctly. SSR guard returns `false` cleanly.
The one remaining WARNING is a pre-existing focus-trap robustness gap (not introduced this phase, but now load-bearing because `aria-modal` promises containment). The three INFO items are minor and non-blocking.
## Warnings
### WR-01: `useFocusTrap` only wraps at the boundaries — focus can still escape via hidden/zero-size focusables
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:25-48`
**Issue:** The trap queries `button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])` and filters only `!disabled` and `tabindex !== '-1'`. It does not exclude elements that are `display:none`, `visibility:hidden`, `hidden`, or zero-size. In the current sheets every focusable is visible, so the trap works today. But the pattern has two latent escape paths: (1) if a dialog ever conditionally renders a focusable inside a `hidden`/collapsed block, that element joins the `first`/`last` computation and the wrap math targets an unfocusable node — `last.focus()` becomes a no-op and Tab leaks to background content (which `aria-modal="true"` asserts is impossible); (2) the trap only intervenes at the exact first/last boundary, so it relies on the browser's natural Tab order between them being correct and contained. This is the kind of half-implemented trap the prior WR-01 set out to eliminate; the fix is correct for the present DOM but fragile for future edits.
**Fix:** Filter to genuinely focusable, rendered elements before computing first/last, e.g.:
```ts
.filter((el) => {
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
if (el.hasAttribute('hidden') || (el as HTMLElement).offsetParent === null) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
```
Alternatively, document that all dialog focusables must be unconditionally rendered and visible while the dialog is open.
## Info
### IN-01: Focus trap does not pull focus back when `activeElement` is already outside the dialog
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:36-47`
**Issue:** The handler wraps only when `document.activeElement === first` (Shift+Tab) or `=== last` (Tab). Each sheet focuses its heading/close button on open, so the trap engages from inside. But `aria-modal="true"` does not actually prevent the background DOM (still mounted behind the backdrop) from receiving focus — e.g. a programmatic focus, or a browser quirk, could land focus outside the dialog, and then neither boundary condition matches, so Tab moves through background content until it happens to re-enter. This is the residual weakness of a boundary-only trap versus a containment trap (which checks `dialogRef.current.contains(document.activeElement)` and redirects when false). Low likelihood given the open-focus behavior; noted for completeness.
**Fix:** Add a containment guard: if `!dialogRef.current.contains(document.activeElement)` on Tab, `preventDefault()` and focus `first`.
### IN-02: `favicon.ico` is referenced by `index.html` but not produced by `pwa:icons`
**File:** `apps/pwa/index.html:7`, `apps/pwa/scripts/copy-pwa-icons.mjs:21-27`
**Issue:** `index.html` links `/favicon.ico`, and the file is committed in `public/` (967 bytes). The new `copy-pwa-icons.mjs` `COPIES` table generates `favicon.svg` (from `logo.svg`) and the PNGs, but the `minimal2023Preset` does not emit a `.ico`, so `favicon.ico` is hand-maintained outside the script. This is the same invisible-coupling class the prior WR-08 flagged, just narrowed: regenerating icons leaves `favicon.ico` stale relative to a new brand mark, with nothing to catch it. The script's own header says "Keep COPIES in sync with the manifest," but the `.ico` link in `index.html` has no such pointer.
**Fix:** Either drop the `favicon.ico` link (the SVG favicon + `sizes="any"` covers modern browsers) or add a comment in `copy-pwa-icons.mjs`/`index.html` noting `favicon.ico` is hand-maintained and must be regenerated manually when the brand mark changes.
### IN-03: `OidcRedirect` placeholder renders an empty `aria-hidden` div for a full render cycle
**File:** `apps/pwa/src/App.tsx:74-82`
**Issue:** The prior IN-01 fix correctly moved the navigation into `useEffect`. The component now renders `<div aria-hidden="true" />` and the redirect fires post-commit. For the OIDC-only-mode unauthenticated path this means a brief blank frame before `window.location.replace('/api/login')` unloads the page. Functionally fine and a strict improvement over the render-phase side effect, but the blank `aria-hidden` div gives screen-reader/keyboard users no "redirecting…" affordance during the gap.
**Fix:** Render a minimal visible "Redirecting to sign in…" status (e.g. `role="status"`) instead of an empty `aria-hidden` div, so the transition is perceivable if the redirect is slow.
---
_Reviewed: 2026-06-18T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
_Re-review: prior 15 findings all confirmed resolved; gates (typecheck + lint) pass clean_
@@ -2,166 +2,127 @@
phase: 17-ui-optimization-polish
reviewed: 2026-06-18T00:00:00Z
depth: deep
files_reviewed: 13
files_reviewed: 16
files_reviewed_list:
- apps/pwa/e2e/admin.spec.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/index.html
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- apps/pwa/scripts/copy-pwa-icons.mjs
- 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/hooks/useFocusTrap.ts
- apps/pwa/src/hooks/useIsPhone.ts
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/styles/tokens.css
- apps/pwa/vite.config.ts
findings:
critical: 0
warning: 8
info: 7
total: 15
critical: 1
warning: 0
info: 1
total: 2
status: issues_found
---
# Phase 17: Code Review Report
# Phase 17: Code Review Report (Final Re-Review After Iter-3 Auto-Fixes)
**Reviewed:** 2026-06-18T00:00:00Z
**Depth:** deep
**Files Reviewed:** 13
**Files Reviewed:** 16
**Status:** issues_found
## Summary
Phase 17 is UI optimization/polish: brand logo swap, PWA manifest/icon hand-maintenance, an admin two-tab ARIA strip, a success toast, and a searchable timezone combobox, plus structural layout/admin Playwright suites. No structural-findings pre-pass was provided.
This is the final re-review of Phase 17 after the second round of auto-fixes, which targeted the three iter-3 findings:
The code is generally careful — XSS surfaces are plain-text JSX, password fields use `new-password` autocomplete and are never pre-filled, the OIDC `authorizationUrl` null is guarded before navigation, and touch targets are consistently ≥44px. I found **no BLOCKERs** (no injection, no secret leakage, no data-loss path, no crash on the happy path).
- **WR-01** — `useFocusTrap` hidden/zero-size focusable exclusion + containment guard.
- **IN-03** — `App.tsx` `OidcRedirect` now renders a visible "Redirecting to sign in…" status.
- **IN-02** — favicon.ico hand-maintained coupling documented in `copy-pwa-icons.mjs` and `index.html`.
There are real correctness/robustness defects worth fixing before ship: the **admin tab keyboard handler half-implements the WAI-ARIA tabs pattern** (no Home/End, no wrap); the **timezone combobox `aria-activedescendant`/highlight can desync after filtering** and **drops Tab-to-commit**; the **success toast does not re-announce** repeated identical messages and its `whiteSpace: nowrap` is a latent Rule-2 horizontal-overflow hazard against the project's own layout suite; **none of the modal dialogs trap focus** despite `aria-modal="true"`; and several `window.matchMedia` reads at render time **do not react to resize/orientation**, a stale-UI class this project explicitly cares about (iPad rotation).
I re-read every listed file at deep depth and traced the changed code against its call sites and the existing test suite.
## Warnings
**Two of the three fixes are correct and regression-free:**
- **IN-03 (OidcRedirect):** Correct. The navigation stays in `useEffect` (no render-phase side effect), and the placeholder is now a perceivable `role="status"` "Redirecting to sign in…" (App.tsx:83-97). No regression.
- **IN-02 (favicon.ico coupling):** Correct and complete. Both `copy-pwa-icons.mjs` (lines 13-17) and `index.html` (line 7) now carry the hand-maintained-`.ico` pointer. Verified on disk: `favicon.svg` is byte-identical to `logo.svg` (produced by the `COPIES` table), and `favicon.ico` (967 B) is committed separately. The invisible coupling is now documented at both ends.
### WR-01: Modal dialogs declare `aria-modal="true"` but do not trap focus
**The WR-01 fix introduces a CR-tier regression.** The new visibility filter in `useFocusTrap.ts` (lines 36-38) relies on `offsetParent` and `getBoundingClientRect()` width/height. Both are `null`/`0` under jsdom — the environment the existing focus-trap unit tests run in — so the filter now excludes **every** focusable, `focusable.length === 0` short-circuits, and the trap silently stops wrapping focus. This breaks the two pre-existing `EventForm.test.tsx` WR-07 tests and makes `pnpm test` (a CI gate per CLAUDE.md) fail.
**File:** `apps/pwa/src/components/CredentialSheet.tsx:173-176`, `apps/pwa/src/components/SettingsSheet.tsx:199-202` (plus ChangePasswordSheet ~667-701 and LinkOidcSheet ~979-1013), `apps/pwa/src/routes/AdminPage.tsx:1420-1423` (ResetPasswordSheet)
**Issue:** Every sheet sets `role="dialog"` + `aria-modal="true"` and focuses the heading/close button on open, but none implements a focus trap. Tab/Shift-Tab can move focus out of the dialog to content behind the backdrop (still in the DOM). `aria-modal="true"` asserts to assistive tech that focus is contained — it is not. The app's stated UX hard-constraint is "slick and low-friction for a non-technical Apple member"; VoiceOver/keyboard users will escape the dialog silently and interact with occluded background controls.
**Fix:** Add a focus trap — capture Tab/Shift-Tab in the dialog keydown handler and cycle between first/last focusable descendants, ideally as a shared `useFocusTrap(ref)` hook reused by all sheets:
```tsx
onKeyDown={(e) => {
if (e.key !== 'Tab') return;
const f = dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])');
if (!f?.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}}
Gate status after the fixes:
- `pnpm --filter @familysync/pwa typecheck`**pass** (tsc + e2e tsconfig).
- `pnpm --filter @familysync/pwa lint`**pass** (eslint `--max-warnings 0`).
- `pnpm --filter @familysync/pwa test` (vitest) → **FAIL**: 2 failed / 264 passed / 266 total. Both failures are the WR-07 focus-trap tests, caused directly by the WR-01 change under review.
The IN-01 containment guard added alongside WR-01 is functionally inert (the handler is only wired to the dialog's `onKeyDown`, which cannot fire when focus is outside the dialog), but it is harmless — recorded as INFO.
## Critical Issues
### CR-01: `useFocusTrap` visibility filter excludes all focusables under jsdom — breaks the focus-trap test suite (CI gate fails)
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:36-38`
**Issue:** The WR-01 fix added a "rendered/visible" filter to the focusable query:
```ts
if (el.hasAttribute('hidden') || el.offsetParent === null) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
```
In a real browser this is correct. But the existing focus-trap regression tests (`apps/pwa/src/components/EventForm.test.tsx`, the two `WR-07` cases at lines 718-764) run under **jsdom**, where:
- `getBoundingClientRect()` returns all-zero geometry for every element (`width === 0`, `height === 0`), and
- `offsetParent` is `null` for every element.
Either condition alone causes the filter to reject **every** focusable. `focusable.length` becomes `0`, the handler hits the `if (focusable.length === 0) return;` early-out (line 41), and Tab/Shift+Tab no longer wrap. Both WR-07 tests now fail:
```
FAIL src/components/EventForm.test.tsx > WR-07: Tab from last focusable element wraps focus to first inside dialog
FAIL src/components/EventForm.test.tsx > WR-07: Shift+Tab from first focusable element wraps focus to last inside dialog
Test Files 1 failed | 21 passed (22)
Tests 2 failed | 264 passed (266)
```
### WR-02: Admin tab strip keyboard nav is incomplete (no Home/End, no explicit wrap)
`pnpm test` (→ `vitest run`) is one of the CI gates the project requires to pass before push (CLAUDE.md "Frustrations" directive + the CI-checks-conformance memory). This regression ships as part of the file under review, so it is in scope even though `EventForm.test.tsx` is not in the listed-files set — those tests exist specifically to guard the changed behavior.
**File:** `apps/pwa/src/routes/AdminPage.tsx:205-225`
**Issue:** `handleTabKeyDown` handles only `ArrowRight`/`ArrowLeft`, and the next/prev computation is a two-state toggle that does not wrap (ArrowRight on the Settings tab is a no-op rather than wrapping to Members). The WAI-ARIA tabs pattern requires `Home`/`End` to jump to first/last tab. `admin.spec.ts` (lines 134-152) only exercises the Arrow keys, so this gap is untested and ships a half-pattern.
**Fix:** Handle `Home`/`End` and decide wrap behavior explicitly:
```tsx
const order = ['members', 'settings'] as const;
const idx = order.indexOf(current);
let next: typeof order[number] | null = null;
if (e.key === 'ArrowRight') next = order[(idx + 1) % order.length];
else if (e.key === 'ArrowLeft') next = order[(idx - 1 + order.length) % order.length];
else if (e.key === 'Home') next = order[0];
else if (e.key === 'End') next = order[order.length - 1];
if (next) { e.preventDefault(); setActiveTab(next); /* focus #admin-tab-${next} */ }
**Fix:** Make the visibility filter tolerant of a non-layout (jsdom) environment, so it excludes genuinely hidden nodes in a browser without nuking all nodes in tests. Treat zero-geometry as "visible" when no layout engine is present, and gate on `offsetParent` only when geometry is meaningful:
```ts
.filter((el) => {
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
if (el.hasAttribute('hidden')) return false;
// jsdom has no layout: getBoundingClientRect() is all-zero and offsetParent is
// null for every node. Only apply the visibility heuristic when a real layout
// exists, so unit tests still see focusables.
const r = el.getBoundingClientRect();
const hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null;
if (!hasLayout) return true; // no layout engine → don't filter on visibility
if (el.offsetParent === null) return false;
return r.width > 0 && r.height > 0;
});
```
### WR-03: Success toast does not re-announce repeated identical messages
**File:** `apps/pwa/src/routes/AdminPage.tsx:62-69`, `1028-1065`
**Issue:** The toast is a single `role="status" aria-live="polite"` region rendering the `toast` string. If the same message fires twice (two password resets, two "Member added.") and the second `setToast('…')` lands before the first cleared, React's state-equality short-circuit means the DOM text does not change, so `aria-live` does not re-announce — the second success is silent to screen-reader users, and the 3s auto-dismiss timer (keyed on `toast` identity) does not reset for an identical string.
**Fix:** Make each toast a distinct value and remount it so AT re-announces and the timer resets:
```tsx
const [toast, setToast] = useState<{ id: number; msg: string } | null>(null);
const show = (msg: string) => setToast({ id: Date.now(), msg });
// effect dep: [toast?.id]; render: <div key={toast.id} role="status" ...>{toast.msg}</div>
```
### WR-04: Toast `whiteSpace: nowrap` is a latent horizontal-overflow regression against Rule 2
**File:** `apps/pwa/src/routes/AdminPage.tsx:1054-1055`
**Issue:** The toast sets `whiteSpace: 'nowrap'` with `maxWidth: '90vw'`. `nowrap` + `maxWidth` does not shrink text; it overflows. A longer/localized toast on a 390px viewport will exceed 90vw and, because the toast is `position: fixed`, contribute to `documentElement.scrollWidth` — violating `layout.spec.ts` Rule 2 (lines 176-200), which asserts no horizontal overflow on `/calendar` and `/lists`. Current strings are short, so the bug is latent, not active, but it is a direct hazard to the project's own quality bar.
**Fix:** Remove `whiteSpace: 'nowrap'` (let it wrap), or bound the width and use `overflow:hidden; text-overflow:ellipsis`. Wrapping is safer for a toast that may localize.
### WR-05: `matchMedia(...)` read at render time does not react to resize/orientation
**File:** `apps/pwa/src/App.tsx:64-66` (`isPhone()`), `apps/pwa/src/components/CalendarShell.tsx:74-76`, `apps/pwa/src/components/SettingsSheet.tsx:135`, `apps/pwa/src/components/CredentialSheet.tsx:156`, `apps/pwa/src/routes/AdminPage.tsx:59`, `1378-1379`
**Issue:** These components compute `phone` once per render via synchronous `matchMedia('(max-width: 767px)').matches`, with no `change` listener. Rotating an iPad across 767px (or resizing a desktop window across the breakpoint) does not trigger a re-render, so the layout (FAB vs toolbar button in `CalendarShell`, bottom-sheet vs centered modal in the sheets, content `paddingBottom` in `App`) stays stale until an unrelated state change forces a re-render. The developer profile explicitly flags resize/orientation correctness; iPad rotation is a realistic trigger for this cross-ecosystem app.
**Fix:** Use a `useMediaQuery` hook backed by `matchMedia.addEventListener('change', …)` so components re-render on breakpoint crossing; share a single `phone` value through context/hook so all call sites stay consistent.
### WR-06: Timezone combobox `aria-activedescendant`/highlight can desync after filtering
**File:** `apps/pwa/src/routes/AdminPage.tsx:819-821`, `833-837`, `839-845`, `912-919`
**Issue:** `onChange` resets `tzActiveIndex` to 0 while `ArrowDown` clamps against `filteredZones.length - 1` from the *current render closure*. With batched updates, interleavings exist where `tzActiveIndex` (and thus `aria-activedescendant={tz-opt-${tzActiveIndex}}`, line 820) references an option index that no longer exists after the filtered list shrinks (e.g., active 12, then a keystroke filters to 3 rows before re-clamp). Separately, the visual highlight uses `i === tzActiveIndex` (line 913) while `aria-selected` uses `tz === effectiveTimezoneInput` (line 919) — two different bases, so the highlighted row and the AT-announced row can disagree.
**Fix:** Derive a clamped active index in render and use it everywhere (visual + `aria-activedescendant`): `const activeIndex = Math.min(tzActiveIndex, Math.max(0, filteredZones.length - 1))`, or reset `tzActiveIndex` to 0 in a `useEffect` keyed on `tzSearch`.
### WR-07: Timezone combobox drops Tab-to-commit and relies on a fragile blur timeout
**File:** `apps/pwa/src/routes/AdminPage.tsx:838-865`, `927`
**Issue:** (1) `onKeyDown` handles ArrowUp/Down/Enter/Escape but not `Tab`. Tabbing out with the listbox open and an option highlighted moves focus to Save without committing — the input/`effectiveTimezoneInput` still holds the raw search text, so the admin can attempt to save a partial string (server 400s, but the UX is a confusing failure). (2) The `onBlur` 120ms `setTimeout` to let an option's `onClick` fire is a race; since options already `onMouseDown` `preventDefault()` (line 927), blur won't fire on option click, so the 120ms hack may be unnecessary. The `tzBlurTimer` is cleared on focus/select but not on unmount.
**Fix:** Commit the active option on `Tab` (without `preventDefault`, so focus still advances); clear `tzBlurTimer` in an unmount cleanup effect; reassess/remove the 120ms blur delay now that `onMouseDown` preventDefault is in place.
### WR-08: `pwa:icons` script is non-portable and silently coupled to generated filenames
**File:** `apps/pwa/package.json:16`
**Issue:** `pwa:icons` chains the assets generator with five `cp` commands. (1) `cp` is Unix-only — breaks on Windows contributors and minimal CI containers. (2) It hard-codes the generator's output names (`pwa-192x192.png`, `maskable-icon-512x512.png`, `apple-touch-icon-180x180.png`); a generator version bump that renames outputs breaks it with an opaque `cp: cannot stat`. (3) The manifest icon entries in `vite.config.ts:38-42` (`/icon-192.png`, etc.) only stay in sync because of these manual renames — an invisible coupling with no test. Regenerating icons without running the full script leaves the manifest referencing stale files.
**Fix:** Configure the generator to emit the final filenames directly, or replace the `cp` chain with a small cross-platform Node script (`fs.copyFileSync`). At minimum, add a comment in `vite.config.ts` by the icon entries pointing at the `pwa:icons` rename step so the coupling is discoverable.
Alternatively, stub `getBoundingClientRect`/`offsetParent` in the test setup so jsdom reports non-zero geometry — but the production-side guard above is the safer minimal change, since other future tests will hit the same wall. Either way, re-run `pnpm --filter @familysync/pwa test` and confirm both WR-07 cases pass before considering this resolved.
## Info
### IN-01: `OidcRedirect` navigates as a render-phase side effect
### IN-01: Focus-trap containment guard is unreachable as wired (harmless dead branch)
**File:** `apps/pwa/src/App.tsx:77-80`
**Issue:** `OidcRedirect` calls `window.location.replace('/api/login')` directly in the function body (render phase). React may render a component more than once (StrictMode double-invoke in dev, concurrent re-renders); side effects in render are an anti-pattern. It works because `replace` is idempotent and the page unloads, but it is fragile.
**Fix:** Move the navigation into `useEffect(() => { window.location.replace('/api/login'); }, [])` and render the placeholder.
**File:** `apps/pwa/src/hooks/useFocusTrap.ts:50-54`
**Issue:** The IN-01 fix added a containment guard:
```ts
if (!dialogRef.current.contains(document.activeElement)) {
e.preventDefault();
first.focus();
return;
}
```
The comment claims this catches the case where "focus has somehow landed outside the dialog ... Tab would walk background content." But the handler is only attached to each dialog container's `onKeyDown` (verified across all 5 sheets + EventForm + SeriesEditPrompt — no `document`-level listener exists). React's synthetic `onKeyDown` on the dialog div only fires when the keydown event's target is **inside** the dialog subtree (the event must bubble up through that div). When `document.activeElement` is genuinely outside the dialog, the keydown fires on that outside element and bubbles through `document`, **not** through the dialog div — so `handleDialogKeyDown` never runs, and `dialogRef.current.contains(document.activeElement)` is effectively always `true` whenever this code executes. The guard is therefore a no-op in practice: it does not deliver the containment guarantee its comment promises.
### IN-02: Inconsistent `exhaustive-deps` disables across sibling dialogs
This is not a correctness bug (it never produces wrong behavior), so it is INFO, not a blocker. But it is worth noting that the IN-01 concern (focus escaping a boundary-only trap) is **not actually addressed** by this change.
**File:** `apps/pwa/src/components/CredentialSheet.tsx:95`, `apps/pwa/src/components/SettingsSheet.tsx:605`
**Issue:** The Escape `useEffect` disables `react-hooks/exhaustive-deps` (because `handleClose` is referenced but not listed), while the `LinkOidcSheet`/`ResetPasswordSheet` versions list `[isOpen, onClose]` with no disable. The blanket disable also hides any future missing dep added to that effect.
**Fix:** Wrap `handleClose` in `useCallback` and add it to the dep array, removing the disable; make the pattern consistent across all sheets.
### IN-03: `isPhone`/`phone` 767px check duplicated across ~6 sites
**File:** `apps/pwa/src/App.tsx:64-66`, `CalendarShell.tsx:74-76`, `SettingsSheet.tsx:135`, `CredentialSheet.tsx:156`, `AdminPage.tsx:59`, `1378-1379`
**Issue:** The same breakpoint check is reimplemented in two spellings (`isPhone()` helper vs inline `matchMedia`), and the JS hard-codes `767` while `tokens.css` declares `--bp-tablet: 768px`. Drift risk if the breakpoint changes.
**Fix:** Extract one `useIsPhone()` hook (ideally the resize-aware one from WR-05) and import it everywhere; reference the breakpoint in a single place.
### IN-04: Dead placeholder brand tokens retained
**File:** `apps/pwa/src/styles/tokens.css:103-104,107`
**Issue:** `--brand-logo-bg`, `--brand-logo-text`, and `--brand-app-name` are leftovers from the Phase 19 "FS initials circle." BrandSlot now renders `logo.svg` and reads only `--brand-logo-size`/`--brand-logo-border-radius`; `--brand-app-name` is commented "drives doc only — not used as CSS content." These are dead declarations.
**Fix:** Remove them, or add a comment that they're retained intentionally for a planned fallback.
### IN-05: Admin members-panel JSX has inconsistent indentation / stacked bottom margins
**File:** `apps/pwa/src/routes/AdminPage.tsx:364-418`
**Issue:** Inside `admin-panel-members`, `<section aria-label="Members">` and its children are indented inconsistently (section at one level, children shallower), and both the Members and Local Accounts sections carry `marginBottom: var(--space-8)`, adding trailing space at the panel boundary. Cosmetic, but will trip future edits.
**Fix:** Reformat the panel JSX — Prettier should normalize it. Confirm `pnpm --filter @familysync/pwa lint`/format was run (a recurring pre-push gate on this project).
### IN-06: Toast and dialog `zIndex` overlap (300/301)
**File:** `apps/pwa/src/routes/AdminPage.tsx:1033` (toast 300) vs `CredentialSheet.tsx:169,189` (backdrop 300 / sheet 301), ResetPasswordSheet (300/301)
**Issue:** The toast shares `zIndex: 300` with the sheet backdrops. If a toast lingers while a sheet opens within the 3s window, paint order becomes DOM-order-dependent and the toast can render under the backdrop dim. Low likelihood, but the z-index scale is not cleanly layered.
**Fix:** Put the toast above dialogs (e.g. `zIndex: 400`) and document a named z-index scale (backdrop/sheet/toast) in `tokens.css`.
### IN-07: `Intl.DateTimeFormat()` recomputed every render in the calendar-config path
**File:** `apps/pwa/src/components/CalendarShell.tsx:159`, `apps/pwa/src/routes/AdminPage.tsx:146`
**Issue:** `Intl.DateTimeFormat().resolvedOptions().timeZone` is called inline in render. Cheap, but in `CalendarShell` it feeds `useCalendarApp` config, whose stability the file's own comments warn about. (Flagged as a note, not a perf-scope item, because it touches the calendar-app config the code explicitly tries to keep stable.)
**Fix:** `const displayTimeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, [])`.
**Fix:** If true containment is desired, move the trap to a `document`-level `keydown` (or `focusin`) listener mounted while the dialog is open, so it can intercept Tab/focus originating outside the dialog. If the boundary-only trap is considered sufficient (it is, for the present always-focus-the-heading-on-open flows), drop the unreachable containment branch and its comment to avoid implying a guarantee the code does not provide.
---
_Reviewed: 2026-06-18T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
_Re-review: IN-02 + IN-03 fixes confirmed clean; WR-01 fix regresses the focus-trap test suite (CR-01) and its IN-01 containment guard is inert. typecheck + lint pass; `pnpm test` FAILS (2 WR-07 tests)._
@@ -0,0 +1,92 @@
---
phase: 17
slug: ui-optimization-polish
status: verified
threats_open: 0
asvs_level: 1
created: 2026-06-18
---
# Phase 17 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
Phase 17 is a UI optimization & polish phase. Every plan carried a plan-time
`<threat_model>` block (`register_authored_at_plan_time: true`). The work is
client-side CSS/layout, static brand-asset wiring, and presentation-only React
state — no new endpoints, no new authorization logic, no new runtime data flow.
The single non-`accept` threat (logout wiring) reuses an endpoint already
verified live in Phase 19.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| Build tooling → repo (17-02) | `@vite-pwa/assets-generator` (+ sharp, sharp-ico) runs at design time and writes static images into `public/`. New devDependency = supply-chain surface. | Static image bytes; no secrets/PII |
| Client UI → existing logout endpoint (17-05) | Sign out control calls the already-implemented, Phase-19-verified `POST /api/auth/local/logout` via `fetchLocalLogout()`. No new endpoint, no new auth logic. | Session cookie (cleared server-side) |
| (none new) — 17-01, 17-03, 17-04, 17-06 | CSS-only restructure/offsets, static asset references, and presentation-only local `useState` (tab/toast). Server-side admin `403` enforcement unchanged. | None |
---
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| T-17-01-01 | Tampering | tokens.css selector restructure | accept | CSS custom properties carry no executable content and no user input; selector change cannot introduce injection. | closed |
| T-17-02-SC | Tampering | npm devDependency install (@vite-pwa/assets-generator, sharp, sharp-ico) | accept | RESEARCH Package Legitimacy Audit rates all three Approved (official vite-pwa, 13-yr sharp, sharp-ico); no `[SLOP]`/unverified packages. devDependencies only; generated output is static images. | closed |
| T-17-02-02 | Information disclosure | generated brand assets | accept | Assets are public-by-design brand images; no secrets or PII. | closed |
| T-17-03-01 | Tampering | FAB/content CSS offsets | accept | Pure layout geometry via existing CSS custom property; no executable content, no input. | closed |
| T-17-04-01 | Tampering | BrandSlot img / index.html links | accept | Logo img is decorative with empty `alt`; no `dangerouslySetInnerHTML` (T-05-24 invariant maintained); favicon/manifest entries point at committed static files. **Verified live:** BrandSlot renders `<img src="/logo.svg" alt="" aria-hidden="true">`, no `dangerouslySetInnerHTML` in source. | closed |
| T-17-05-01 | Elevation of Privilege | logout control (D-07) | mitigate | `fetchLocalLogout()` clears the local-session cookie via the existing Phase-19-verified endpoint; client navigates to `/login` regardless of success/failure so a stale-cookie-with-logged-out-UI state cannot persist. **Verified:** `SettingsSheet.tsx:143-151``try { await fetchLocalLogout(); } catch {} onClose(); void navigate('/login');`. | closed |
| T-17-05-02 | Tampering | sheet centering CSS (D-09) | accept | Position-only CSS branch; no input, no executable content. | closed |
| T-17-06-01 | Tampering | toast message content (D-08) | accept | Toast copy is hardcoded JSX string constants ("Member added." / "Password reset."); no user-controlled content; no `dangerouslySetInnerHTML`. **Verified live:** toast rendered "Member added." from a `role=status` node on member creation. | closed |
| T-17-06-02 | Elevation of Privilege | admin two-tab nav (D-10) | accept | Tab strip is presentation-only local `useState`; `isAdmin` nav visibility is UX-only — the real boundary is server-side `403` on `/api/admin/*` (unchanged). | closed |
*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-17-01 | T-17-01-01 | Static stylesheet selector restructure; zero runtime data flow. | Lucas Berger | 2026-06-18 |
| AR-17-02 | T-17-02-SC | All new devDependencies Approved by RESEARCH package-legitimacy audit; design-time only. | Lucas Berger | 2026-06-18 |
| AR-17-03 | T-17-02-02 | Brand assets are public-by-design; no secrets/PII. | Lucas Berger | 2026-06-18 |
| AR-17-04 | T-17-03-01 | Pure CSS layout geometry; no input surface. | Lucas Berger | 2026-06-18 |
| AR-17-05 | T-17-04-01 | Decorative img with empty alt; no `dangerouslySetInnerHTML`; committed static assets. | Lucas Berger | 2026-06-18 |
| AR-17-06 | T-17-05-02 | Position-only CSS branch; no input/executable content. | Lucas Berger | 2026-06-18 |
| AR-17-07 | T-17-06-01 | Hardcoded toast string constants; no user-controlled content. | Lucas Berger | 2026-06-18 |
| AR-17-08 | T-17-06-02 | Presentation-only tab state; authorization enforced server-side (unchanged). | Lucas Berger | 2026-06-18 |
*Accepted risks do not resurface in future audit runs.*
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-06-18 | 9 | 9 | 0 | /gsd-secure-phase (orchestrator, plan-time register verification) |
Verification method: all 6 plans carried plan-time `<threat_model>` blocks
(`register_authored_at_plan_time: true`). 8 `accept`-disposition threats are
documented accepted risks; the 1 `mitigate` threat (T-17-05-01) had its
mitigation verified present in `SettingsSheet.tsx`. Several dispositions were
additionally corroborated at runtime during the Phase 17 UAT (playwright-cli):
BrandSlot decorative img, hardcoded success toast, admin tab presentation-only
state. `threats_open: 0` — short-circuit per workflow Step 3.
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-06-18
@@ -0,0 +1,82 @@
---
status: complete
phase: 17-ui-optimization-polish
source:
- 17-01-SUMMARY.md
- 17-02-SUMMARY.md
- 17-03-SUMMARY.md
- 17-04-SUMMARY.md
- 17-05-SUMMARY.md
- 17-06-SUMMARY.md
verification_method: playwright-cli (Chromium, host Vite @5173, Docker API/DB)
started: 2026-06-18T18:26:00Z
updated: 2026-06-18T18:31:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Cold Start Smoke Test
expected: App boots and `/calendar` loads with live data — calendar grid, color legend, and primary controls render without console errors.
result: pass
evidence: PWA opened at http://localhost:5173/ → redirected to /calendar (dev-bypass). June 2026 grid rendered, color legend ("Dev User" #4A90D9, "Family" #F25C7A), New Event button + Today/nav present. 0 console errors. Docker API/MariaDB/Redis up.
### 2. Login Page Branding — FamilySync logo (Plan 17-04)
expected: Login page shows the approved family-house logo (BrandSlot), not the old "FS" text placeholder.
result: pass
evidence: /login renders `<img src="/logo.svg" alt="" aria-hidden="true">` at 48px. Old `aria-hidden` "FS" placeholder div is absent. `/logo.svg` serves 200 image/svg+xml.
### 3. Favicon & Theme Color (Plan 17-04)
expected: Browser tab favicon set (SVG + ICO + apple-touch) wired; warm-amber theme color applied.
result: pass
evidence: `<link rel=icon>` for /favicon.svg (image/svg+xml) + /favicon.ico + apple-touch-icon present. All of favicon.svg/favicon.ico/apple-touch-icon.png/icon-maskable-512.png fetch 200 with correct content-types. `<meta name=theme-color>` = #e8915a.
### 4. Phone Layout Overlap Fix (Plan 17-03 / D-01)
expected: At ≤767px, the New Event FAB sits above the fixed BottomTabBar (not occluded) and the color-legend chips remain fully visible.
result: pass
evidence: @390×844 — FAB bottom=764, BottomTabBar top=788 → FAB above bar with 24px gap (= --space-6). Color legend bottom=780 < nav top=788, visible:true, not occluded. Both chips ("Dev User", "Family") present. 0 console errors.
### 5. Sign Out Control (Plan 17-05 / D-07)
expected: Settings sheet exposes a reachable "Sign out" control.
result: pass
evidence: Settings dialog (opened from "Dev User — open settings") contains Account section, "Change password", and a "Sign out" button — all reachable.
### 6. Settings Sheet Centering (Plan 17-05 / D-09)
expected: On desktop, the settings sheet renders as a centered modal (not a bottom sheet).
result: pass
evidence: Settings dialog — position:fixed, width 480px, horizontal & vertical center offset = 0 on 1280×720, aria-modal="true".
### 7. Modal Focus Trap (code-review CR-01 / WR-01 fix)
expected: With a sheet open, Tab/Shift+Tab cycle focus within the dialog and never escape to background controls.
result: pass
evidence: Settings dialog (5 focusables). Tab from last ("Sign out") → wraps to "Close settings" (still inside). Shift+Tab from first → wraps to "Sign out" (still inside). Focus stayed contained both directions. Confirms the jsdom-tolerant visibility filter works correctly in a real (laid-out) browser — resolves the code-review human-verification flag.
### 8. Admin Two-Tab Navigation (Plan 17-06 / D-10 + WR-02)
expected: Admin page shows "Members & Accounts" / "Settings" tabs with full WAI-ARIA keyboard support (arrows wrap, Home/End).
result: pass
evidence: `role=tablist` with two `role=tab`s, "Members & Accounts" selected by default, tabpanels with regions. Keyboard: ArrowRight→Settings, ArrowRight wraps→Members, ArrowLeft→Settings, Home→Members, End→Settings. All transitions update aria-selected.
### 9. Admin Success Toast (Plan 17-06 / D-08)
expected: Creating a member shows a transient success toast announced to assistive tech.
result: pass
evidence: Filled + submitted the Add-member form (throwaway "ZZ Verify Toast"); a `role=status` aria-live="polite" toast read "Member added." Throwaway member removed from the dev DB afterward (verified 0 remaining).
### 10. iOS Standalone PWA — install + home-screen icon + push
expected: Installed-to-home-screen behavior and apple-touch/maskable icon appearance on a real iOS device.
result: skipped
reason: Genuinely device-only — cannot be driven by playwright-cli/Chromium (manifest is production-only and not injected in Vite dev). Icon assets and manifest config are verified at the asset/code level (Tests 23, code review). Real-device behavior remains a human checkpoint, already tracked in 17-VERIFICATION.md.
## Summary
total: 10
passed: 9
issues: 0
skipped: 1
pending: 0
## Gaps
[none — all automated checks passed; 1 device-only item deferred to existing human checkpoints]
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<!-- favicon.ico is hand-maintained (not emitted by pwa:icons); regenerate manually when the brand mark changes — see scripts/copy-pwa-icons.mjs (IN-02). -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="theme-color" content="#e8915a" />
+6
View File
@@ -9,6 +9,12 @@
* chain so Windows contributors and minimal CI containers can run `pwa:icons`.
*
* Keep COPIES in sync with the manifest `icons` array in vite.config.ts.
*
* NOTE (IN-02): public/favicon.ico is NOT produced by this script — the
* minimal2023Preset does not emit a .ico. It is hand-maintained and committed
* directly in public/, and referenced by index.html (`<link rel="icon"
* href="/favicon.ico">`). When the brand mark changes, regenerate favicon.ico
* manually so it does not go stale relative to the SVG/PNG outputs below.
*/
import { copyFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
+17 -1
View File
@@ -78,7 +78,23 @@ function OidcRedirect() {
useEffect(() => {
window.location.replace('/api/login');
}, []);
return <div aria-hidden="true" />;
// IN-03: render a perceivable status (not an empty aria-hidden div) so the
// transition is announced to screen-reader/keyboard users if the redirect is slow.
return (
<div
role="status"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
color: 'var(--color-text-muted, #6b7280)',
fontSize: '0.95rem',
}}
>
Redirecting to sign in
</div>
);
}
export default function App() {
+6 -1
View File
@@ -153,7 +153,12 @@ export function CalendarShell() {
// IANATimezone (the config's timezone type) is declared but not exported by @schedule-x/calendar,
// so derive it from useCalendarApp's config parameter rather than importing it.
type SxTimeZone = NonNullable<Parameters<typeof useCalendarApp>[0]['timezone']>;
const displayTimeZone: SxTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// IN-07: memoize so this stable value never changes identity across renders —
// it feeds the useCalendarApp config the file works hard to keep stable.
const displayTimeZone = useMemo<SxTimeZone>(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
[],
);
// useCalendarApp — config is stable; plugins passed as second argument
const calendar = useCalendarApp(
+27 -1
View File
@@ -26,13 +26,39 @@ export function useFocusTrap(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
).filter((el) => {
// Exclude disabled / explicitly-untabbable nodes …
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
// … and the `hidden` attribute, which is unambiguous regardless of layout.
if (el.hasAttribute('hidden')) return false;
// … and nodes that are not actually rendered/visible (WR-01). A focusable
// inside a hidden/collapsed block would otherwise become the computed
// first/last and `last.focus()` would no-op, leaking Tab to background
// content that `aria-modal="true"` promises is unreachable.
//
// Guard against a non-layout environment (jsdom): there, every node reports
// all-zero geometry and a null offsetParent, so applying the visibility
// heuristic unconditionally would reject *every* focusable and silently
// disable the trap. Only filter on visibility when there is positive
// evidence a layout engine is present; otherwise treat the node as visible.
const r = el.getBoundingClientRect();
const hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null;
if (!hasLayout) return true; // no layout engine → don't filter on visibility
if (el.offsetParent === null) return false;
return r.width > 0 && r.height > 0;
});
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
// Boundary-only trap (IN-01): this handler is wired to the dialog's own
// onKeyDown, so it only runs while focus is already inside the dialog
// subtree — a `document`-level containment guard would be required to pull
// back focus that originates outside, and is unnecessary for the current
// always-focus-the-heading-on-open flows. We intentionally do not claim a
// containment guarantee the wiring cannot provide.
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === first) {
+265 -274
View File
@@ -23,7 +23,7 @@
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
*/
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import {
@@ -157,8 +157,9 @@ export function AdminPage() {
},
});
// Detected browser timezone (D-02)
const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Detected browser timezone (D-02). IN-07: memoize — the resolved zone is
// stable for the session, no need to recompute every render.
const detectedTz = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);
// Effective timezone input value: local override → stored value → ''
const storedTimezone = timezoneQuery.data?.timezone ?? '';
@@ -243,9 +244,7 @@ export function AdminPage() {
e.preventDefault();
setActiveTab(next);
(
e.currentTarget.parentElement?.querySelector(
`[id="admin-tab-${next}"]`,
) as HTMLElement | null
e.currentTarget.parentElement?.querySelector(`[id="admin-tab-${next}"]`) as HTMLElement | null
)?.focus();
}
@@ -369,13 +368,9 @@ export function AdminPage() {
fontSize: 'var(--text-label-size, 13px)',
fontWeight: activeTab === id ? 600 : 400,
color:
activeTab === id
? 'var(--color-text-primary)'
: 'var(--color-text-secondary)',
activeTab === id ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
borderBottom:
activeTab === id
? '2px solid var(--color-member-0)'
: '2px solid transparent',
activeTab === id ? '2px solid var(--color-member-0)' : '2px solid transparent',
transition: 'color 0.1s ease, border-color 0.1s ease',
fontFamily: 'var(--font-family-base)',
}}
@@ -393,281 +388,280 @@ export function AdminPage() {
tabIndex={0}
hidden={activeTab !== 'members'}
>
{/* ── MEMBERS section ───────────────────────────────────────────── */}
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
<div style={sectionLabelStyle}>Members</div>
<div style={sectionLabelStyle}>Members</div>
{membersQuery.isLoading && (
<div
style={{
padding: 'var(--space-4, 16px) 0',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Loading members
</div>
)}
{membersQuery.isLoading && (
<div
style={{
padding: 'var(--space-4, 16px) 0',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Loading members
</div>
)}
{membersQuery.isError && (
<div
style={{
color: 'var(--color-destructive)',
fontSize: 'var(--text-body-size, 15px)',
padding: 'var(--space-4, 16px) 0',
}}
>
Could not load members.
</div>
)}
{membersQuery.isError && (
<div
style={{
color: 'var(--color-destructive)',
fontSize: 'var(--text-body-size, 15px)',
padding: 'var(--space-4, 16px) 0',
}}
>
Could not load members.
</div>
)}
{membersQuery.data && (
<div>
{membersQuery.data.members.map((member, idx) => (
<MemberRow
key={member.id}
member={member}
colorIndex={idx}
onAction={(buttonRef) => openSheet(member, buttonRef)}
onResetPassword={(buttonRef) => {
// Capture trigger button so focus can return on close
resetTriggerRef.current = buttonRef.current;
setResetTargetMember(member);
setResetSheetOpen(true);
}}
/>
))}
</div>
)}
</section>
{membersQuery.data && (
<div>
{membersQuery.data.members.map((member, idx) => (
<MemberRow
key={member.id}
member={member}
colorIndex={idx}
onAction={(buttonRef) => openSheet(member, buttonRef)}
onResetPassword={(buttonRef) => {
// Capture trigger button so focus can return on close
resetTriggerRef.current = buttonRef.current;
setResetTargetMember(member);
setResetSheetOpen(true);
}}
/>
))}
</div>
)}
</section>
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
<div style={sectionLabelStyle}>Local Accounts</div>
{/* Surface 11A — Add member inline form */}
<div
style={{
border: '1px solid var(--color-border-subtle, var(--color-border))',
borderRadius: '8px',
padding: 'var(--space-4, 16px)',
marginBottom: 'var(--space-6, 24px)',
}}
>
{/* Surface 11A — Add member inline form */}
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-4, 16px)',
border: '1px solid var(--color-border-subtle, var(--color-border))',
borderRadius: '8px',
padding: 'var(--space-4, 16px)',
marginBottom: 'var(--space-6, 24px)',
}}
>
Add member
</div>
{/* Display name */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-display-name"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Display name
</label>
<input
id="admin-create-display-name"
type="text"
value={createDisplayName}
onChange={(e) => setCreateDisplayName(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Username */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-username"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Username
</label>
<input
id="admin-create-username"
type="text"
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Initial password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Initial password
</label>
<input
id="admin-create-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Confirm password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="admin-create-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm password
</label>
<input
id="admin-create-confirm-password"
type="password"
autoComplete="new-password"
value={createConfirmPassword}
onChange={(e) => setCreateConfirmPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Inline error */}
{createError && (
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive)',
marginBottom: 'var(--space-3, 12px)',
}}
>
{createError}
</div>
)}
{/* Action row */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={createSubmitDisabled}
onClick={() => {
setCreateError(null);
createMemberMutation.mutate();
}}
style={{
background: createSubmitDisabled
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: createSubmitDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-4, 16px)',
}}
>
{createMemberMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Add member
</button>
</div>
</div>
</section>
</div>
</div>{/* end admin-panel-members */}
{/* Display name */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-display-name"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Display name
</label>
<input
id="admin-create-display-name"
type="text"
value={createDisplayName}
onChange={(e) => setCreateDisplayName(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Username */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-username"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Username
</label>
<input
id="admin-create-username"
type="text"
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Initial password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Initial password
</label>
<input
id="admin-create-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Confirm password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="admin-create-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm password
</label>
<input
id="admin-create-confirm-password"
type="password"
autoComplete="new-password"
value={createConfirmPassword}
onChange={(e) => setCreateConfirmPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
minHeight: '44px',
}}
/>
</div>
{/* Inline error */}
{createError && (
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive)',
marginBottom: 'var(--space-3, 12px)',
}}
>
{createError}
</div>
)}
{/* Action row */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={createSubmitDisabled}
onClick={() => {
setCreateError(null);
createMemberMutation.mutate();
}}
style={{
background: createSubmitDisabled
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: createSubmitDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
}}
>
{createMemberMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Add member
</button>
</div>
</div>
</section>
</div>
{/* end admin-panel-members */}
{/* ── Tab panel: Settings ───────────────────────────────────────────── */}
<div
@@ -677,7 +671,6 @@ export function AdminPage() {
tabIndex={0}
hidden={activeTab !== 'settings'}
>
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
<section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
<div style={sectionLabelStyle}>Shared Calendar</div>
@@ -983,9 +976,7 @@ export function AdminPage() {
color: 'var(--color-text-primary)',
borderRadius: 'var(--space-1, 4px)',
cursor: 'pointer',
background: active
? 'var(--color-member-0, #4A90D9)'
: 'transparent',
background: active ? 'var(--color-member-0, #4A90D9)' : 'transparent',
...(active ? { color: '#ffffff' } : null),
minHeight: '44px',
display: 'flex',
@@ -1068,10 +1059,10 @@ export function AdminPage() {
</>
)}
</section>
</div>{/* end admin-panel-settings */}
</div>{/* end centered content column */}
</div>
{/* end admin-panel-settings */}
</div>
{/* end centered content column */}
{/* ── Success toast (D-08) ──────────────────────────────────────────────── */}
{toast && (