docs(17): create phase plan (6 plans, 2 waves) — UI optimization & polish

This commit is contained in:
Lucas Berger
2026-06-18 11:34:04 -04:00
parent 28cf79754a
commit 280438b2d3
7 changed files with 992 additions and 2 deletions
+11 -2
View File
@@ -397,7 +397,7 @@ Plans:
**Goal**: A visual-identity & polish pass for the PWA spanning three workstreams: **(A) phone-layout polish** so the phone (≤767px) layout has no fixed-chrome overlap and small-viewport spacing reads cleanly — starting with the long-standing BottomTabBar overlap that hides the New Event FAB and the colour legend, plus a small-viewport sweep; **(B) branding assets** — generate a real FamilySync logo into the existing `BrandSlot` seam (`apps/pwa/src/components/BrandSlot.tsx`) and a complete favicon/PWA-icon set replacing the placeholder stubs in `apps/pwa/public/`; **(C) theme-token groundwork** — restructure `apps/pwa/src/styles/tokens.css` into a themeable semantic-token layer (swappable by `data-theme`/`prefers-color-scheme`), light staying the only shipped theme, so a future dark theme is cheap. **Goal**: A visual-identity & polish pass for the PWA spanning three workstreams: **(A) phone-layout polish** so the phone (≤767px) layout has no fixed-chrome overlap and small-viewport spacing reads cleanly — starting with the long-standing BottomTabBar overlap that hides the New Event FAB and the colour legend, plus a small-viewport sweep; **(B) branding assets** — generate a real FamilySync logo into the existing `BrandSlot` seam (`apps/pwa/src/components/BrandSlot.tsx`) and a complete favicon/PWA-icon set replacing the placeholder stubs in `apps/pwa/public/`; **(C) theme-token groundwork** — restructure `apps/pwa/src/styles/tokens.css` into a themeable semantic-token layer (swappable by `data-theme`/`prefers-color-scheme`), light staying the only shipped theme, so a future dark theme is cheap.
**Mode:** standard **Mode:** standard
**Depends on**: Nothing structural (CSS/layout + assets only). Best sequenced after Phase 10 merges (the BottomTabBar gained an Admin tab and the new SetupBanner adds top pressure on phone), but otherwise independent of the admin chain. **Depends on**: Nothing structural (CSS/layout + assets only). Best sequenced after Phase 10 merges (the BottomTabBar gained an Admin tab and the new SetupBanner adds top pressure on phone), but otherwise independent of the admin chain.
**Requirements**: TBD (UI/UX polish + branding — define/promote in plan-phase; decisions captured in 17-CONTEXT.md) **Requirements**: No REQ-IDs — decisions D-01…D-10 (17-CONTEXT.md) stand in. Coverage: D-01/D-02 (A, phone overlap+guard) → 17-03; D-03/D-04 (B, assets) → 17-02; D-04/D-05 (B, wiring) → 17-04; D-06 (C, token groundwork) → 17-01; D-07/D-09 (D, logout+sheet centering) → 17-05; D-08/D-09/D-10 (D, admin toasts+reset-sheet+two-tab nav) → 17-06.
**Scope boundary (set in `/gsd-discuss-phase 17`, 2026-06-17):** Workstream C ships token groundwork **only** — no dark palette, no theme toggle (→ backlog **999.20**). A broader "modern styling" visual refresh is **out of scope** and routed to backlog **999.21** (future milestone). Keep Phase 17 a focused polish + branding + groundwork pass, not a redesign. **Scope boundary (set in `/gsd-discuss-phase 17`, 2026-06-17):** Workstream C ships token groundwork **only** — no dark palette, no theme toggle (→ backlog **999.20**). A broader "modern styling" visual refresh is **out of scope** and routed to backlog **999.21** (future milestone). Keep Phase 17 a focused polish + branding + groundwork pass, not a redesign.
**Seed defect — phone-layout bottom-bar overlap (documented 2026-06-13; long-standing, NOT introduced by Phase 10 — the BottomTabBar dates to Phase 04):** **Seed defect — phone-layout bottom-bar overlap (documented 2026-06-13; long-standing, NOT introduced by Phase 10 — the BottomTabBar dates to Phase 04):**
@@ -413,7 +413,16 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
**Candidate scope (to sharpen in `/gsd-discuss-phase 17`):** the seed defect above, plus a sweep for other small-viewport spacing / tap-target / overlap issues (the Phase 7 `layout.spec.ts` tap-target/overflow assertions are a ready checklist) and any phone/desktop visual inconsistencies noticed in use. Keep it a focused polish pass, not a redesign. **Candidate scope (to sharpen in `/gsd-discuss-phase 17`):** the seed defect above, plus a sweep for other small-viewport spacing / tap-target / overlap issues (the Phase 7 `layout.spec.ts` tap-target/overflow assertions are a ready checklist) and any phone/desktop visual inconsistencies noticed in use. Keep it a focused polish pass, not a redesign.
**Plans**: TBD **Plans**: 6 plans
Plans:
- [ ] 17-01-PLAN.md — C: tokens.css themeable-layer groundwork + --bottom-chrome-h token (D-06) [Wave 1]
- [ ] 17-02-PLAN.md — B: generate logo + full icon set, operator approval checkpoint (D-03, D-04) [Wave 1]
- [ ] 17-03-PLAN.md — A: phone FAB/BottomTabBar overlap fix + sweep + CI overlap assertion (D-01, D-02) [Wave 2, dep 01]
- [ ] 17-04-PLAN.md — B: wire logo into BrandSlot + index.html favicons + manifest maskable + accent (D-04, D-05) [Wave 2, dep 01,02]
- [ ] 17-05-PLAN.md — D: logout control + sheet desktop-centering (SettingsSheet/CredentialSheet) (D-07, D-09) [Wave 2, dep 01]
- [ ] 17-06-PLAN.md — D: admin success toasts + two-tab ARIA nav + reset-sheet centering + admin.spec.ts (D-08, D-09, D-10) [Wave 2, dep 01]
**UI hint**: yes **UI hint**: yes
## Progress ## Progress
@@ -0,0 +1,144 @@
---
phase: 17-ui-optimization-polish
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/src/styles/tokens.css
autonomous: true
requirements: [D-06]
must_haves:
truths:
- "Light tokens still apply with no data-theme attribute on <html> (combined :root, [data-theme=\"light\"] selector)"
- "Schedule-X calendar grid keeps its custom member/shared colors after the restructure"
- "A single --bottom-chrome-h token exists for Workstreams A and D to consume"
- "No hard-coded hex/px appears in component/route files (existing invariant unbroken)"
artifacts:
- path: "apps/pwa/src/styles/tokens.css"
provides: "Themeable token layer (:root, [data-theme=\"light\"]) + --bottom-chrome-h"
contains: "--bottom-chrome-h"
key_links:
- from: "apps/pwa/src/styles/tokens.css"
to: "@schedule-x/theme-default"
via: "--sx-color-* overrides remain inside the combined rule block, after the theme-default import"
pattern: "--sx-color-"
---
<objective>
Restructure `apps/pwa/src/styles/tokens.css` from a single light `:root {}` block into a themeable semantic-token layer using the combined selector `:root, [data-theme="light"]` (D-06), and add the shared `--bottom-chrome-h` layout-chrome token that Workstreams A and D will consume. This is GROUNDWORK ONLY — no dark palette values, no `prefers-color-scheme` wiring, no theme toggle. Light stays the sole shipped theme.
This plan OWNS `tokens.css` for the phase. It runs first (Wave 1) so the phone-layout plan (A) and branding plan (B) add/update their token edits into the already-restructured block without write-ordering conflicts.
Purpose: enable a future `data-theme="dark"` attribute (backlog 999.20) to override tokens with zero component-file changes, and establish one source of truth for the BottomTabBar height.
Output: a restructured `tokens.css` with the combined selector, a dark-theme stub comment, the new `--bottom-chrome-h` token, all existing values unchanged.
</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
</context>
<tasks>
<task type="auto">
<name>Task 1: Restructure tokens.css to a themeable layer and add --bottom-chrome-h</name>
<files>apps/pwa/src/styles/tokens.css</files>
<read_first>
- apps/pwa/src/styles/tokens.css (the entire file — single `:root {}` block; note the `--sx-color-*` overrides near the bottom and the `--brand-logo-*` defaults)
- 17-UI-SPEC.md §"Workstream C — Theme-Token Groundwork" (the exact combined-selector contract + dark-theme stub comment)
- 17-UI-SPEC.md §"Spacing Scale" → "New layout-chrome token" (the --bottom-chrome-h definition)
- 17-PATTERNS.md §"apps/pwa/src/styles/tokens.css" (before/after selector excerpt)
- 17-RESEARCH.md §"Common Pitfalls" → Pitfall 3 (Schedule-X cascade break)
</read_first>
<action>
Change the single top-level `:root {` opening selector of `apps/pwa/src/styles/tokens.css` to the combined selector `:root,
[data-theme="light"] {`. Keep every existing declaration inside that one rule block VERBATIM — no value changes. Critically, the `--sx-color-*` Schedule-X overrides and the `--brand-logo-*` tokens MUST remain inside this same combined rule block (do NOT split them into a separate selector — splitting changes specificity and breaks the override of `@schedule-x/theme-default`, per RESEARCH Pitfall 3).
Inside the same block, add the new layout-chrome token alongside the existing spacing scale: `--bottom-chrome-h: calc(56px + env(safe-area-inset-bottom, 0px));`. This is the source-of-truth for the BottomTabBar effective height; Workstreams A and D consume it.
Below the closing brace of the combined block, add a dark-theme stub as a COMMENT ONLY (no live rule), documenting that Phase 999.20 fills the values and wires prefers-color-scheme. Do NOT add a live `[data-theme="dark"]` rule, do NOT add any dark color values, do NOT add a `data-theme` attribute anywhere in the app (anti-pattern per RESEARCH — the combined selector means light applies with no attribute present).
Do not touch any component or route file in this plan.
</action>
<verify>
<automated>grep -q 'bottom-chrome-h' apps/pwa/src/styles/tokens.css && grep -qE '^\s*\[data-theme="light"\]' apps/pwa/src/styles/tokens.css && test $(grep -c -- '--sx-color' apps/pwa/src/styles/tokens.css) -ge 1 && pnpm --filter @familysync/pwa build</automated>
</verify>
<acceptance_criteria>
- `grep -qE '\[data-theme="light"\]' apps/pwa/src/styles/tokens.css` succeeds (combined selector present).
- `grep -q 'bottom-chrome-h' apps/pwa/src/styles/tokens.css` succeeds; the value is exactly `calc(56px + env(safe-area-inset-bottom, 0px))`.
- The `--sx-color-*` override lines remain inside the combined `:root, [data-theme="light"]` block (count unchanged from before: still 12 `sx-color` occurrences).
- No live `[data-theme="dark"]` rule exists: `grep -nE '\[data-theme="dark"\]\s*\{' apps/pwa/src/styles/tokens.css` returns nothing (a commented stub is acceptable; a live rule is not).
- `pnpm --filter @familysync/pwa build` exits 0 (TypeScript + Vite build green).
</acceptance_criteria>
<done>tokens.css uses the combined `:root, [data-theme="light"]` selector with all values unchanged, `--bottom-chrome-h` added, `--sx-color-*` overrides intact inside the block, dark stub is a comment only, and the PWA build passes.</done>
</task>
<task type="auto">
<name>Task 2: Verify Schedule-X colors and the no-hard-coded-values invariant survive the restructure</name>
<files>apps/pwa/src/styles/tokens.css (verification target — read-only)</files>
<read_first>
- apps/pwa/src/styles/tokens.css (post-restructure)
- 17-VALIDATION.md §"Per-Task Verification Map" rows for D-06 (grep invariant + Schedule-X sweep + build)
- .claude/skills/playwright-cli/ (browser-verification skill for the /calendar Schedule-X sweep)
</read_first>
<action>
Confirm the no-hard-coded-values invariant still holds: component and route files reference only `var(--token)` — no literal hex or px values leaked in during the restructure (the restructure is selector-only, but verify nothing in components changed). Run the grep invariant from 17-VALIDATION.md across `apps/pwa/src/components/` and `apps/pwa/src/routes/`.
Then verify the Schedule-X custom calendar colors are visually unchanged after the restructure using the playwright-cli skill: load `/calendar`, confirm the member/shared event chips render in the FamilySync custom colors (member-0 blue, shared rose) and have NOT reverted to the Schedule-X default theme colors. This is the regression check for RESEARCH Pitfall 3 (cascade break).
If the grep flags any pre-existing literals that are NOT introduced by this restructure, do NOT fix them in this plan (out of scope — selector-only change); record them in the SUMMARY as a noted observation instead.
</action>
<verify>
<automated>! grep -rnE '#[0-9a-fA-F]{3,6}|[0-9]+px' apps/pwa/src/components apps/pwa/src/routes --include='*.tsx' --include='*.ts' | grep -v 'var(--' | grep -vE '^\s*//|^\s*\*' | grep -q . ; echo "grep invariant check exit=$?"</automated>
</verify>
<acceptance_criteria>
- The grep invariant command from 17-VALIDATION.md (D-06 row) shows no NEW hard-coded hex/px literals introduced by this plan in component/route files.
- playwright-cli sweep of `/calendar`: Schedule-X event chips render in FamilySync custom colors (member blue + shared rose), not Schedule-X default blue/green — visual confirmation captured (screenshot or observation noted in SUMMARY).
- `pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts` still green (no structural regression from the token change).
</acceptance_criteria>
<done>The grep invariant passes with no new literals, Schedule-X colors are confirmed unchanged via playwright-cli on /calendar, and the layout.spec.ts pixel profile is green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | This plan is a CSS-only restructure of a static stylesheet. No new trust boundary is introduced. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-17-01-01 | Tampering | tokens.css restructure | accept | CSS custom properties carry no executable content and no user input; a selector change cannot introduce injection. No new threat above LOW for Workstream C. |
No new high/medium-severity threats. This is a static stylesheet selector restructure with zero runtime data flow and no new dependencies.
</threat_model>
<verification>
- `grep -qE '\[data-theme="light"\]' apps/pwa/src/styles/tokens.css` — combined selector present
- `grep -q 'bottom-chrome-h' apps/pwa/src/styles/tokens.css` — new token present
- `pnpm --filter @familysync/pwa build` — exits 0
- playwright-cli `/calendar` sweep — Schedule-X custom colors intact
- `pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts` — green
</verification>
<success_criteria>
tokens.css is restructured to the combined `:root, [data-theme="light"]` selector with all values unchanged, `--bottom-chrome-h` added as the single source of truth for bottom-chrome height, Schedule-X overrides confirmed working, the no-hard-coded-values invariant intact, and the build + pixel layout suite green.
</success_criteria>
<output>
Create `.planning/phases/17-ui-optimization-polish/17-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,175 @@
---
phase: 17-ui-optimization-polish
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/package.json
- apps/pwa/pwa-assets.config.ts
- apps/pwa/public/logo.svg
- apps/pwa/public/favicon.svg
- apps/pwa/public/favicon.ico
- apps/pwa/public/icon-192.png
- apps/pwa/public/icon-512.png
- apps/pwa/public/icon-maskable-512.png
- apps/pwa/public/apple-touch-icon.png
autonomous: false
requirements: [D-03, D-04]
user_setup: []
must_haves:
truths:
- "A real FamilySync logo (warm/rounded/at-home/caricature-family) exists as a committed SVG"
- "All 7 branding assets exist in apps/pwa/public/ with correct formats and dimensions"
- "A proper maskable 512 icon exists as its own file with the logo inside the 80% safe zone"
- "The operator has approved the logo art AND selected the brand accent before wiring (next plan) is committed"
artifacts:
- path: "apps/pwa/public/logo.svg"
provides: "Hand-authored source brand mark (BrandSlot img + icon derivation source)"
- path: "apps/pwa/public/icon-maskable-512.png"
provides: "512x512 maskable PWA icon with safe-zone padding"
- path: "apps/pwa/pwa-assets.config.ts"
provides: "@vite-pwa/assets-generator config (minimal-2023 preset)"
- path: "apps/pwa/package.json"
provides: "@vite-pwa/assets-generator devDependency + pwa:icons script"
key_links:
- from: "apps/pwa/pwa-assets.config.ts"
to: "apps/pwa/public/logo.svg"
via: "images: ['public/logo.svg'] — generator reads the source SVG"
pattern: "logo.svg"
---
<objective>
Generate the complete FamilySync branding asset set (D-03, D-04): hand-author a warm/rounded/at-home/caricature-family `logo.svg`, install `@vite-pwa/assets-generator` as a devDependency in `apps/pwa`, and derive the full icon/favicon set (`favicon.svg`, `favicon.ico`, `icon-192.png`, `icon-512.png`, a PROPER `icon-maskable-512.png` with real safe-zone padding, `apple-touch-icon.png`) using the `minimal-2023` preset. Then obtain explicit operator approval of the logo art AND the brand-accent selection at a blocking human checkpoint BEFORE any wiring is committed (wiring happens in plan 17-04).
This plan deliberately produces and commits ONLY the assets + generator config. The BrandSlot/index.html/vite.config.ts wiring is a separate plan (17-04) so the checkpoint gates the wiring, exactly as the phase contract requires.
Purpose: replace the placeholder icon stubs (icon-192.png 699 B, icon-512.png, apple-touch-icon.png 617 B) and fix the missing-favicon + improper-maskable defects with a coherent, approved brand identity.
Output: 8 files (logo.svg + 6 derived assets + pwa-assets.config.ts), package.json devDep + script, and a recorded operator approval (logo art + accent choice).
</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-RESEARCH.md
@.planning/phases/17-ui-optimization-polish/17-CONTEXT.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install @vite-pwa/assets-generator and author logo.svg + pwa-assets.config.ts</name>
<files>apps/pwa/package.json, apps/pwa/public/logo.svg, apps/pwa/pwa-assets.config.ts</files>
<read_first>
- 17-CONTEXT.md §"Specific Ideas" (the brand brief: FamilySync is the brand; caricature-family vibes; warm tones; rounded corners/shapes; "comfortable and at home")
- 17-UI-SPEC.md §"Workstream B — Branding Assets" → "Brand brief" + "Logo asset contract" + "Maskable safe-zone rule"
- 17-RESEARCH.md §"Architecture Patterns" → "Recommended Asset-Generation Script Shape" + the minimal pwa-assets.config.ts excerpt + the generate command
- 17-RESEARCH.md §"Package Legitimacy Audit" (sharp SUS verdict is a documented false positive — 13-yr/65.6M-wk package; no human-verify checkpoint needed for the package itself)
- apps/pwa/public/ (the 3 placeholder stubs being replaced)
</read_first>
<action>
Install `@vite-pwa/assets-generator@1.0.2` as a devDependency in `apps/pwa` (pulls `sharp` + `sharp-ico` transitively — these are the only new packages; per the Package Legitimacy Audit they are Approved, no human-verify gate required for the package). Add a `"pwa:icons": "pwa-assets-generator generate"` script to `apps/pwa/package.json`.
Hand-author `apps/pwa/public/logo.svg` as a square-viewBox SVG mark to the brand brief: warm tones, rounded corners/shapes, a friendly caricature-family feel that "makes you feel comfortable and at home" — NOT cold/corporate/geometric. Use a square viewBox so the generator's safe-zone arithmetic works. The mark is text-free art (the app name lives in the `<h1>`, not the logo). Keep the SVG self-contained (no external font/image refs).
Create `apps/pwa/pwa-assets.config.ts` using `defineConfig` + `minimal2023Preset` from `@vite-pwa/assets-generator/config`, with `images: ['public/logo.svg']`. Do NOT set `overrideManifestIcons: true` — the manifest is maintained by hand in vite.config.ts (plan 17-04); auto-override would stomp the explicit entries (RESEARCH anti-pattern).
</action>
<verify>
<automated>test -f apps/pwa/public/logo.svg && test -f apps/pwa/pwa-assets.config.ts && node -e "const p=require('./apps/pwa/package.json'); if(!p.devDependencies['@vite-pwa/assets-generator']) process.exit(1); if(!p.scripts['pwa:icons']) process.exit(1)" && head -c 5 apps/pwa/public/logo.svg | grep -q '<'</automated>
</verify>
<acceptance_criteria>
- `@vite-pwa/assets-generator` present in `apps/pwa/package.json` devDependencies; `pwa:icons` script present.
- `apps/pwa/public/logo.svg` exists, is valid SVG (parses; square viewBox), self-contained, text-free art.
- `apps/pwa/pwa-assets.config.ts` exists, imports `defineConfig`/`minimal2023Preset` from `@vite-pwa/assets-generator/config`, sets `images: ['public/logo.svg']`, and does NOT set `overrideManifestIcons: true`.
</acceptance_criteria>
<done>The generator devDep + script are installed, logo.svg matches the brand brief as a valid square SVG, and pwa-assets.config.ts is configured for the minimal-2023 preset against the source logo.</done>
</task>
<task type="auto">
<name>Task 2: Generate the full icon/favicon set and validate formats</name>
<files>apps/pwa/public/favicon.svg, apps/pwa/public/favicon.ico, apps/pwa/public/icon-192.png, apps/pwa/public/icon-512.png, apps/pwa/public/icon-maskable-512.png, apps/pwa/public/apple-touch-icon.png</files>
<read_first>
- 17-UI-SPEC.md §"Workstream B" → "Logo asset contract" table (all 7 filenames + dimensions)
- 17-RESEARCH.md §"Architecture Patterns" → the minimal-2023 preset output list + "Maskable safe-zone math" + "ICO generation"
- 17-VALIDATION.md §"Per-Task Verification Map" rows D-03/D-04 (asset existence ls, ICO >100 bytes, maskable 512x512 via sharp metadata)
- apps/pwa/pwa-assets.config.ts (authored in Task 1)
</read_first>
<action>
Run the generator from `apps/pwa/` (`pnpm pwa:icons` or `npx @vite-pwa/assets-generator generate`) to produce the derived assets into `apps/pwa/public/`: `favicon.svg`, `favicon.ico`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png` (separate maskable file with safe-zone padding — this fixes the defect where the manifest reused icon-512.png for the maskable purpose), and `apple-touch-icon.png` (180x180). The minimal-2023 preset handles the safe-zone math, maskable background fill, and ICO encoding via sharp-ico.
Validate the outputs: confirm all 7 files (logo.svg + 6 generated) exist in `apps/pwa/public/`; confirm `favicon.ico` is non-trivial (>100 bytes); confirm `icon-maskable-512.png` is exactly 512x512 via sharp metadata (proving real safe-zone generation, not a stub). The 48px single-size ICO from the preset is acceptable (RESEARCH documents this as adequate vs 16+32 multi-size for this household app; favicon.svg covers modern browsers).
Do NOT wire anything yet (no BrandSlot/index.html/vite.config.ts edits — that is plan 17-04, gated behind the Task 3 checkpoint).
</action>
<verify>
<automated>ls apps/pwa/public/logo.svg apps/pwa/public/favicon.svg apps/pwa/public/favicon.ico apps/pwa/public/icon-192.png apps/pwa/public/icon-512.png apps/pwa/public/icon-maskable-512.png apps/pwa/public/apple-touch-icon.png && test $(wc -c < apps/pwa/public/favicon.ico) -gt 100 && node -e "require('sharp')('apps/pwa/public/icon-maskable-512.png').metadata().then(m=>{if(m.width!==512||m.height!==512){console.error('bad maskable dims',m.width,m.height);process.exit(1)}console.log('maskable',m.width+'x'+m.height)})"</automated>
</verify>
<acceptance_criteria>
- `ls` of all 7 asset paths succeeds (logo.svg + favicon.svg + favicon.ico + icon-192.png + icon-512.png + icon-maskable-512.png + apple-touch-icon.png).
- `test $(wc -c < apps/pwa/public/favicon.ico) -gt 100` passes (ICO is real, not zero-byte).
- `icon-maskable-512.png` reports exactly 512x512 via sharp metadata.
- `icon-maskable-512.png` is a DISTINCT file from `icon-512.png` (different bytes — the maskable has safe-zone padding).
</acceptance_criteria>
<done>All 7 branding assets exist with correct formats; favicon.ico is non-trivial; the maskable icon is a separate, properly-padded 512x512 file.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: Checkpoint — operator approves logo art + selects brand accent + logo border-radius</name>
<files>apps/pwa/public/logo.svg, apps/pwa/public/favicon.svg, apps/pwa/public/favicon.ico, apps/pwa/public/icon-192.png, apps/pwa/public/icon-512.png, apps/pwa/public/icon-maskable-512.png, apps/pwa/public/apple-touch-icon.png</files>
<action>Present the generated branding assets and the brand-accent decision to the operator for blocking approval before any wiring is committed (see how-to-verify). Capture the approved logo, the selected accent hex, and the chosen --brand-logo-border-radius for plan 17-04.</action>
<what-built>
A complete FamilySync branding asset set generated from a hand-authored warm/rounded/at-home logo: `logo.svg` (source), `favicon.svg`, `favicon.ico`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png` (proper safe-zone maskable), and `apple-touch-icon.png` — all in `apps/pwa/public/`. Nothing is wired into the app yet; this checkpoint gates the wiring (plan 17-04).
</what-built>
<how-to-verify>
1. Use the playwright-cli skill to render the logo in context: open the logo.svg directly and/or stage it in the BrandSlot region of `/login` for a preview screenshot, and show the favicon at small sizes.
2. Present the generated logo to the operator against the acceptance lens: does it feel warm / rounded / at-home / caricature-family (not cold/corporate/geometric)? Confirm the maskable icon keeps the mark inside the safe zone (no clipping of meaningful art).
3. Present the BRAND ACCENT decision (UI-SPEC §Color "Brand accent checkpoint", Q1) — two comparable options:
- Variant A — keep cool-blue: `--color-member-0: #4a90d9`, `theme-color #4A90D9` (default if no choice).
- Variant B — warm: `--color-member-0: #f25c7a` (rose, already `--color-shared-family`) OR amber `#e8915a`; contrast ≥3:1 on #ffffff.
Ask the operator to pick A or one of the B candidates. Record the chosen accent hex for plan 17-04 to apply to tokens.css `--color-member-0`, index.html `theme-color`, and vite.config.ts `theme_color`.
4. Also confirm the intended `--brand-logo-border-radius` for the logo shape (0 if the SVG draws its own rounded shape, or 12px if it's a square mark needing rounding) — record for plan 17-04.
</how-to-verify>
<resume-signal>Operator types "approved" with: (a) logo accepted (or revise instructions), (b) the selected brand-accent hex, (c) the --brand-logo-border-radius value. These three feed plan 17-04.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Build tooling → repo | `@vite-pwa/assets-generator` (+ sharp, sharp-ico) runs at design time and writes static assets into `public/`. New devDependency = supply-chain surface. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-17-02-SC | Tampering | npm devDependency install (@vite-pwa/assets-generator, sharp, sharp-ico) | accept | Per RESEARCH Package Legitimacy Audit all three are Approved: assets-generator (official vite-pwa project, 231K/wk), sharp (13-yr, 65.6M/wk — the `too-new` SUS flag is a documented false positive from the latest version's publish date), sharp-ico (431K/wk). No `[SLOP]`/unverified packages; no human-verify gate required for the package legitimacy. Generated assets are static images served as files — no executable content. |
| T-17-02-02 | Information disclosure | generated assets | accept | Assets are public-by-design brand images; no secrets or PII. |
No new high-severity threats. devDependencies only; zero new runtime dependencies; generated output is static images.
</threat_model>
<verification>
- `ls` all 7 asset paths — present
- `test $(wc -c < apps/pwa/public/favicon.ico) -gt 100` — ICO non-trivial
- sharp metadata on `icon-maskable-512.png` — exactly 512x512
- `@vite-pwa/assets-generator` in apps/pwa devDependencies; `pwa:icons` script present
- Blocking human checkpoint: logo art approved + brand-accent hex selected + --brand-logo-border-radius value recorded
</verification>
<success_criteria>
The full branding asset set is generated and committed (logo.svg + 6 derived assets), the assets-generator devDep + script are in place, format/dimension checks pass, and the operator has approved the logo and selected the brand accent + logo border-radius — unblocking the wiring plan (17-04).
</success_criteria>
<output>
Create `.planning/phases/17-ui-optimization-polish/17-02-SUMMARY.md` when done. Record the approved brand-accent hex and --brand-logo-border-radius value in the SUMMARY (plan 17-04 reads them).
</output>
@@ -0,0 +1,173 @@
---
phase: 17-ui-optimization-polish
plan: 03
type: execute
wave: 2
depends_on: ["17-01"]
files_modified:
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/App.tsx
- apps/pwa/e2e/layout.spec.ts
autonomous: true
requirements: [D-01, D-02]
must_haves:
truths:
- "The New Event FAB never intersects the BottomTabBar rect on iphone/pixel profiles"
- "Phone content scrolls fully above the BottomTabBar — the color-legend chips are not occluded"
- "Desktop layout geometry is unchanged (no extra bottom padding, no FAB offset change)"
- "A permanent overlap regression assertion guards the FAB↔BottomTabBar geometry in CI"
artifacts:
- path: "apps/pwa/e2e/layout.spec.ts"
provides: "Permanent FAB↔BottomTabBar overlap assertion (iphone + pixel, skipped on desktop)"
contains: "New Event FAB does not overlap BottomTabBar"
key_links:
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "apps/pwa/src/styles/tokens.css"
via: "FAB bottom offset reads var(--bottom-chrome-h)"
pattern: "var\\(--bottom-chrome-h\\)"
- from: "apps/pwa/src/App.tsx"
to: "apps/pwa/src/styles/tokens.css"
via: "phone contentStyle paddingBottom reads var(--bottom-chrome-h)"
pattern: "var\\(--bottom-chrome-h\\)"
---
<objective>
Fix the long-standing phone (≤767px) fixed-chrome overlap where the `position: fixed` BottomTabBar covers the New Event FAB and occludes the bottom of the calendar + color-legend chips (D-01, the phase seed defect), using the shared `--bottom-chrome-h` token added by plan 17-01 (D-02 fix technique). Add a permanent overlap regression assertion to `layout.spec.ts` (D-02 regression guard) and run the bounded small-viewport sweep across all three Playwright profiles. CSS-only — no behaviour change, no desktop geometry change.
Purpose: the FAB currently lands on the Admin tab and the "Dev User" legend is clipped at 390×844; this fix lifts the FAB above the bar and reserves content padding so nothing is occluded at rest.
Output: corrected FAB offset (CalendarShell.tsx), phone-only content padding (App.tsx), and a new CI overlap assertion (layout.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: Lift the FAB and reserve phone content padding via --bottom-chrome-h</name>
<files>apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/App.tsx</files>
<read_first>
- apps/pwa/src/components/CalendarShell.tsx lines 463491 (the phone-only FAB block; current `bottom: 'var(--space-6)'` is the defect site)
- apps/pwa/src/App.tsx lines 64, 84, 155163 (`isPhone()`, the `phone` boolean, and `contentStyle` which lacks paddingBottom)
- apps/pwa/src/components/BottomTabBar.tsx line ~73 (bar height `calc(56px + env(safe-area-inset-bottom, 0px))` — resolves identically to `--bottom-chrome-h`)
- 17-UI-SPEC.md §"Workstream A — Phone-Layout Overlap Fix" → "Fix contract" + "Visual invariants" (exact before/after values)
- 17-PATTERNS.md §"apps/pwa/src/components/CalendarShell.tsx" and §"apps/pwa/src/App.tsx" (line-anchored before/after excerpts + the `...(phone ? {...} : {})` spread pattern)
- 17-RESEARCH.md §"Common Pitfalls" → Pitfall 2 (paddingBottom must be phone-only)
- 17-01-SUMMARY.md (confirms `--bottom-chrome-h` is available in tokens.css)
</read_first>
<action>
In `apps/pwa/src/components/CalendarShell.tsx`, in the phone-only FAB style block, change ONLY the `bottom` property from `'var(--space-6)'` to `'calc(var(--bottom-chrome-h) + var(--space-6))'`. This sits the FAB `var(--space-6)` (24px) above the BottomTabBar top edge regardless of the safe-area-inset value. Leave `right`, `width`, `height`, `zIndex`, and all other FAB style properties unchanged.
In `apps/pwa/src/App.tsx`, add `paddingBottom` to `contentStyle` PHONE-ONLY using the existing `phone` boolean and the established spread pattern: append `...(phone ? { paddingBottom: 'var(--bottom-chrome-h)' } : {})` to the `contentStyle` object literal. Do NOT add this unconditionally — desktop has no BottomTabBar and must not gain extra bottom padding (RESEARCH Pitfall 2). Do not change any other contentStyle property.
Optionally (not required for the visual invariant) reference `var(--bottom-chrome-h)` from BottomTabBar.tsx's height; if you do, the value must remain identical (`calc(56px + env(safe-area-inset-bottom, 0px))`). Skipping this is fine — keep the diff minimal.
</action>
<verify>
<automated>grep -q 'calc(var(--bottom-chrome-h) + var(--space-6))' apps/pwa/src/components/CalendarShell.tsx && grep -q "paddingBottom: 'var(--bottom-chrome-h)'" apps/pwa/src/App.tsx && pnpm --filter @familysync/pwa build</automated>
</verify>
<acceptance_criteria>
- CalendarShell.tsx FAB `bottom` is exactly `calc(var(--bottom-chrome-h) + var(--space-6))`.
- App.tsx `contentStyle` adds `paddingBottom: 'var(--bottom-chrome-h)'` ONLY inside the `phone` branch (the `...(phone ? {...} : {})` spread); desktop branch has no paddingBottom.
- `pnpm --filter @familysync/pwa build` exits 0.
- No other FAB/contentStyle properties changed (diff is minimal, CSS-only).
</acceptance_criteria>
<done>The FAB clears the BottomTabBar by var(--space-6), phone content reserves --bottom-chrome-h padding, desktop geometry is untouched, and the build passes.</done>
</task>
<task type="auto">
<name>Task 2: Add the FAB↔BottomTabBar overlap regression assertion to layout.spec.ts</name>
<files>apps/pwa/e2e/layout.spec.ts</files>
<read_first>
- apps/pwa/e2e/layout.spec.ts lines 160 (file header, imports, existing `test.describe` + `boundingBox()` assertion pattern, the `navigation`/`Main navigation` role locator)
- apps/pwa/playwright.config.ts (the three profiles: iphone 390×844 WebKit, pixel 412×915 Chromium, desktop 1280×720 — the assertion skips desktop via `testInfo.project.name`)
- 17-UI-SPEC.md §"Workstream A" → "Regression guard (D-02 decision)" (the exact assertion contract)
- 17-PATTERNS.md §"apps/pwa/e2e/layout.spec.ts" (the new overlap assertion excerpt)
- 17-VALIDATION.md §"Wave 0 Requirements" (this assertion is the Wave 0 overlap-regression item)
</read_first>
<action>
Add a new Playwright test to `apps/pwa/e2e/layout.spec.ts` named "New Event FAB does not overlap BottomTabBar (A — phone only)". It must: skip on the desktop profile via `test.skip(testInfo.project.name === 'desktop', 'Phone-only assertion')`; `page.goto('/calendar')`; locate the FAB via `page.getByRole('button', { name: 'New Event' })` and the bar via `page.getByRole('navigation', { name: 'Main navigation' })`; read both `boundingBox()`; assert neither is null; and assert `fabBox.y + fabBox.height <= navBox.y` (the FAB bottom edge is at or above the BottomTabBar top edge). Match the existing file's test style and import (`import { test, expect } from '@playwright/test'`).
This is the permanent regression guard for the seed defect. It runs on iphone + pixel (the profiles that exposed the long-standing, desktop-invisible defect).
</action>
<verify>
<automated>grep -q 'New Event FAB does not overlap BottomTabBar' apps/pwa/e2e/layout.spec.ts && pnpm --filter @familysync/pwa exec playwright test --project=iphone --project=pixel layout.spec.ts</automated>
</verify>
<acceptance_criteria>
- The new test "New Event FAB does not overlap BottomTabBar (A — phone only)" exists in layout.spec.ts.
- It skips on desktop (`testInfo.project.name === 'desktop'`).
- It asserts `fabBox.y + fabBox.height <= navBox.y`.
- `pnpm --filter @familysync/pwa exec playwright test --project=iphone --project=pixel layout.spec.ts` is GREEN (the assertion passes against the Task 1 fix — proving the overlap is resolved on both phone profiles).
</acceptance_criteria>
<done>The overlap assertion is committed to layout.spec.ts, skips desktop, and passes green on iphone + pixel — confirming the fix and guarding against regression.</done>
</task>
<task type="auto">
<name>Task 3: Run the bounded small-viewport sweep across all three profiles</name>
<files>apps/pwa/e2e/layout.spec.ts (sweep run — fix only violations flagged, CSS-only)</files>
<read_first>
- 17-UI-SPEC.md §"Workstream A" → "Small-viewport sweep (D-01)" (the checklist: Admin tab tap target, ColorLegend chips visible, no overflow on phone profiles)
- 17-VALIDATION.md §"Per-Task Verification Map" D-01 rows (color-legend visible, no horizontal overflow Rule 2, tap targets Rule 1)
- apps/pwa/e2e/layout.spec.ts (Rules 14 assertions: ≥44px/≥56px tap targets, no overflow, in-viewport, accessible names)
- .claude/skills/playwright-cli/ (for the manual color-legend visibility/scroll observation the spec suite cannot fully cover)
</read_first>
<action>
Run the full `layout.spec.ts` suite across all three profiles (`test:e2e`). The D-01 sweep is checklist-driven by the existing Rules 14 assertions — fix ANY violation they flag, staying within the no-behaviour-change boundary (CSS/layout only). Expected areas to confirm per UI-SPEC: the Admin tab still meets ≥44px after the layout fix, the ColorLegend "Dev User"/member chips are fully visible (not occluded) now that content padding is added, and no horizontal overflow on phone profiles.
Use the playwright-cli skill at 390×844 to visually confirm the color-legend chips are reachable by scrolling and not clipped behind the bar (the occlusion symptom from the seed defect). Do NOT undertake a free-form audit or touch desktop geometry — fix only what the assertions flag.
</action>
<verify>
<automated>pnpm --filter @familysync/pwa test:e2e</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa test:e2e` (full 3-profile suite) is GREEN — Rules 14 (tap targets, no overflow, in-viewport, accessible names) all pass on iphone/pixel/desktop after the fix.
- playwright-cli @390×844: the ColorLegend chips are fully visible (scroll-reachable, not clipped behind the BottomTabBar) — observation noted in SUMMARY.
- No desktop geometry regression (desktop profile assertions unchanged/green).
</acceptance_criteria>
<done>The full layout.spec.ts suite is green on all three profiles, the color-legend occlusion is confirmed resolved via playwright-cli, and no desktop geometry changed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | CSS-only layout offsets + a Playwright test. No runtime data flow, no new endpoints, no user input. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-17-03-01 | Tampering | FAB/content CSS offsets | accept | Pure layout geometry via existing CSS custom property; no executable content, no input. No new threat above LOW for Workstream A. |
No new high/medium-severity threats. CSS-only positioning change plus a geometry test.
</threat_model>
<verification>
- `grep -q 'calc(var(--bottom-chrome-h) + var(--space-6))' apps/pwa/src/components/CalendarShell.tsx` — FAB lifted
- `grep -q "paddingBottom: 'var(--bottom-chrome-h)'" apps/pwa/src/App.tsx` — phone padding added
- `grep -q 'New Event FAB does not overlap BottomTabBar' apps/pwa/e2e/layout.spec.ts` — regression guard added
- `pnpm --filter @familysync/pwa exec playwright test --project=iphone --project=pixel layout.spec.ts` — overlap assertion green
- `pnpm --filter @familysync/pwa test:e2e` — full 3-profile sweep green
</verification>
<success_criteria>
The FAB no longer overlaps the BottomTabBar on phone, the color-legend is no longer occluded, desktop geometry is unchanged, a permanent overlap assertion guards the regression in CI, and the full Playwright suite is green across all three profiles.
</success_criteria>
<output>
Create `.planning/phases/17-ui-optimization-polish/17-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,158 @@
---
phase: 17-ui-optimization-polish
plan: 04
type: execute
wave: 2
depends_on: ["17-01", "17-02"]
files_modified:
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/index.html
- apps/pwa/vite.config.ts
- apps/pwa/src/styles/tokens.css
autonomous: true
requirements: [D-04, D-05]
must_haves:
truths:
- "The real logo renders in BrandSlot on /login with no LoginPage layout shift; <h1> still carries the app name"
- "index.html links favicon.svg + favicon.ico + apple-touch-icon, and theme-color matches the approved accent"
- "The PWA manifest references the proper separate icon-maskable-512.png (not a reused icon-512.png)"
- "The approved brand accent is applied consistently across tokens.css, index.html, and the manifest"
artifacts:
- path: "apps/pwa/src/components/BrandSlot.tsx"
provides: "Logo img swapped in for the placeholder div, decorative (alt empty, aria-hidden)"
contains: "logo.svg"
- path: "apps/pwa/index.html"
provides: "favicon link set + theme-color meta"
contains: "favicon.svg"
key_links:
- from: "apps/pwa/vite.config.ts"
to: "apps/pwa/public/icon-maskable-512.png"
via: "manifest icons[] references the separate maskable file with purpose maskable"
pattern: "icon-maskable-512.png"
- from: "apps/pwa/src/components/BrandSlot.tsx"
to: "apps/pwa/public/logo.svg"
via: "img src /logo.svg"
pattern: "logo.svg"
---
<objective>
Wire the approved branding assets (from plan 17-02) into the app (D-05, D-04): swap the BrandSlot placeholder div for a decorative logo img through the existing seam (no LoginPage layout change), add the missing favicon links to index.html, fix the vite.config.ts manifest to reference the proper separate `icon-maskable-512.png`, and apply the operator-selected brand accent plus `--brand-logo-border-radius` (from the 17-02 checkpoint) consistently across tokens.css, index.html, and the manifest.
This plan runs AFTER 17-02 (assets exist + accent/border-radius approved at the checkpoint) and 17-01 (tokens.css restructured, `--brand-logo-*` live in the combined block). It does not touch logo art — only wiring + the approved token values.
Purpose: replace placeholder stubs/missing favicons with the real, approved identity and fix the improper-maskable defect.
Output: BrandSlot img, index.html favicon links + theme-color, manifest icons[] with separate maskable, and the accent/border-radius token values.
</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
@.planning/phases/17-ui-optimization-polish/17-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Swap BrandSlot placeholder div for the logo img and set --brand-logo-border-radius</name>
<files>apps/pwa/src/components/BrandSlot.tsx, apps/pwa/src/styles/tokens.css</files>
<read_first>
- apps/pwa/src/components/BrandSlot.tsx lines 29-50 (the placeholder div to replace; note the h1 FamilySync and tagline p must stay)
- 17-UI-SPEC.md section "Workstream B" -> "BrandSlot swap contract (D-05)" and section "Surface Architecture" -> "Surface B-1" (the exact img style object)
- 17-PATTERNS.md section "apps/pwa/src/components/BrandSlot.tsx" (before/after excerpt)
- 17-RESEARCH.md section "Common Pitfalls" -> Pitfall 5 (--brand-logo-border-radius 50% clips an SVG into a circle)
- 17-02-SUMMARY.md (the operator-approved --brand-logo-border-radius value from the checkpoint)
- .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md section "Brand Slot" (the Phase 19 seam contract — LoginPage layout MUST NOT change)
</read_first>
<action>
In apps/pwa/src/components/BrandSlot.tsx, replace the placeholder div (currently rendering the "FS" initials with aria-hidden) with a decorative logo image element: src "/logo.svg", empty alt, aria-hidden true. Apply the existing brand-logo token surface to the image per UI-SPEC Surface B-1: width var(--brand-logo-size, 48px), height var(--brand-logo-size, 48px), borderRadius var(--brand-logo-border-radius), margin "0 auto var(--space-2, 8px)", display block, aspectRatio "1 / 1", objectFit contain, flexShrink 0. The --brand-logo-bg background is NOT applied (no background div now). Keep the h1 "FamilySync" and the tagline p exactly as-is — the logo is decorative; the h1 remains the accessible page name. Do NOT modify LoginPage.tsx (the Phase 19 seam contract forbids it). Do NOT use dangerouslySetInnerHTML.
In apps/pwa/src/styles/tokens.css, update --brand-logo-border-radius from 50% to the value the operator approved at the 17-02 checkpoint (recorded in 17-02-SUMMARY — likely 0 if the SVG draws its own rounded shape, or 12px for a square mark; per RESEARCH Pitfall 5, leaving it at 50% would clip the logo into a circle). This is the only tokens.css edit in this task; it goes inside the combined :root, [data-theme="light"] block.
</action>
<verify>
<automated>grep -q 'src="/logo.svg"' apps/pwa/src/components/BrandSlot.tsx && grep -q 'alt=""' apps/pwa/src/components/BrandSlot.tsx && grep -q 'FamilySync' apps/pwa/src/components/BrandSlot.tsx && ! grep -q 'dangerouslySetInnerHTML' apps/pwa/src/components/BrandSlot.tsx && pnpm --filter @familysync/pwa build</automated>
</verify>
<acceptance_criteria>
- BrandSlot renders a decorative logo image (src "/logo.svg", empty alt, aria-hidden); the h1 "FamilySync" and tagline p are unchanged.
- --brand-logo-border-radius in tokens.css equals the operator-approved value from 17-02-SUMMARY (not the old 50%, unless the operator explicitly chose circular).
- LoginPage.tsx is NOT in this plan's diff (seam contract honored).
- playwright-cli on /login: the logo renders, no layout shift vs the placeholder (aspect-ratio 1/1 + explicit width), h1 "FamilySync" still present as text. Observation noted in SUMMARY.
- pnpm --filter @familysync/pwa build exits 0.
</acceptance_criteria>
<done>BrandSlot shows the real decorative logo via the seam with no LoginPage change, the border-radius token matches the approved shape, and the login view has no layout shift.</done>
</task>
<task type="auto">
<name>Task 2: Wire index.html favicons + theme-color and fix the vite.config.ts manifest maskable + accent</name>
<files>apps/pwa/index.html, apps/pwa/vite.config.ts, apps/pwa/src/styles/tokens.css</files>
<read_first>
- apps/pwa/index.html (current: one apple-touch-icon link, theme-color #4A90D9, no rel=icon links)
- apps/pwa/vite.config.ts lines ~33 (theme_color) and ~38-42 (the icons array; the last entry reuses icon-512.png for maskable — the defect)
- 17-UI-SPEC.md section "Workstream B" -> "index.html wiring contract" and "vite.config.ts manifest wiring contract" (exact link set + icons[] + theme_color rules)
- 17-PATTERNS.md sections "apps/pwa/index.html" and "apps/pwa/vite.config.ts" (before/after excerpts)
- 17-02-SUMMARY.md (the operator-selected accent hex from the checkpoint — applies to index.html theme-color + manifest theme_color; tokens.css --color-member-0 only if Variant B)
- 17-UI-SPEC.md section "Color" -> "Brand accent checkpoint" (which files flip for the accent)
</read_first>
<action>
In apps/pwa/index.html, add the favicon links in order (SVG first for modern browsers, ICO second for legacy): a rel=icon link to /favicon.svg with type image/svg+xml, then a rel=icon link to /favicon.ico with sizes "any". Update the existing apple-touch-icon link to include sizes "180x180". Add the apple-mobile-web-app meta tags per UI-SPEC (capable yes, status-bar-style default, title FamilySync). Set the theme-color meta content to the operator-approved accent hex from 17-02-SUMMARY (#4A90D9 if Variant A was kept).
In apps/pwa/vite.config.ts, fix the manifest icons array so the maskable entry references the SEPARATE /icon-maskable-512.png file (src /icon-maskable-512.png, sizes 512x512, type image/png, purpose maskable) — remove the defective reuse of /icon-512.png for the maskable purpose. Keep the /icon-192.png and /icon-512.png (purpose any) entries. Update the manifest theme_color to the same approved accent hex so it matches index.html.
If the operator selected the warm accent (Variant B) at the 17-02 checkpoint, ALSO update --color-member-0 in tokens.css to the chosen hex (this is the only additional tokens.css edit, inside the combined block; --sx-color-primary already maps to --color-member-0 and follows automatically). If Variant A was kept, no tokens.css color edit is needed.
</action>
<verify>
<automated>grep -q 'favicon.svg' apps/pwa/index.html && grep -q 'favicon.ico' apps/pwa/index.html && grep -q 'icon-maskable-512.png' apps/pwa/vite.config.ts && ! grep -E "/icon-512.png'.*maskable|maskable.*/icon-512.png'" apps/pwa/vite.config.ts && pnpm --filter @familysync/pwa build</automated>
</verify>
<acceptance_criteria>
- index.html contains rel=icon links to /favicon.svg (svg first) and /favicon.ico (sizes any), plus the apple-touch-icon with sizes 180x180.
- index.html theme-color content equals the approved accent hex; vite.config.ts manifest theme_color matches it.
- vite.config.ts manifest maskable entry references /icon-maskable-512.png (the separate file), and no manifest entry uses /icon-512.png with purpose maskable.
- If Variant B accent was chosen: tokens.css --color-member-0 updated to the chosen hex; otherwise tokens.css color unchanged.
- pnpm --filter @familysync/pwa build exits 0; playwright-cli on / confirms manifest icon links resolve (no 404). Observation noted in SUMMARY.
</acceptance_criteria>
<done>index.html links the full favicon set with the approved theme-color, the manifest references the proper separate maskable icon and matching theme_color, the accent is applied consistently, and the build passes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none new) | Wiring static asset references + an img element + token values. No runtime data flow, no user input, no new endpoints. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-17-04-01 | Tampering | BrandSlot img / index.html links | accept | The logo img is decorative with empty alt; no dangerouslySetInnerHTML (T-05-24 invariant maintained); favicon links and manifest entries point at committed static files. No new threat above LOW for Workstream B wiring. |
No new high/medium-severity threats. Static asset wiring + token value edits; no executable content, no input surface.
</threat_model>
<verification>
- grep favicon.svg / favicon.ico in apps/pwa/index.html — links present
- grep icon-maskable-512.png in apps/pwa/vite.config.ts — maskable references the separate file
- no manifest entry uses icon-512.png for maskable purpose
- grep src="/logo.svg" + alt="" in BrandSlot.tsx; no dangerouslySetInnerHTML
- pnpm --filter @familysync/pwa build — exits 0
- playwright-cli /login (logo, no layout shift, h1 intact) + / (manifest icons resolve)
</verification>
<success_criteria>
The real logo is wired through the BrandSlot seam with no LoginPage change, the full favicon set + theme-color are in index.html, the manifest references the proper separate maskable icon, the approved accent + logo border-radius are applied consistently, and the build is green.
</success_criteria>
<output>
Create `.planning/phases/17-ui-optimization-polish/17-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,151 @@
---
phase: 17-ui-optimization-polish
plan: 05
type: execute
wave: 2
depends_on: ["17-01"]
files_modified:
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/components/CredentialSheet.tsx
autonomous: true
requirements: [D-07, D-09]
must_haves:
truths:
- "A reachable Sign out control exists in SettingsSheet and clears the session then routes to /login"
- "Logout navigates to /login even if the logout API call fails (fire-and-best-effort)"
- "SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and CredentialSheet render centered on desktop and as bottom-sheets on phone"
artifacts:
- path: "apps/pwa/src/components/SettingsSheet.tsx"
provides: "Sign out control + handleSignOut + desktop-centering branch for SettingsSheet/ChangePasswordSheet/LinkOidcSheet"
contains: "handleSignOut"
- path: "apps/pwa/src/components/CredentialSheet.tsx"
provides: "Desktop-centering phone/desktop style branch on the dialog wrapper"
key_links:
- from: "apps/pwa/src/components/SettingsSheet.tsx"
to: "apps/pwa/src/api/client.ts"
via: "handleSignOut calls fetchLocalLogout()"
pattern: "fetchLocalLogout"
---
<objective>
Wire a reachable logout control into SettingsSheet (D-07) and fix dialog/sheet centering so all settings sheets render as a centered modal on desktop and an unchanged bottom-sheet on phone (D-09, the SettingsSheet-owned sheets + CredentialSheet).
D-07: logout is fully plumbed server-side (`POST /api/auth/local/logout` works, `fetchLocalLogout()` exists at client.ts:124) but no component calls it. Add a "Sign out" row to SettingsSheet that calls the existing client function then navigates to /login. UI wiring only — no backend work.
D-09: sheets currently use bottom-only positioning (`bottom: 0; left: 0; right: 0; maxWidth: 480px; margin: 0 auto`) which renders bottom-center on desktop instead of truly centered. Add a phone/desktop style branch to each sheet's outer dialog wrapper. (The admin reset-password sheet's centering lives in AdminPage.tsx and is handled in plan 17-06 to keep that file single-owner.)
Purpose: make logout reachable and make all settings sheets feel properly centered on desktop.
Output: a Sign out control + handleSignOut in SettingsSheet, and phone/desktop centering branches on SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and CredentialSheet dialog wrappers.
</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 the Sign out control and handleSignOut to SettingsSheet</name>
<files>apps/pwa/src/components/SettingsSheet.tsx</files>
<read_first>
- apps/pwa/src/components/SettingsSheet.tsx (the existing imports line for lucide icons + the api/client imports; the Account section + its section-divider pattern at lines ~372-376; the end of the sheet content where the logout row appends)
- apps/pwa/src/api/client.ts lines 124-134 (fetchLocalLogout — POST /api/auth/local/logout, credentials include, redirect manual; throws on non-ok)
- 17-UI-SPEC.md section "Workstream D" -> "D-07 — Logout control" + "Interaction Contract" -> "Logout flow (D-07)" (placement, style, fire-and-best-effort semantics, copy "Sign out")
- 17-PATTERNS.md section "apps/pwa/src/components/SettingsSheet.tsx" -> the logout row pattern, handleSignOut try/catch excerpt, section divider analog, accessible button row pattern
- 17-RESEARCH.md section "Common Pitfalls" -> Pitfall 4 (fetchLocalLogout error must not prevent navigation)
</read_first>
<action>
Add the LogOut lucide icon to SettingsSheet's existing lucide-react import, add fetchLocalLogout to the existing api/client import, and add a navigation primitive (react-router useNavigate, matching the project's router usage; if SettingsSheet has no router context use window.location.replace('/login') as the documented fallback per UI-SPEC).
Add a "Sign out" row at the BOTTOM of the sheet, after all existing sections, separated by the existing 1px var(--color-border-subtle) divider pattern (margin var(--space-4) top/bottom). The button: type button, onClick handleSignOut, aria-label "Sign out", full width, display flex, alignItems center, gap var(--space-2), minHeight 44px, background none, border none, cursor pointer, padding "var(--space-2) 0", fontSize var(--text-body-size), fontWeight 400, color var(--color-destructive), textAlign left, fontFamily var(--font-family-base). Content: a 16px LogOut icon (aria-hidden) + the text "Sign out". No confirmation dialog.
Implement handleSignOut as an async function that wraps fetchLocalLogout() in try/catch and calls navigate('/login') (or window.location.replace('/login')) in BOTH the success and catch branches — fire-and-best-effort per RESEARCH Pitfall 4 (the server-side cookie is cleared or already expired; navigation must always proceed so the sheet does not stay open on an API failure). Close the sheet as part of the flow if the existing close handler is in scope.
</action>
<verify>
<automated>grep -q 'handleSignOut' apps/pwa/src/components/SettingsSheet.tsx && grep -q 'fetchLocalLogout' apps/pwa/src/components/SettingsSheet.tsx && grep -q 'Sign out' apps/pwa/src/components/SettingsSheet.tsx && grep -q 'LogOut' apps/pwa/src/components/SettingsSheet.tsx && pnpm --filter @familysync/pwa build</automated>
</verify>
<acceptance_criteria>
- SettingsSheet imports LogOut (lucide) and fetchLocalLogout (api/client).
- A "Sign out" button (aria-label "Sign out", minHeight 44px, var(--color-destructive)) renders at the bottom of the sheet after a divider.
- handleSignOut calls fetchLocalLogout() inside try/catch and navigates to /login in BOTH branches.
- playwright-cli: open the settings sheet, confirm the "Sign out" control is visible with its aria-label; click it and confirm redirect to /login. Observation noted in SUMMARY.
- pnpm --filter @familysync/pwa build exits 0.
</acceptance_criteria>
<done>A reachable Sign out control in SettingsSheet calls the existing logout endpoint and always routes to /login (even on API failure), with a 44px tap target and destructive styling.</done>
</task>
<task type="auto">
<name>Task 2: Add desktop-centering / phone-bottom-sheet branch to all SettingsSheet-owned sheets and CredentialSheet</name>
<files>apps/pwa/src/components/SettingsSheet.tsx, apps/pwa/src/components/CredentialSheet.tsx</files>
<read_first>
- apps/pwa/src/components/SettingsSheet.tsx outer dialog wrapper (lines ~184-202: SettingsSheet), the ChangePasswordSheet wrapper (~598-616), the LinkOidcSheet wrapper (~894-909) — all currently bottom-only positioned
- apps/pwa/src/components/CredentialSheet.tsx (its outer role=dialog wrapper — apply the same branch)
- 17-UI-SPEC.md section "Workstream D" -> "D-09 — Dialog/sheet centering fix" (the exact phone vs desktop style contract) + section "Responsive Behavior" (Sheets/dialogs row)
- 17-PATTERNS.md section "apps/pwa/src/components/SettingsSheet.tsx" -> the phone/desktop style-branch excerpt + the "applies to ALL sheets" list + the Phone/Desktop Breakpoint shared pattern
</read_first>
<action>
Add an isPhone-style breakpoint check (window.matchMedia('(max-width: 767px)').matches, the established project pattern) and branch each sheet's outer role=dialog wrapper style:
Phone branch (unchanged bottom-sheet): position fixed, bottom 0, left 0, right 0, borderRadius "12px 12px 0 0", plus the existing background/boxShadow/padding/zIndex/fontFamily.
Desktop branch (centered modal): position fixed, top 50%, left 50%, transform "translate(-50%, -50%)", maxWidth 480px, width "calc(100% - var(--space-8, 32px))", maxHeight "calc(100dvh - var(--space-8, 32px))", overflowY auto, borderRadius 12px, boxShadow "0 8px 32px rgba(0,0,0,0.18)", plus the existing background/padding/zIndex/fontFamily. Remove bottom/left/right/margin auto from the desktop branch.
Apply this exact branch to the SettingsSheet wrapper, the ChangePasswordSheet wrapper, and the LinkOidcSheet wrapper (all inside SettingsSheet.tsx), and to the CredentialSheet.tsx dialog wrapper. The backdrop (position fixed, inset 0, var(--color-overlay)) is unchanged. The role=dialog, aria-modal, aria-label, Escape-closes, and focus-return invariants are unchanged — only the position CSS branches. The admin reset-password sheet is NOT in this plan (it lives in AdminPage.tsx; plan 17-06 owns it).
</action>
<verify>
<automated>grep -q 'translate(-50%, -50%)' apps/pwa/src/components/SettingsSheet.tsx && grep -q 'translate(-50%, -50%)' apps/pwa/src/components/CredentialSheet.tsx && grep -q 'max-width: 767px' apps/pwa/src/components/CredentialSheet.tsx && pnpm --filter @familysync/pwa build</automated>
</verify>
<acceptance_criteria>
- SettingsSheet.tsx outer wrappers for SettingsSheet, ChangePasswordSheet, and LinkOidcSheet each have a phone/desktop style branch; desktop uses top/left 50% + translate(-50%, -50%); phone keeps bottom 0 / left 0 / right 0.
- CredentialSheet.tsx dialog wrapper has the same phone/desktop branch.
- role=dialog / aria-modal / aria-label unchanged on every wrapper (only position CSS changed).
- playwright-cli @1280x720: SettingsSheet renders centered (top/left 50% transform); @390x844: renders as bottom-sheet. Observations noted in SUMMARY.
- pnpm --filter @familysync/pwa build exits 0.
</acceptance_criteria>
<done>SettingsSheet, ChangePasswordSheet, LinkOidcSheet, and CredentialSheet all render centered on desktop and as bottom-sheets on phone, with their dialog accessibility invariants unchanged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client UI -> existing logout endpoint | The Sign out control calls the already-implemented, Phase-19-verified POST /api/auth/local/logout via fetchLocalLogout(). No new endpoint, no new auth logic. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-17-05-01 | Elevation of Privilege | logout control (D-07) | mitigate | fetchLocalLogout() clears the local-session cookie server-side via the existing endpoint (BL-02 verified live in Phase 19); the client navigates to /login regardless of success/failure so a stale-cookie-with-logged-out-UI state cannot persist. This is UI wiring to an existing, already-verified endpoint — no new trust boundary. |
| T-17-05-02 | Tampering | sheet centering CSS | accept | Position-only CSS branch; no input, no executable content. No new threat above LOW. |
No new high-severity threats. D-07 reuses an existing verified endpoint; D-09 is position-only CSS.
</threat_model>
<verification>
- grep handleSignOut / fetchLocalLogout / "Sign out" / LogOut in SettingsSheet.tsx
- grep translate(-50%, -50%) in SettingsSheet.tsx and CredentialSheet.tsx
- pnpm --filter @familysync/pwa build — exits 0
- playwright-cli: Sign out reachable + redirects to /login; sheets centered @desktop, bottom-sheet @phone
</verification>
<success_criteria>
A reachable Sign out control clears the session and routes to /login (even on API failure), and all four settings sheets (SettingsSheet, ChangePasswordSheet, LinkOidcSheet, CredentialSheet) render centered on desktop and as bottom-sheets on phone with accessibility invariants intact.
</success_criteria>
<output>
Create `.planning/phases/17-ui-optimization-polish/17-05-SUMMARY.md` when done.
</output>
@@ -0,0 +1,180 @@
---
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>