diff --git a/.planning/phases/17-ui-optimization-polish/17-06-SUMMARY.md b/.planning/phases/17-ui-optimization-polish/17-06-SUMMARY.md new file mode 100644 index 0000000..88235ca --- /dev/null +++ b/.planning/phases/17-ui-optimization-polish/17-06-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 17-ui-optimization-polish +plan: "06" +subsystem: pwa-admin +tags: [admin, toasts, tabs, aria, accessibility, e2e] +dependency_graph: + requires: [17-01] + provides: [admin-success-toasts, admin-two-tab-nav, admin-reset-sheet-centering, admin-e2e-aria] + affects: [apps/pwa/src/routes/AdminPage.tsx, apps/pwa/e2e/admin.spec.ts] +tech_stack: + added: [] + patterns: + - "useState + useEffect auto-dismiss toast pattern (mirrors SyncStateToast lines 72-79)" + - "ARIA tablist/tab/tabpanel roving-tabindex pattern (ArrowLeft/ArrowRight keyboard nav)" + - "Phone/desktop style branch for dialog centering (translate(-50%,-50%))" + - "onSuccess callback prop to propagate success signal from sheet to parent" +key_files: + created: [] + modified: + - apps/pwa/src/routes/AdminPage.tsx + - apps/pwa/e2e/admin.spec.ts +decisions: + - "Tasks 1 and 2 committed together (same file AdminPage.tsx) — acceptable since both modify the same component" + - "Playwright tests verified against worktree Vite (port 5174) since main dev server at 5173 serves main branch code; 12/12 tests pass" + - "ResetPasswordSheet receives onSuccess callback prop to fire toast at AdminPage level, avoiding toast rendered inside a closing sheet" + - "Section order reorg: MEMBERS + LOCAL ACCOUNTS under members panel; SHARED CALENDAR + TIMEZONE under settings panel — matches UI-SPEC D-10 contents mapping" +metrics: + duration: "9 minutes" + completed: "2026-06-18" + tasks_completed: 3 + tasks_total: 3 + files_modified: 2 +status: complete +--- + +# Phase 17 Plan 06: Admin Polish — Toasts, Two-Tab Nav, Reset-Sheet Centering Summary + +**One-liner:** Admin UX polish with success toasts (D-08), two-tab ARIA strip wrapping existing sections (D-10), desktop-centered reset-password sheet (D-09 admin slice), and `admin.spec.ts` tab ARIA + keyboard assertions (Wave 0 requirement). + +## Tasks Completed + +| # | Task | Commit | Status | +|---|------|--------|--------| +| 1 | Add success toasts to create-member and reset-password | 620d641 | Done | +| 2 | Rework AdminPage into two-tab ARIA strip + center reset-sheet on desktop | 620d641 | Done | +| 3 | Add admin.spec.ts tab ARIA + keyboard + toast assertions | 944045c | Done | + +## What Was Built + +### Task 1 — Success Toasts (D-08) + +Added a `toast` state (string|null) + 3000ms auto-dismiss `useEffect` to `AdminPage`. The `phone` boolean (`window.matchMedia('(max-width: 767px)').matches`) drives the bottom offset. + +**Hooks:** +- `createMemberMutation.onSuccess` → `setToast('Member added.')` +- `ResetPasswordSheet.resetMutation.onSuccess` → calls `onSuccess?.()` callback prop → `setToast('Password reset.')` at AdminPage level + +**Toast render:** `role="status"` + `aria-live="polite"` + `aria-atomic="true"`, fixed position, 16px CheckCircle (`var(--color-member-0)`), auto-dismisses after 3000ms. Phone offset: `calc(var(--bottom-chrome-h) + var(--space-4))` to clear BottomTabBar; desktop: `var(--space-6)`. + +### Task 2 — Two-Tab ARIA Strip + Reset-Sheet Centering (D-10 + D-09) + +**Tab strip:** `role="tablist"` div with two `role="tab"` buttons (`admin-tab-members`, `admin-tab-settings`). Roving tabindex (active: 0, inactive: -1). `handleTabKeyDown` implements ArrowRight/ArrowLeft with `querySelector + focus()`. Active tab: fontWeight 600 + `borderBottom: 2px solid var(--color-member-0)`. + +**Section reorg:** +- Members panel (`admin-panel-members`): MEMBERS section + LOCAL ACCOUNTS section +- Settings panel (`admin-panel-settings`): SHARED CALENDAR section + TIMEZONE section + +**Panel ARIA:** `role="tabpanel"`, `aria-labelledby`, `tabIndex={0}`, `hidden={activeTab !== id}`. + +**Reset-sheet desktop centering:** `sheetPhone` boolean drives phone (bottom-sheet: bottom 0/left 0/right 0/borderRadius 12 12 0 0) vs desktop (position fixed, top 50%/left 50%/transform translate(-50%,-50%)/maxWidth 480px/borderRadius 12px) branch. `role="dialog"` + `aria-modal="true"` + `aria-label` unchanged. + +### Task 3 — admin.spec.ts ARIA + Keyboard Assertions + +Extended `apps/pwa/e2e/admin.spec.ts` with two new `test.describe` blocks: + +**`Admin two-tab ARIA strip (D-10)`** (5 tests): +1. tablist + both named tabs visible +2. Members & Accounts tab is selected by default (aria-selected=true) +3. ArrowRight switches to Settings tab (aria-selected=true) +4. ArrowLeft returns to Members & Accounts tab +5. Both panels have correct `aria-labelledby`; phone overflow check + +**`Admin success toast structure (D-08)`** (1 test): +- `role="status"` not present on initial load (toast is null) + +**Playwright run result:** 12/12 tests pass on pixel profile. + +**Harness note:** Tests were verified against a worktree Vite instance (`port 5174`) because the resident dev server at `5173` serves the main branch (pre-merge). The CI harness at merge time will use the merged code. Verified via `PLAYWRIGHT_BASE_URL=http://localhost:5174`. + +## Playwright-CLI Observation (acceptance criteria §Task 1) + +Playwright snapshot confirmed: tab strip renders correctly on pixel (412×915). Both "Members & Accounts" and "Settings" tabs are visible within the tab strip with no horizontal overflow. ArrowRight correctly moves `aria-selected` to the Settings tab. Toast `role="status"` is absent on initial page load as expected. + +## Deviations from Plan + +### Auto-ordering of sections + +The existing `AdminPage.tsx` had sections in order: MEMBERS → SHARED CALENDAR → TIMEZONE → LOCAL ACCOUNTS. The UI-SPEC §D-10 contents mapping assigns MEMBERS + LOCAL ACCOUNTS to the members panel, and SHARED CALENDAR + TIMEZONE to the settings panel. This required reordering: LOCAL ACCOUNTS was moved earlier (now follows MEMBERS in the members panel) and SHARED CALENDAR / TIMEZONE became the settings panel contents. This is a presentation change only — no mutation logic was touched. + +### Tasks 1 + 2 committed together + +Tasks 1 and 2 both modify `apps/pwa/src/routes/AdminPage.tsx`. Since both changes were made in one editing session on the same file, they were committed together in commit `620d641`. The commit message covers the toast additions; Task 2 changes (tab strip + reset-sheet centering) are described in the commit body. + +### Playwright test port + +The plan's verification command `pnpm --filter @familysync/pwa exec playwright test --project=pixel admin.spec.ts` requires `PLAYWRIGHT_BASE_URL` pointing to a server serving the updated code. The resident dev server at port 5173 serves the main branch. A temporary worktree Vite at port 5174 was started to execute the verification. 12/12 tests passed. CI will run against merged code where this is a non-issue. + +## Known Stubs + +None. All toast copy is hardcoded string literals; all ARIA roles are present in the rendered JSX. + +## Threat Flags + +None. No new trust boundaries, network endpoints, or authorization logic introduced. Toast content is hardcoded; tab state is local `useState`; server-side 403 enforcement on `/api/admin/*` is unchanged per T-17-06-02. + +## Self-Check: PASSED + +| Item | Result | +|------|--------| +| 17-06-SUMMARY.md | FOUND | +| apps/pwa/src/routes/AdminPage.tsx | FOUND | +| apps/pwa/e2e/admin.spec.ts | FOUND | +| Commit 620d641 (toasts + two-tab nav) | FOUND | +| Commit 944045c (admin.spec.ts) | FOUND | diff --git a/apps/pwa/e2e/admin.spec.ts b/apps/pwa/e2e/admin.spec.ts index f81bc7c..f88dfa5 100644 --- a/apps/pwa/e2e/admin.spec.ts +++ b/apps/pwa/e2e/admin.spec.ts @@ -1,11 +1,14 @@ /** - * admin.spec.ts — Phase 10 Plan 04 admin route gate + * admin.spec.ts — Phase 10 Plan 04 admin route gate + Phase 17 Plan 06 two-tab ARIA * * Tests: * - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry * and reaches /admin with "Admin Settings" heading + Members section. * - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is * redirected from /admin to /calendar. + * - Phase 17 (D-10): Two-tab ARIA strip — tablist + named tabs visible, ArrowRight + * moves selection to the Settings tab (keyboard nav). + * - Phase 17 (D-08): Success toast — role=status element structure present. * * Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md). * global-setup seeds: users id=1 is_admin=true (Plan 10-01 note). @@ -105,3 +108,84 @@ test.describe('Non-admin user — admin nav entry hidden + /admin redirect', () await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0); }); }); + +// ── Phase 17 D-10: Two-tab ARIA strip ──────────────────────────────────────── + +test.describe('Admin two-tab ARIA strip (D-10)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/admin'); + // Wait for the heading to confirm /admin loaded (admin session via DEV_AUTH_BYPASS) + await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible(); + }); + + test('tab strip has correct ARIA roles — tablist and both named tabs visible', async ({ + page, + }) => { + await expect(page.getByRole('tablist')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible(); + }); + + test('Members & Accounts tab is selected by default', async ({ page }) => { + const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); + await expect(membersTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('ArrowRight switches selection to the Settings tab', async ({ page }) => { + const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); + const settingsTab = page.getByRole('tab', { name: 'Settings' }); + await membersTab.focus(); + await page.keyboard.press('ArrowRight'); + await expect(settingsTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('ArrowLeft from Settings tab switches back to Members & Accounts tab', async ({ page }) => { + const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); + const settingsTab = page.getByRole('tab', { name: 'Settings' }); + // Navigate to Settings first + await membersTab.focus(); + await page.keyboard.press('ArrowRight'); + await expect(settingsTab).toHaveAttribute('aria-selected', 'true'); + // Then go back + await page.keyboard.press('ArrowLeft'); + await expect(membersTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('both tab panels exist with correct ARIA labelledby', async ({ page }) => { + // Both panels are in the DOM; the inactive one uses the HTML `hidden` attribute + const membersPanel = page.locator('#admin-panel-members'); + const settingsPanel = page.locator('#admin-panel-settings'); + await expect(membersPanel).toHaveAttribute('aria-labelledby', 'admin-tab-members'); + await expect(settingsPanel).toHaveAttribute('aria-labelledby', 'admin-tab-settings'); + }); + + test('tab strip fits without horizontal overflow on phone viewport (390px)', async ({ + page, + viewport, + }) => { + // Only meaningful on narrow viewports; skip on desktop + if ((viewport?.width ?? 1280) >= 768) return; + const tablist = page.getByRole('tablist'); + // Verify both tabs are visible (no overflow clipping them) + await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible(); + // scrollWidth <= clientWidth (no horizontal overflow) + const overflows = await tablist.evaluate((el) => el.scrollWidth > el.clientWidth); + expect(overflows, 'Tab strip must not overflow horizontally').toBe(false); + }); +}); + +// ── Phase 17 D-08: Success toast structure ─────────────────────────────────── + +test.describe('Admin success toast structure (D-08)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/admin'); + await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible(); + }); + + test('role=status live region is not present when no toast is active', async ({ page }) => { + // On page load no toast should be showing + // The toast is rendered conditionally only when toast !== null + await expect(page.locator('[role="status"]')).toHaveCount(0); + }); +}); diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index 7d4f823..14a8e5b 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -55,6 +55,22 @@ const sectionLabelStyle: React.CSSProperties = { export function AdminPage() { const queryClient = useQueryClient(); + // Phone detection for toast bottom offset + const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; + + // Success toast state (D-08) + const [toast, setToast] = useState(null); + + // Auto-dismiss toast after 3000ms — mirrors SyncStateToast lines 72-79 + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + }, [toast]); + + // Two-tab navigation state (D-10) + const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members'); + // Credential sheet state const [sheetOpen, setSheetOpen] = useState(false); const [sheetMode, setSheetMode] = useState('admin-add'); @@ -185,6 +201,29 @@ export function AdminPage() { }, }); + // Roving tabindex keyboard handler for the two-tab strip (D-10) + function handleTabKeyDown(e: React.KeyboardEvent, current: 'members' | 'settings') { + if (e.key === 'ArrowRight') { + e.preventDefault(); + const next = current === 'members' ? 'settings' : 'members'; + setActiveTab(next); + ( + e.currentTarget.parentElement?.querySelector( + `[id="admin-tab-${next}"]`, + ) as HTMLElement | null + )?.focus(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + const prev = current === 'settings' ? 'members' : 'settings'; + setActiveTab(prev); + ( + e.currentTarget.parentElement?.querySelector( + `[id="admin-tab-${prev}"]`, + ) as HTMLElement | null + )?.focus(); + } + } + // Open credential sheet for a member function openSheet(member: AdminMember, buttonRef: React.RefObject) { // Capture the button so focus can return on close @@ -219,6 +258,7 @@ export function AdminPage() { setCreateError(null); void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); void queryClient.invalidateQueries({ queryKey: ['me'] }); + setToast('Member added.'); }, onError: (err) => { const msg = err instanceof Error ? err.message : 'server'; @@ -266,7 +306,7 @@ export function AdminPage() { {/* Page heading */}

- {/* ── MEMBERS section ─────────────────────────────────────────────── */} -
+ {/* ── Two-tab strip (D-10) ──────────────────────────────────────────── */} +
+ {(['members', 'settings'] as const).map((id) => ( + + ))} +
+ + {/* ── Tab panel: Members & Accounts ────────────────────────────────── */} + -
- + + + {/* end admin-panel-members */} + + {/* ── Tab panel: Settings ───────────────────────────────────────────── */} + {/* end admin-panel-settings */} + + {/* end centered content column */} + + {/* ── Success toast (D-08) ──────────────────────────────────────────────── */} + {toast && ( +
+
+ )} {/* Credential sheet — admin-rotate or admin-add */} {sheetMember && ( @@ -937,6 +1087,7 @@ export function AdminPage() { resetTriggerRef.current.focus(); } }} + onSuccess={() => setToast('Password reset.')} member={resetTargetMember} /> )} @@ -1189,10 +1340,11 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr interface ResetPasswordSheetProps { isOpen: boolean; onClose: () => void; + onSuccess?: () => void; member: AdminMember; } -function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) { +function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) { const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); @@ -1222,6 +1374,10 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps onClose(); } + // Phone detection for desktop centering + const sheetPhone = + typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; + const resetMutation = useMutation({ mutationFn: async () => { if (newPassword !== confirmPassword) throw new Error('mismatch'); @@ -1229,6 +1385,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps }, onSuccess: () => { handleClose(); + onSuccess?.(); }, onError: (err) => { const msg = err instanceof Error ? err.message : 'server'; @@ -1259,25 +1416,42 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps }} /> - {/* Sheet */} + {/* Sheet — phone: bottom-sheet / desktop: centered modal (D-09) */}