---
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\""
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.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
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add success toasts to admin create-member and reset-password flows</name>
  <files>apps/pwa/src/routes/AdminPage.tsx</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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).
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>Both admin success flows show an accessible, auto-dismissing toast with the correct copy, positioned to clear the BottomTabBar on phone.</done>
</task>

<task type="auto">
  <name>Task 2: Rework AdminPage into a two-tab ARIA strip and center the reset-password sheet on desktop</name>
  <files>apps/pwa/src/routes/AdminPage.tsx</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>AdminPage uses an accessible two-tab strip wrapping the existing sections, the reset-password sheet centers on desktop, and the build passes.</done>
</task>

<task type="auto">
  <name>Task 3: Add admin.spec.ts with tab ARIA + keyboard + toast assertions</name>
  <files>apps/pwa/e2e/admin.spec.ts</files>
  <read_first>
    - 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
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - 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).
  </acceptance_criteria>
  <done>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.</done>
</task>

</tasks>

<threat_model>
## 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.
</threat_model>

<verification>
- 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
</verification>

<success_criteria>
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.
</success_criteria>

<output>
Create `.planning/phases/17-ui-optimization-polish/17-06-SUMMARY.md` when done.
</output>
