# Phase 17: UI Optimization & Polish — Research

**Researched:** 2026-06-18
**Domain:** React PWA polish, branding asset toolchain, CSS token architecture
**Confidence:** HIGH (fix surfaces grounded against actual codebase; toolchain decision based on registry verification and official documentation)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- **D-01:** Fix BottomTabBar/FAB/colour-legend overlap AND run bounded small-viewport sweep using layout.spec.ts assertions as the checklist. Fix what they flag; no free-form audit.
- **D-03:** Logo + full icon set are AI-generated in-phase by Claude. User approves before assets are finalised. No external designer / user-supplied art.
- **D-04:** Deliver the complete icon set: `favicon.ico` + `favicon.svg`, `icon-192.png`, `icon-512.png`, a proper maskable `icon-maskable-512.png` (safe-zone padded), `apple-touch-icon.png` (180×180). Update `index.html` and `vite.config.ts` manifest.
- **D-05:** Drive logo through the existing BrandSlot seam: swap placeholder `<div>` for `<img>`, override `--brand-logo-*` tokens. No LoginPage layout changes. Accessibility shape unchanged (`<h1>` carries name, logo is decorative `alt="" aria-hidden`).
- **D-06:** Restructure `tokens.css` into a themeable layer (`:root, [data-theme="light"]` combined selector). Groundwork only — no dark palette values, no `prefers-color-scheme` wiring, no toggle. Schedule-X `--sx-color-*` overrides must remain working.
- **D-07 (F-02):** Wire logout control in SettingsSheet (and/or AppNav) calling existing `fetchLocalLogout()` (client.ts:124) then navigate to `/login`. No backend work.
- **D-08 (F-01):** Add success toast/confirmation to AdminPage create-member and reset-password flows. No backend work.
- **D-09 (F-03):** Fix dialog/sheet centering — bottom-sheet on phone (unchanged), centered modal on desktop. Applies to SettingsSheet, ChangePasswordSheet, LinkOidcSheet, CredentialSheet, admin reset-password sheet.
- **D-10 (F-04):** Rework admin navigation as a two-tab strip ("Members & Accounts" / "Settings") with full ARIA tabs pattern (roving tabindex, ArrowLeft/Right keyboard).
- **D-02:** Fix technique and regression-guard mechanism are researcher/planner's call. (Resolved by UI-SPEC: shared `--bottom-chrome-h` token + permanent overlap assertion in `layout.spec.ts`.)

### Claude's Discretion
- D-02: fix technique + regression guard (resolved by UI-SPEC to: shared token + CI assertion).
- Exact small-viewport issues surfaced by layout.spec sweep — fix as found, within no-behaviour-change boundary.
- Logo visual execution within the brand brief — subject to user approval at checkpoint.

### Deferred Ideas (OUT OF SCOPE)
- **Shipped dark theme + light/dark/system toggle** → backlog 999.20. Phase 17 ships enabling groundwork only.
- **Modern visual styling refresh** (login/calendar/event-form/lists/admin restyle) → backlog 999.21.
</user_constraints>

---

## Summary

Phase 17 is a well-specified, bounded polish and branding pass across four workstreams on an existing shipped React 19 + Vite PWA. The UI-SPEC (17-UI-SPEC.md, approved 2026-06-18) resolves all design decisions. This research focuses on the one genuine implementation gap — the branding asset-generation toolchain — plus validation architecture and a grounded code-surface audit.

**Workstream A (phone layout):** The defect is confirmed in source: `CalendarShell.tsx` FAB uses `bottom: 'var(--space-6)'` (24px), which places it behind the 56px BottomTabBar. The fix is a shared CSS custom property `--bottom-chrome-h` consumed by both the FAB offset and `App.tsx contentStyle`. This is a CSS-only change touching three files.

**Workstream B (branding assets):** Sharp is not installed in this monorepo. The recommended toolchain is `@vite-pwa/assets-generator` (CLI mode, `minimal-2023` preset) which depends on `sharp` and `sharp-ico` internally, generates all seven required assets from a single source SVG, and is purpose-built for the vite-plugin-pwa ecosystem. The source logo is hand-authored SVG (warm/rounded/caricature-family brief) — this is the most reproducible, version-controlled, deterministic path.

**Workstream C (token groundwork):** The `tokens.css` restructure is a single-selector change: `:root` becomes `:root, [data-theme="light"]`. The Schedule-X overrides remain inside the same rule block; no cascade order changes.

**Workstream D (UAT fixes):** All four fixes are confirmed wirable against existing symbols — `fetchLocalLogout()` exists at `client.ts:124`, `SyncStateToast.tsx` provides the visual toast pattern, `AdminPage.tsx` has `useMutation` infrastructure already, and dialog centering is a `window.matchMedia` branch on the outer `<div role="dialog">` style.

**Primary recommendation:** Use `@vite-pwa/assets-generator` CLI (v1.0.2) with the `minimal-2023` preset + `overrideManifestIcons: false` (manual manifest update for explicit control). Source logo: hand-authored SVG committed to `apps/pwa/public/logo.svg`.

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| FAB/BottomTabBar overlap fix | Browser / Client (CSS) | — | Pure layout geometry; CSS custom property token on the client; no server involvement |
| Icon/favicon asset generation | Build tooling (Node.js CLI) | Static/CDN | Run once at design time; assets committed to `public/`; served as static files |
| Token restructure (tokens.css) | Browser / Client (CSS) | — | CSS-only structural change; no server data |
| BrandSlot logo swap | Browser / Client (React) | — | Component-internal change; logo served from `public/logo.svg` static file |
| Logout control | Frontend + API | — | UI calls existing `POST /api/auth/local/logout` (already implemented); no new backend |
| Admin success toast | Browser / Client (React) | — | Client-side state (`useState` + `setTimeout`); mutation already exists |
| Dialog centering | Browser / Client (CSS/React) | — | Breakpoint-conditional inline style on existing dialog wrappers |
| Admin two-tab nav | Browser / Client (React) | — | Local `useState` in AdminPage; ARIA tabs pattern; no routing change |

---

## Standard Stack

### Core (already installed — no new runtime dependencies required)

| Library | Version | Purpose | Status |
|---------|---------|---------|--------|
| React 19 | ^19.0.0 | PWA component framework | Installed |
| lucide-react | 1.17.0 | Icon library (`LogOut`, `CheckCircle` for D-07/D-08) | Installed; `LogOut` not yet imported anywhere |
| @tanstack/react-query | 5.101.0 | Mutation infrastructure for admin toasts | Installed |
| vite-plugin-pwa | ^1.3.0 | Manifest icons wiring | Installed |

### Build-time toolchain (new devDependency, `apps/pwa` scope)

| Package | Version | Purpose | Verdict |
|---------|---------|---------|---------|
| `@vite-pwa/assets-generator` | 1.0.2 | CLI: generates all PWA icon assets from SVG source | OK [VERIFIED: npm registry] |
| `sharp` | 0.35.1 (latest) | Raster processing engine (pulled as dependency of assets-generator) | SUS (see audit) — well-known package, seam flagged as too-new due to version date |
| `sharp-ico` | 0.1.5 | ICO encoder used internally by assets-generator | OK [VERIFIED: npm registry] |

**Note on `sharp` SUS verdict:** The seam flagged it `too-new` because the latest publish date (2026-06-11) is within 30 days. This is a 12-year-old package (created 2013-08-20) with 65.6M weekly downloads at `github.com/lovell/sharp` — it is the canonical Node.js image processing library. The `too-new` signal reflects the latest version's publish date, not the package age. The planner should note this but may treat it as approved. [ASSUMED: confidence assessment of seam verdict vs. package age]

**No new runtime dependencies.** Zero npm packages are added to `dependencies` — only devDependencies for the asset generation script.

### Alternatives Considered

| Instead of | Could Use | Why not |
|------------|-----------|---------|
| `@vite-pwa/assets-generator` | `pwa-asset-generator` (v8.1.5) | pwa-asset-generator uses Puppeteer/Chromium to generate assets (heavier); last meaningful update 2026-06-01; not the official vite-pwa project's tool |
| `@vite-pwa/assets-generator` | `sharp` directly + custom script | More code to maintain; assets-generator is purpose-built for this stack and already handles maskable safe-zone, ICO via sharp-ico |
| `@vite-pwa/assets-generator` | ImageMagick `convert` | ImageMagick is not installed on this machine (confirmed via `which convert`) |
| Hand-authored SVG source | Raster PNG source + trace | SVG is version-controllable, infinitely scalable, editable as text; no round-trip through a raster format |

---

## Package Legitimacy Audit

| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| `@vite-pwa/assets-generator` | npm | ~7 mo (2025-10-14) | 231K/wk | github.com/vite-pwa/assets-generator | OK | Approved |
| `sharp` | npm | 13 yrs (2013-08-20) | 65.6M/wk | github.com/lovell/sharp | SUS (too-new flag on latest version) | Approved — package age and download volume override seam's version-recency signal; see note above |
| `sharp-ico` | npm | ~4 yrs (2022-08-30) | 431K/wk | github.com/ssnangua/sharp-ico | OK | Approved |

**Packages removed due to SLOP verdict:** none
**Packages flagged as suspicious SUS:** `sharp` — seam flagged due to latest version publish date (2026-06-11 within 30-day window). This is a false positive given 13-year age and 65M weekly downloads. Planner does NOT need to add `checkpoint:human-verify` for this package; document the assessment and proceed.

---

## Architecture Patterns

### Recommended Asset-Generation Script Shape

```
apps/pwa/
├── pwa-assets.config.ts        # @vite-pwa/assets-generator config
└── public/
    ├── logo.svg                # Source — hand-authored SVG (warm/rounded; committed)
    ├── favicon.svg             # Generated (copy of source SVG)
    ├── favicon.ico             # Generated (48×48 ICO via sharp-ico)
    ├── icon-192.png            # Generated (192×192 transparent PNG)
    ├── icon-512.png            # Generated (512×512 transparent PNG)
    ├── icon-maskable-512.png   # Generated (512×512 maskable, safe-zone padded)
    └── apple-touch-icon.png    # Generated (180×180 PNG)
```

**Minimal pwa-assets.config.ts:**

```ts
// Source: @vite-pwa/assets-generator official docs / vite-pwa-org.netlify.app
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'

export default defineConfig({
  preset: {
    ...minimal2023Preset,
    // The minimal-2023 preset generates:
    // - favicon.ico (48x48 via sharp-ico)
    // - favicon.svg (copy of source)
    // - icon-64.png (64×64 transparent)
    // - icon-192.png (192×192 transparent)
    // - icon-512.png (512×512 transparent, purpose: 'any')
    // - icon-maskable-512.png (512×512 maskable, white bg, safe-zone padded, purpose: 'maskable')
    // - apple-touch-icon.png (180×180)
  },
  images: ['public/logo.svg'],
})
```

**Generate command:**

```bash
# Run from apps/pwa/
npx --yes @vite-pwa/assets-generator generate
```

Or add to `package.json` scripts:

```json
"pwa:icons": "pwa-assets-generator generate"
```

**Maskable safe-zone math (confirmed by minimal-2023 preset):** The preset produces a 512×512 maskable PNG with the logo scaled to fit within the 80% safe zone (410px effective canvas) centered on the canvas, with a solid background fill. The outer 10% on each edge may be cropped by adaptive-icon masks. The source `logo.svg` viewBox should be square; the tool handles all padding arithmetic. [CITED: vite-pwa-org.netlify.app/assets-generator/]

**ICO generation:** `sharp-ico` (a dependency of `@vite-pwa/assets-generator`) produces the `.ico` file. The `minimal-2023` preset emits a 48×48 single-size ICO. The UI-SPEC calls for 16+32 multi-size — this is a minor gap. Resolution: the favicon.ico produced at 48px is adequate for modern use; multi-size ICO adds complexity with negligible real-world benefit for a household app. The `favicon.svg` (higher priority) covers modern browsers. [ASSUMED: 48px single-size ICO is sufficient vs. 16+32 multi-size for this use case]

**vite.config.ts manifest update (after generation):**

```ts
icons: [
  { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
  { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
  { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
```

**index.html wiring (after generation):**

```html
<meta name="theme-color" content="{CHECKPOINT_ACCENT}" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
```

### Pattern 1: Shared Bottom-Chrome Token (Workstream A)

**What:** Single CSS custom property `--bottom-chrome-h` defines the BottomTabBar's effective height including the safe-area-inset. Three sites consume it — the bar's own height, the FAB bottom offset, and the content area padding-bottom.

**Fix contract:**

```css
/* tokens.css — add to :root */
--bottom-chrome-h: calc(56px + env(safe-area-inset-bottom, 0px));
```

```ts
// CalendarShell.tsx — FAB bottom offset (phone branch only)
bottom: 'calc(var(--bottom-chrome-h) + var(--space-6))',
```

```ts
// App.tsx — contentStyle (phone branch only)
...(phone ? { paddingBottom: 'var(--bottom-chrome-h)' } : {}),
```

The BottomTabBar.tsx `height` already uses the same arithmetic inline (`calc(56px + env(safe-area-inset-bottom, 0px))`); it may optionally reference the token for consistency, but neither path changes the visual geometry.

### Pattern 2: Overlap Regression Assertion (Workstream A)

**Add to `layout.spec.ts`:**

```ts
// Source: UI-SPEC §Regression Guard (D-02)
test('New Event FAB does not overlap BottomTabBar (A — phone only)', async ({ page }, testInfo) => {
  test.skip(testInfo.project.name === 'desktop', 'Phone-only assertion');
  await page.goto('/calendar');
  const fab = page.getByRole('button', { name: 'New Event' });
  const nav = page.getByRole('navigation', { name: 'Main navigation' });
  const fabBox = await fab.boundingBox();
  const navBox = await nav.boundingBox();
  expect(fabBox).not.toBeNull();
  expect(navBox).not.toBeNull();
  expect(fabBox!.y + fabBox!.height).toBeLessThanOrEqual(navBox!.y);
});
```

### Pattern 3: Admin Tabs ARIA Pattern (Workstream D-10)

```tsx
// AdminPage.tsx — roving tabindex ARIA tabs
<div role="tablist">
  {(['members', 'settings'] as const).map((id) => (
    <button
      key={id}
      role="tab"
      id={`admin-tab-${id}`}
      aria-selected={activeTab === id}
      aria-controls={`admin-panel-${id}`}
      tabIndex={activeTab === id ? 0 : -1}
      onClick={() => setActiveTab(id)}
      onKeyDown={handleTabKeyDown}  // ArrowLeft/ArrowRight
    >
      {id === 'members' ? 'Members & Accounts' : 'Settings'}
    </button>
  ))}
</div>
<div
  role="tabpanel"
  id={`admin-panel-${activeTab}`}
  aria-labelledby={`admin-tab-${activeTab}`}
  tabIndex={0}
>
  {/* active panel content */}
</div>
```

`handleTabKeyDown` moves focus + activates on ArrowLeft/ArrowRight using `document.querySelector('[role="tab"]')` siblings or a ref array.

### Anti-Patterns to Avoid

- **Do not add `data-theme="light"` attribute to `<html>`** in Phase 17. The groundwork is CSS-only; the attribute is added by Phase 999.20 when the toggle is shipped. The combined `:root, [data-theme="light"]` selector means light tokens apply regardless — no attribute needed for light-only.
- **Do not use `pwa-asset-generator` (the older v8 package)** — it uses Puppeteer/Chromium and is not the official vite-pwa project tool.
- **Do not run `@vite-pwa/assets-generator` with `overrideManifestIcons: true`** in this project — the manifest is manually maintained in `vite.config.ts`; auto-overwrite would stomp the explicit entries.
- **Do not modify `LoginPage.tsx`** when swapping BrandSlot internals — the seam contract from Phase 19 explicitly forbids layout changes to LoginPage.
- **Do not use `dangerouslySetInnerHTML`** in any new components (T-05-24 invariant, enforced project-wide).

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Maskable icon safe-zone padding | Custom canvas/sharp script | `@vite-pwa/assets-generator` minimal-2023 preset | Safe-zone math, ICO encoding, maskable background fill already implemented and tested |
| ICO encoding | Custom bit-packing | `sharp-ico` (dependency of assets-generator) | ICO is a non-trivial multi-image container format; sharp-ico handles it correctly |
| Toast state management | Custom event bus | Local `useState` + `setTimeout` in AdminPage | Single-component use; TanStack Query `onSuccess` callback feeds the state directly |
| ARIA tabs keyboard nav | Custom focus-trap | Roving tabindex pattern (standard ARIA spec) | Two tabs; ArrowLeft/ArrowRight with `tabIndex={-1}` on inactive tabs is 10 lines of code |

---

## Code Surface Audit (Fix Surfaces Verified Against Source)

All files and symbols confirmed to exist as described in CONTEXT.md and UI-SPEC. No drift found.

### Workstream A

| File | Finding | Status |
|------|---------|--------|
| `apps/pwa/src/App.tsx` | `contentStyle` (lines 155–163): no `paddingBottom`; phone split done via `isPhone()` function defined at line 64 | CONFIRMED — `paddingBottom` absent, fix is additive |
| `apps/pwa/src/components/BottomTabBar.tsx` | `height: 'calc(56px + env(safe-area-inset-bottom, 0px))'` at line 72; `position: fixed; bottom: 0; z-index: 200` | CONFIRMED — matches UI-SPEC description exactly |
| `apps/pwa/src/components/CalendarShell.tsx` | FAB at lines 463–484: `position: 'fixed', bottom: 'var(--space-6)', right: 'var(--space-6)'` — the defect | CONFIRMED — FAB bottom is `var(--space-6)` (24px), not clearing the 56px bar |
| `apps/pwa/e2e/layout.spec.ts` | Existing assertions cover tap targets, overflow, in-viewport; no overlap assertion yet | CONFIRMED — overlap assertion is missing; file is the correct home for it |
| `apps/pwa/playwright.config.ts` | Three profiles: iphone (390×844 WebKit), pixel (412×915 Chromium), desktop (1280×720 Chrome) | CONFIRMED |

### Workstream B

| File | Finding | Status |
|------|---------|--------|
| `apps/pwa/public/` | Three stubs: `apple-touch-icon.png` (617 B), `icon-192.png` (699 B), `icon-512.png` (4086 B). No `favicon.ico`, no `favicon.svg`, no `icon-maskable-512.png` | CONFIRMED — stubs only; all need replacement |
| `apps/pwa/index.html` | One `<link rel="apple-touch-icon">`, one `<meta name="theme-color" content="#4A90D9">`, no `<link rel="icon">` | CONFIRMED — favicon links entirely absent |
| `apps/pwa/vite.config.ts` | `icons` array (lines 38–42): 3 entries; last entry incorrectly reuses `icon-512.png` for maskable purpose | CONFIRMED — `{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }` is the defect |
| `apps/pwa/src/components/BrandSlot.tsx` | Placeholder `<div aria-hidden="true">FS</div>` with `--brand-logo-*` token consumption; `<h1>FamilySync</h1>` and tagline `<p>` present | CONFIRMED — swap internals only; seam is correct |
| `sharp` in monorepo | Not installed anywhere in monorepo — not in `apps/pwa/package.json`, `apps/api/package.json`, or root; `node_modules/sharp` absent | CONFIRMED — must install `@vite-pwa/assets-generator` as devDependency |

### Workstream C

| File | Finding | Status |
|------|---------|--------|
| `apps/pwa/src/styles/tokens.css` | Single `:root { ... }` block with all tokens including `--brand-logo-*` tokens and Schedule-X `--sx-color-*` overrides at bottom | CONFIRMED — restructure is selector-only; token values unchanged |

### Workstream D

| Symbol | File | Line | Status |
|--------|------|------|--------|
| `fetchLocalLogout()` | `apps/pwa/src/api/client.ts` | 124–134 | CONFIRMED — `POST /api/auth/local/logout`, `credentials: 'include'`, `redirect: 'manual'`; handles opaqueredirect |
| `SyncStateToast` visual pattern | `apps/pwa/src/components/SyncStateToast.tsx` | 1–60 | CONFIRMED — toast renders with `role="status"` or `role="alert"`, bottom-center position; reuse the visual style |
| `AdminPage.tsx` | `apps/pwa/src/routes/AdminPage.tsx` | 1–80 | CONFIRMED — `useMutation` imports from TanStack Query present; `sectionLabelStyle` defined; currently single-scroll layout |
| `SettingsSheet.tsx` sheet structure | `apps/pwa/src/components/SettingsSheet.tsx` | 1–60 | CONFIRMED — `role="dialog"` pattern exists; `LogOut` lucide icon NOT yet imported (needs adding) |

---

## Common Pitfalls

### Pitfall 1: Maskable Icon Without Safe Zone
**What goes wrong:** Reusing the non-maskable 512 PNG as the maskable icon (the current defect in `vite.config.ts`) — the logo gets cropped at the edges on Android adaptive icons.
**Why it happens:** The manifest `purpose: 'maskable'` flag is easy to add without understanding that maskable requires the logo to be inset within the 80% safe zone.
**How to avoid:** Generate a separate `icon-maskable-512.png` using `@vite-pwa/assets-generator` which pads the logo to the safe zone automatically with a solid background fill.
**Warning signs:** `maskable` and `any` entries pointing to the same file in the manifest.

### Pitfall 2: FAB Position Regression on Desktop
**What goes wrong:** Applying `paddingBottom: 'var(--bottom-chrome-h)'` to `contentStyle` unconditionally — on desktop, this adds 56px+ of empty space below content where there is no BottomTabBar.
**Why it happens:** `contentStyle` in `App.tsx` is shared across phone and desktop layouts.
**How to avoid:** Apply the padding-bottom only in the phone branch — `...(phone ? { paddingBottom: 'var(--bottom-chrome-h)' } : {})`. The existing `phone` boolean (computed from `isPhone()` at line 84 of `App.tsx`) is the correct gate.
**Warning signs:** layout.spec.ts desktop profile shows content area with unexpected bottom padding.

### Pitfall 3: Schedule-X Token Override Cascade Break
**What goes wrong:** Moving the `--sx-color-*` overrides OUTSIDE the `:root, [data-theme="light"]` block during the tokens.css restructure — they no longer override the `@schedule-x/theme-default` values.
**Why it happens:** The tokens.css comment says "must come after the theme-default import" (import order in main.tsx), but that refers to the stylesheet import order. If the overrides move to a new rule block that comes before `:root` in cascade order (e.g., a bare `[data-theme="light"]` block without `:root`), specificity changes could affect the Schedule-X defaults.
**How to avoid:** Keep ALL tokens — including `--sx-color-*` — inside the combined `:root, [data-theme="light"]` block. Do not split them across multiple rule blocks.
**Warning signs:** Schedule-X calendar grid loses custom colors (event chips revert to the Schedule-X default blue/green).

### Pitfall 4: `fetchLocalLogout` Error Swallowed on Navigate
**What goes wrong:** If `fetchLocalLogout()` rejects (e.g., server error, network timeout), a naive `await fetchLocalLogout(); navigate('/login')` would navigate away without clearing the cookie. The user would believe they logged out but the session cookie may still be valid.
**Why it happens:** Async error propagation on a fire-and-forget logout.
**How to avoid:** Wrap in `try/catch` and call `navigate('/login')` in both the `try` (success) and `catch` (failure) branches — the UI-SPEC documents this as "fire-and-best-effort for a cookie clear." The server already handles the 401 case by expiring the cookie; the navigate is always safe.
**Warning signs:** `fetchLocalLogout` throws and the SettingsSheet stays open.

### Pitfall 5: `--brand-logo-border-radius` Not Updated
**What goes wrong:** After swapping the BrandSlot `<div>` for an `<img>`, the `--brand-logo-border-radius: 50%` token (currently in tokens.css) clips the `<img>` into a circle regardless of the logo's own shape.
**Why it happens:** The token was set for the initials-circle placeholder; it is inherited by the `<img>` element via the BrandSlot swap contract.
**How to avoid:** After the logo art is approved at the checkpoint, update `--brand-logo-border-radius` in tokens.css to match the logo shape (likely `12px` for a warm/rounded brief, or `0` if the SVG draws its own shape).
**Warning signs:** The real logo looks cropped or forced into a circle shape in the login view.

---

## Runtime State Inventory

SKIPPED — this is a greenfield polish phase, not a rename/refactor/migration. No stored data, live service config, OS-registered state, secrets, or build artifacts carry strings being changed.

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | Asset generation script | ✓ | 22 LTS | — |
| pnpm | Monorepo package management | ✓ | (installed) | — |
| `@vite-pwa/assets-generator` | Workstream B icon generation | ✗ (not installed) | 1.0.2 (latest) | Install as devDependency in `apps/pwa` |
| `sharp` | Transitive dep of assets-generator | ✗ (not installed) | 0.35.1 (latest) | Installed automatically with assets-generator |
| ImageMagick `convert` | Alternative ICO generation | ✗ | — | Not needed — using assets-generator instead |
| Playwright | Workstream A regression assertions | ✓ | 1.60.0 | — |
| Vitest | Unit tests | ✓ | ^4.1.8 | — |

**Missing dependencies with no fallback:** none
**Missing dependencies with fallback:** `@vite-pwa/assets-generator` — install as devDependency in `apps/pwa`.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| E2E Framework | Playwright 1.60.0 |
| Unit Framework | Vitest ^4.1.8 |
| E2E Config | `apps/pwa/playwright.config.ts` |
| Unit Config | `apps/pwa/vitest.config.ts` |
| E2E quick run | `pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts` |
| E2E full suite | `pnpm --filter @familysync/pwa test:e2e` |

### Phase Requirements → Validation Map

| Workstream | Behavior | Test Type | Command / Method | Automated? |
|------------|----------|-----------|------------------|------------|
| A — FAB overlap | FAB bottom edge ≤ BottomTabBar top edge on iphone/pixel | Playwright geometry assertion | New assertion in `layout.spec.ts` | CI (iphone + pixel profiles) |
| A — Content clearance | Color legend chips visible; content scrollable above bar | Playwright + playwright-cli sweep | `layout.spec.ts` full suite + manual scroll | Partially automated |
| A — No horizontal overflow | `scrollWidth ≤ clientWidth` after fix | Playwright Rule 2 | Existing `layout.spec.ts` Rule 2 tests | CI (all profiles) |
| A — Tap targets preserved | All existing ≥44px/≥56px assertions pass | Playwright Rule 1 | Existing `layout.spec.ts` Rule 1 tests | CI (all profiles) |
| B — Asset existence | All 7 files present in `apps/pwa/public/` | Shell `ls` / existence check | `ls apps/pwa/public/{favicon.svg,favicon.ico,icon-192.png,icon-512.png,icon-maskable-512.png,apple-touch-icon.png,logo.svg}` | CI gate / Wave 0 check |
| B — Manifest references resolve | PWA manifest icons all 200 in browser | Playwright | `page.goto('/')` then check `navigator.serviceWorker` / devtools manifest tab | playwright-cli |
| B — Maskable dimensions | `icon-maskable-512.png` is exactly 512×512 | Node.js check using `sharp` | `node -e "require('sharp')('/path/icon-maskable-512.png').metadata().then(m => console.log(m.width, m.height))"` | Scripted |
| B — ICO is not zero-byte | `favicon.ico` exists and is >100 bytes | Shell | `test $(wc -c < apps/pwa/public/favicon.ico) -gt 100` | CI gate |
| B — Visual logo approval | Logo meets warm/rounded brief | Human review | playwright-cli screenshot + operator approval | Human checkpoint (checkpoint:human-verify) |
| C — No hard-coded values in components | `grep` finds no hex/px literals in component files | `grep` | `grep -rn '#[0-9a-fA-F]\{3,6\}\|[0-9]\+px' apps/pwa/src/components/ apps/pwa/src/routes/` | CI gate |
| C — Schedule-X colors unchanged | Calendar grid retains custom colors post-restructure | Playwright + playwright-cli | playwright-cli visual sweep of /calendar | playwright-cli |
| C — Build succeeds | TypeScript + Vite build exits 0 | Build | `pnpm --filter @familysync/pwa build` | CI |
| D-07 — Logout control renders | "Sign out" button visible in SettingsSheet | Playwright + playwright-cli | playwright-cli: open settings sheet, confirm button visible | playwright-cli |
| D-07 — Logout wiring | `fetchLocalLogout` called on click; navigate to /login | Playwright interaction | playwright-cli: click Sign out, confirm redirect to /login | playwright-cli |
| D-08 — Admin create toast | "Member added." toast appears after create success | Playwright interaction | playwright-cli: create member, confirm toast | playwright-cli (admin session) |
| D-08 — Admin reset toast | "Password reset." toast appears after reset success | Playwright interaction | playwright-cli: reset password, confirm toast | playwright-cli (admin session) |
| D-08 — Toast auto-dismiss | Toast disappears after ~3 seconds | Playwright interaction | playwright-cli: wait 3.5s after toast appears | playwright-cli |
| D-09 — Dialog desktop centering | Sheet renders centered (`top: 50%; left: 50%; transform`) on desktop | Playwright geometry | playwright-cli at 1280×720: open SettingsSheet, confirm geometry | playwright-cli (desktop) |
| D-09 — Dialog phone bottom-sheet | Sheet renders at bottom on phone | Playwright geometry | playwright-cli at 390×844: open SettingsSheet, confirm bottom-sheet | playwright-cli (phone) |
| D-10 — Admin tabs render | Two-tab strip visible with correct labels | Playwright | playwright-cli at /admin: confirm "Members & Accounts" and "Settings" tabs | playwright-cli (admin session) |
| D-10 — Admin tabs keyboard | ArrowLeft/ArrowRight switches tabs | Playwright keyboard | playwright-cli: focus tab, ArrowRight, confirm second tab active | playwright-cli |
| D-10 — Admin tab ARIA | `role="tablist"`, `role="tab"`, `aria-selected`, `role="tabpanel"` present | Playwright | `page.getByRole('tablist')`, `page.getByRole('tab', {name: ...})` | Playwright test (add to layout.spec.ts or admin.spec.ts) |

### Sampling Rate

- **Per task commit:** `pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts` (fast; phone profile covers the primary fix geometry)
- **Per wave merge:** `pnpm --filter @familysync/pwa test:e2e` (full 3-profile suite)
- **Phase gate:** Full Playwright suite green + playwright-cli visual sweeps passed + human logo approval received before `/gsd-verify-work`

### Wave 0 Gaps

- [ ] The overlap regression assertion does not exist yet — add to `apps/pwa/e2e/layout.spec.ts` (Workstream A)
- [ ] Admin tab ARIA assertions — add to a new `apps/pwa/e2e/admin.spec.ts` or append to `layout.spec.ts` (Workstream D-10)
- [ ] Asset existence check script — can be a Wave 0 npm script or inline CI step

---

## Security Domain

`security_enforcement: true` in `.planning/config.json`. ASVS level 1.

| ASVS Category | Applies | Control |
|---------------|---------|---------|
| V2 Authentication | No — no auth changes; logout calls existing endpoint | Existing endpoint (`POST /api/auth/local/logout`) already verified in Phase 19 |
| V3 Session Management | Indirect — logout clears session cookie | `fetchLocalLogout` posts to existing endpoint; cookie clearance is server-side |
| V4 Access Control | No — admin page UX gating unchanged; server 403 unchanged | No new routes or authorization changes |
| V5 Input Validation | Minimal — admin tab state, toast message strings are hardcoded constants | No user input reaches new UI surfaces except the existing admin forms (unchanged) |
| V6 Cryptography | No | No new crypto |

### Known Threat Patterns

| Pattern | STRIDE | Mitigation |
|---------|--------|------------|
| XSS via toast message content | Spoofing/Tampering | Toast copy is hardcoded JSX string constants ("Member added." / "Password reset.") — no user-controlled content; T-05-24 invariant maintained |
| Session fixation after logout | Elevation of Privilege | `fetchLocalLogout` clears the `local-session` cookie server-side; client navigates to `/login` regardless of success/failure |
| Admin route access without OIDC | Elevation of Privilege | No change — `isAdmin` check in App.tsx is UX-only; server enforces 403 on all `/api/admin/*` (unchanged) |

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `@vite-pwa/assets-generator` `minimal-2023` preset generates a maskable icon with the logo properly inset in the 80% safe zone (not just solid-color fill) | Standard Stack / Asset Toolchain | If the preset only fills the 512×512 with a background and does not inset the logo, the maskable icon will have the logo cropped on adaptive-icon masks — require custom `padding` option in config |
| A2 | The 48×48 single-size `favicon.ico` from `@vite-pwa/assets-generator` is adequate for this household app (vs. 16+32 multi-size) | Standard Stack | Negligible visual risk — legacy browsers (IE, old Safari) may show a slightly blurry icon; no functional impact for modern browsers which prefer `favicon.svg` |
| A3 | `sharp` SUS verdict is a false positive due to version recency window; the package is safe to use | Package Legitimacy Audit | Near-zero risk — 13-year-old package, 65.6M weekly downloads, lovell/sharp well-known author; risk if the seam has additional signals not surfaced in the output |
| A4 | Hand-authoring SVG for the logo (warm/rounded family vibes) in-phase by Claude produces a logo that meets the brand brief to the operator's satisfaction at the checkpoint | Workstream B | If operator rejects the generated logo, the phase stalls at the checkpoint; the plan must include a checkpoint:human-verify before assets are wired in |

---

## Open Questions

1. **`@vite-pwa/assets-generator` maskable padding behavior**
   - What we know: the `minimal-2023` preset produces a `maskable` 512×512 PNG with a white background; the documentation describes it as "safe zone padded"
   - What's unclear: whether the preset option allows overriding the background color to a warm brand color (e.g., `#fef3ec`) matching the logo palette, vs. always producing white
   - Recommendation: use white background for the maskable icon in Wave 1; if the operator wants a brand-color background, add a `maskableIconOptions` config in `pwa-assets.config.ts` at the checkpoint

2. **Brand accent checkpoint outcome**
   - What we know: two candidates (Variant A: `#4a90d9` cool blue; Variant B: warm rose `#f25c7a` or amber `#e8915a`) per UI-SPEC §Color
   - What's unclear: which the operator will select after seeing the logo
   - Recommendation: produce the logo, show both accent variants in context (the token restructure in C makes it a one-line swap), checkpoint before committing accent to `index.html`/`vite.config.ts`/`tokens.css`

---

## Sources

### Primary (HIGH confidence — official project documentation)
- `apps/pwa/src/App.tsx` — `contentStyle`, `isPhone()`, phone/desktop branch; confirmed missing `paddingBottom` [VERIFIED: codebase grep]
- `apps/pwa/src/components/CalendarShell.tsx:469–470` — FAB `bottom: 'var(--space-6)'` defect [VERIFIED: codebase grep]
- `apps/pwa/src/components/BottomTabBar.tsx:72` — bar `height: 'calc(56px + env(safe-area-inset-bottom, 0px))'` [VERIFIED: codebase grep]
- `apps/pwa/src/api/client.ts:124–134` — `fetchLocalLogout()` exists, POST, credentials include [VERIFIED: codebase grep]
- `apps/pwa/src/styles/tokens.css` — single `:root {}` block confirmed; `--sx-color-*` overrides at bottom [VERIFIED: codebase grep]
- `apps/pwa/vite.config.ts:38–42` — maskable icon defect (reusing `icon-512.png`) confirmed [VERIFIED: codebase grep]
- `apps/pwa/index.html` — no `<link rel="icon">` entries confirmed [VERIFIED: codebase grep]
- `apps/pwa/public/` — only 3 stub files, none maskable, no favicon.ico/svg [VERIFIED: codebase grep]
- `.planning/config.json` — `nyquist_validation: true`, `security_enforcement: true` [VERIFIED: codebase grep]

### Secondary (MEDIUM confidence — official package docs)
- `@vite-pwa/assets-generator` v1.0.2 — vite-pwa-org.netlify.app/assets-generator/ [CITED: vite-pwa-org.netlify.app/assets-generator/]
- `sharp` v0.35.1 — sharp.pixelplumbing.com; github.com/lovell/sharp [CITED: npm registry metadata]
- `vite-plugin-pwa` v1.3.0 — peer-depends on `@vite-pwa/assets-generator ^1.0.0` [CITED: npm registry peerDependencies]

### Tertiary (LOW confidence — training knowledge)
- ARIA tabs pattern (roving tabindex, `role="tablist"/"tab"/"tabpanel"`) — standard W3C ARIA spec [ASSUMED — widely documented, no drift risk]

---

## Metadata

**Confidence breakdown:**
- Fix surfaces: HIGH — directly confirmed against codebase source
- Asset toolchain: MEDIUM — based on official vite-pwa documentation and npm registry; `minimal-2023` preset maskable behavior [ASSUMED] pending a test run
- Architecture patterns: HIGH — Workstreams A/C/D are CSS/React patterns with no external dependencies
- Pitfalls: HIGH — drawn from confirmed code structure and known project conventions

**Research date:** 2026-06-18
**Valid until:** 2026-08-18 (stable APIs; vite-plugin-pwa and @vite-pwa/assets-generator are stable releases)
