chore: merge executor worktree (worktree-agent-a12887546a61a8053)
This commit is contained in:
@@ -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 |
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+562
-388
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user