--- phase: 17-ui-optimization-polish plan: 06 type: execute wave: 2 depends_on: ["17-01"] files_modified: - apps/pwa/src/routes/AdminPage.tsx - apps/pwa/e2e/admin.spec.ts autonomous: true requirements: [D-08, D-09, D-10] must_haves: truths: - "Admin create-member shows a 'Member added.' toast and reset-password shows a 'Password reset.' toast, each auto-dismissing after ~3s" - "AdminPage uses a two-tab strip ('Members & Accounts' / 'Settings') with full ARIA tabs + roving tabindex + ArrowLeft/Right keyboard nav" - "The admin reset-password sheet renders centered on desktop and as a bottom-sheet on phone" - "A CI assertion verifies the admin tab ARIA roles and keyboard switching" artifacts: - path: "apps/pwa/src/routes/AdminPage.tsx" provides: "Success toasts + two-tab ARIA nav + reset-sheet desktop centering" contains: "role=\"tablist\"" - path: "apps/pwa/e2e/admin.spec.ts" provides: "Admin tab ARIA + keyboard + toast assertions" contains: "Members & Accounts" key_links: - from: "apps/pwa/src/routes/AdminPage.tsx" to: "apps/pwa/src/components/SyncStateToast.tsx" via: "reuse the SyncStateToast visual pattern + auto-dismiss useEffect for admin success toasts" pattern: "role=\"status\"" --- Polish the admin surface (D-08, D-10, plus the admin slice of D-09): add success toasts to the create-member and reset-password flows, rework the clunky single-scroll admin layout into a two-tab strip with a full ARIA tabs pattern, and center the admin reset-password sheet on desktop. Add a new `admin.spec.ts` with the tab ARIA + keyboard assertions (Wave 0 requirement). D-10 is the largest single item in the phase, so it gets its own plan together with the AdminPage-local D-08 toasts and the AdminPage-owned reset-sheet centering (keeping AdminPage.tsx single-owner avoids a same-file conflict with plan 17-05). Purpose: the operator reported create-member and reset-password succeed silently (no confirmation) and the admin navigation reads as clunky; this plan fixes both and adds the missing admin reset-sheet centering. Output: toast state + render in AdminPage, a two-tab ARIA strip wrapping the existing sections, the reset-sheet phone/desktop branch, and admin.spec.ts. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/17-ui-optimization-polish/17-UI-SPEC.md @.planning/phases/17-ui-optimization-polish/17-PATTERNS.md @.planning/phases/17-ui-optimization-polish/17-RESEARCH.md @.planning/phases/17-ui-optimization-polish/17-01-SUMMARY.md Task 1: Add success toasts to admin create-member and reset-password flows apps/pwa/src/routes/AdminPage.tsx - apps/pwa/src/routes/AdminPage.tsx (the createMemberMutation onSuccess at ~lines 213-221; the resetMutation onSuccess at ~lines 1230-1232 inside the ResetPasswordSheet; the existing CheckCircle import at line 28) - apps/pwa/src/components/SyncStateToast.tsx lines 72-79 (auto-dismiss useEffect pattern) and lines 159-189 (the toast wrapper render with role=status) - 17-UI-SPEC.md section "Workstream D" -> "D-08 — Admin success feedback" (toast style, position, 3s auto-dismiss, role=status, aria-live polite) + "Copywriting Contract" (copy: "Member added." / "Password reset.") - 17-PATTERNS.md section "apps/pwa/src/routes/AdminPage.tsx" -> the toast state pattern, auto-dismiss useEffect, and the SyncStateToast-derived render excerpt (note the phone-aware bottom offset uses var(--bottom-chrome-h)) - 17-01-SUMMARY.md (confirms --bottom-chrome-h exists for the phone toast offset) Add a local toast state to AdminPage (a string-or-null message + a setter) and an auto-dismiss useEffect that clears it after 3000ms (mirror SyncStateToast's pattern). Add a phone boolean (window.matchMedia('(max-width: 767px)').matches) for the toast bottom offset. Hook the toast into the existing mutations: in createMemberMutation onSuccess, after the form-reset logic, set the toast to "Member added."; in the reset-password flow onSuccess, set the toast to "Password reset." (the reset success fires inside ResetPasswordSheet — propagate the message up to AdminPage's toast state via a callback prop or a shared setter so the toast renders at the AdminPage level, not inside the closing sheet). Render the toast at the AdminPage level using the SyncStateToast visual pattern: position fixed; bottom "calc(var(--bottom-chrome-h) + var(--space-4))" on phone, "var(--space-6)" on desktop; left 50%, transform translateX(-50%); zIndex 300; background var(--color-surface-raised); 1px var(--color-border) border; borderRadius var(--space-2); the documented boxShadow/padding; a 16px CheckCircle (var(--color-member-0)) + the message text (Label 13px). Accessibility: role status, aria-live polite, aria-atomic true. Only one toast at a time (a second action replaces the message). grep -q 'Member added' apps/pwa/src/routes/AdminPage.tsx && grep -q 'Password reset' apps/pwa/src/routes/AdminPage.tsx && grep -q 'role="status"' apps/pwa/src/routes/AdminPage.tsx && grep -q 'var(--bottom-chrome-h)' apps/pwa/src/routes/AdminPage.tsx && pnpm --filter @familysync/pwa build - createMemberMutation onSuccess sets the toast to "Member added."; reset-password onSuccess sets it to "Password reset." - The toast renders with role status + aria-live polite, a CheckCircle icon, and auto-dismisses after ~3000ms. - The phone toast bottom offset uses calc(var(--bottom-chrome-h) + var(--space-4)) so it clears the BottomTabBar. - playwright-cli (admin session): create a member -> "Member added." appears then disappears after ~3.5s; reset a password -> "Password reset." appears. Observations noted in SUMMARY. - pnpm --filter @familysync/pwa build exits 0. Both admin success flows show an accessible, auto-dismissing toast with the correct copy, positioned to clear the BottomTabBar on phone. Task 2: Rework AdminPage into a two-tab ARIA strip and center the reset-password sheet on desktop apps/pwa/src/routes/AdminPage.tsx - apps/pwa/src/routes/AdminPage.tsx (the h1 "Admin Settings"; the MEMBERS, LOCAL ACCOUNTS, SHARED CALENDAR, and TIMEZONE sections; the existing sectionLabelStyle at ~lines 44-51; the ResetPasswordSheet outer role=dialog wrapper at ~line 1264) - 17-UI-SPEC.md section "Workstream D" -> "D-10 — Admin two-tab navigation" (tab labels, contents mapping, tab strip + button visual contract, full ARIA pattern, tab IDs, default tab) + "D-09 — Dialog/sheet centering fix" (the admin reset-password sheet is one of the listed surfaces) - 17-PATTERNS.md section "apps/pwa/src/routes/AdminPage.tsx" -> the activeTab state, handleTabKeyDown roving-tabindex excerpt, the role=tablist/tab/tabpanel render, and the hidden-panel pattern - 17-RESEARCH.md section "Architecture Patterns" -> Pattern 3 (Admin Tabs ARIA Pattern) Add an activeTab state ('members' | 'settings', default 'members') and a handleTabKeyDown roving-tabindex handler (ArrowRight -> next tab + focus it; ArrowLeft -> previous tab + focus it; preventDefault). Render a tab strip directly below the h1 "Admin Settings": a container with role tablist (display flex; borderBottom 1px var(--color-border-subtle); marginBottom var(--space-6)), containing two buttons with role tab. For each tab: id "admin-tab-{id}", aria-selected (active), aria-controls "admin-panel-{id}", tabIndex 0 when active else -1, onClick setActiveTab, onKeyDown handleTabKeyDown. Visual: minHeight 44px, padding var(--space-3) var(--space-4), fontSize var(--text-label-size); inactive fontWeight 400 / color var(--color-text-secondary) / borderBottom 2px solid transparent; active fontWeight 600 / color var(--color-text-primary) / borderBottom 2px solid var(--color-member-0). Labels: "Members & Accounts" (members) and "Settings" (settings). Wrap the existing sections into two tab panels: panel "admin-panel-members" (role tabpanel, aria-labelledby admin-tab-members, tabIndex 0, hidden when activeTab != members) containing the MEMBERS + LOCAL ACCOUNTS sections; panel "admin-panel-settings" (role tabpanel, aria-labelledby admin-tab-settings, tabIndex 0, hidden when activeTab != settings) containing the SHARED CALENDAR + TIMEZONE sections. Reuse sectionLabelStyle unchanged inside the panels. Tab state is local useState only (not URL-persisted) — intentional per UI-SPEC. Separately (D-09 admin slice): add the same phone/desktop centering branch used in plan 17-05 to the ResetPasswordSheet outer role=dialog wrapper — phone keeps bottom 0/left 0/right 0 + borderRadius 12px 12px 0 0; desktop uses position fixed, top 50%, left 50%, transform translate(-50%, -50%), maxWidth 480px, width calc(100% - var(--space-8)), maxHeight calc(100dvh - var(--space-8)), overflowY auto, borderRadius 12px, the deeper boxShadow. role=dialog/aria-modal/aria-label unchanged. grep -q 'role="tablist"' apps/pwa/src/routes/AdminPage.tsx && grep -q 'Members & Accounts' apps/pwa/src/routes/AdminPage.tsx && grep -q 'admin-panel-members' apps/pwa/src/routes/AdminPage.tsx && grep -q 'translate(-50%, -50%)' apps/pwa/src/routes/AdminPage.tsx && pnpm --filter @familysync/pwa build - AdminPage renders a role=tablist with two role=tab buttons labeled "Members & Accounts" and "Settings", roving tabindex (active 0 / inactive -1), and ArrowLeft/ArrowRight keyboard switching. - Two role=tabpanel panels (admin-panel-members, admin-panel-settings) wrap the existing sections per the contents mapping; default active tab is "members". - The ResetPasswordSheet dialog wrapper has the phone/desktop branch (desktop centered via translate(-50%, -50%); phone bottom-sheet); role=dialog/aria-modal/aria-label unchanged. - playwright-cli @390x844: both tabs fit with no horizontal overflow on the strip. Observation noted in SUMMARY. - pnpm --filter @familysync/pwa build exits 0. AdminPage uses an accessible two-tab strip wrapping the existing sections, the reset-password sheet centers on desktop, and the build passes. Task 3: Add admin.spec.ts with tab ARIA + keyboard + toast assertions apps/pwa/e2e/admin.spec.ts - apps/pwa/e2e/layout.spec.ts lines 1-27 (file header/import pattern + test.describe structure) and the page.goto pattern — admin.spec.ts copies this shape - apps/pwa/playwright.config.ts (profiles; note an admin session may require storageState — follow the existing test's auth/seed approach) - 17-UI-SPEC.md section "Workstream D" -> "D-10" ARIA contract (roles + keyboard) + "D-08" toast role=status - 17-PATTERNS.md section "apps/pwa/e2e/admin.spec.ts (new file)" -> the file header + test.describe + getByRole assertion + ArrowRight test excerpt - 17-VALIDATION.md section "Wave 0 Requirements" (admin tab ARIA assertion is a Wave 0 item) + the D-10/D-08 verification-map rows Create apps/pwa/e2e/admin.spec.ts following layout.spec.ts's header/import/test.describe conventions (import { test, expect } from '@playwright/test'). Navigate to /admin (using whatever admin session/storageState the existing e2e setup provides; if admin auth is not yet wired into the e2e harness, gate the navigation behind the project's dev-bypass admin path and note any harness limitation in the SUMMARY rather than leaving the file unable to run). Add assertions: (1) tab strip ARIA — getByRole('tablist') visible, getByRole('tab', { name: 'Members & Accounts' }) and getByRole('tab', { name: 'Settings' }) visible; (2) keyboard — focus the "Members & Accounts" tab, press ArrowRight, assert the "Settings" tab has aria-selected true; (3) toast (D-08) — if reachable in the harness, trigger create-member success and assert a role=status element with text "Member added." appears (if create-member requires live backend state not available in the harness, assert the toast role/structure via a lighter path or document the limitation). This satisfies the Wave 0 admin-ARIA assertion requirement. test -f apps/pwa/e2e/admin.spec.ts && grep -q 'Members & Accounts' apps/pwa/e2e/admin.spec.ts && grep -q "getByRole('tablist')" apps/pwa/e2e/admin.spec.ts && grep -q 'ArrowRight' apps/pwa/e2e/admin.spec.ts && pnpm --filter @familysync/pwa exec playwright test --project=pixel admin.spec.ts - apps/pwa/e2e/admin.spec.ts exists, imports from @playwright/test, and asserts the tablist + both named tabs are visible. - It asserts ArrowRight moves selection to the "Settings" tab (aria-selected true). - pnpm --filter @familysync/pwa exec playwright test --project=pixel admin.spec.ts is GREEN (or, where an admin-session harness limitation blocks a sub-assertion, that limitation is documented in the SUMMARY and the runnable assertions pass). admin.spec.ts asserts the two-tab ARIA roles and ArrowRight keyboard switching and runs green on the pixel profile, satisfying the Wave 0 admin-ARIA requirement. ## Trust Boundaries | Boundary | Description | |----------|-------------| | (none new) | Client-side admin UX: local useState for tab/toast, position-only CSS, and a Playwright test. The underlying admin mutations and server 403 enforcement are unchanged. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | 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; T-05-24 invariant maintained. | | T-17-06-02 | Elevation of Privilege | admin two-tab nav (D-10) | accept | The tab strip is presentation-only local useState; isAdmin nav visibility is UX-only and the real boundary is server-side 403 on /api/admin/* (unchanged). No new route or authorization logic. | No new high-severity threats. Client-side UX state + position CSS + a test; server-side admin authorization is untouched. - grep "Member added" / "Password reset" / role=status / var(--bottom-chrome-h) in AdminPage.tsx — toasts - grep role=tablist / "Members & Accounts" / admin-panel-members / translate(-50%, -50%) in AdminPage.tsx — tabs + reset-sheet centering - admin.spec.ts exists with tablist + named tabs + ArrowRight assertions - pnpm --filter @familysync/pwa build — exits 0 - pnpm --filter @familysync/pwa exec playwright test --project=pixel admin.spec.ts — green Admin create/reset flows show accessible auto-dismissing toasts, AdminPage uses an accessible two-tab strip wrapping the existing sections, the reset-password sheet centers on desktop, and admin.spec.ts asserts the tab ARIA + keyboard behavior in CI. Create `.planning/phases/17-ui-optimization-polish/17-06-SUMMARY.md` when done.