---
phase: 17
slug: ui-optimization-polish
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-18
---

# Phase 17 — UI Design Contract: UI Optimization & Polish

> Visual and interaction contract for four bounded workstreams:
> A — phone-layout overlap fix + small-viewport sweep,
> B — branding assets (logo, favicon, PWA icon set),
> C — theme-token groundwork (light-only, semantic layer),
> D — UAT-surfaced UI fixes (logout control, admin success feedback,
>     dialog/sheet centering, admin two-tab nav).
>
> Generated by gsd-ui-researcher. Consume before planning or executing.

---

## Context & Approach

This is a **polish + branding + theme-token-groundwork** phase on an **existing shipped**
React 19 + Vite PWA. NOT greenfield, NOT a redesign. All design decisions extend the
established token system in `apps/pwa/src/styles/tokens.css`.

The no-hard-coded-values invariant is a hard constraint: **all hex/px values live in
`tokens.css` as CSS custom properties; component files reference variables only.**

The existing Phase 19 UI-SPEC (approved 2026-06-16) establishes the design-system
baseline this phase builds on. No new tokens are introduced except the layout-chrome
token added in Workstream A.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (existing CSS custom properties) |
| Preset | not applicable |
| Component library | none (hand-rolled inline `React.CSSProperties`, project convention) |
| Icon library | lucide-react (already installed) |
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif (`var(--font-family-base)`) |

Source: `apps/pwa/src/styles/tokens.css` — pre-populated from codebase scan.
Pattern baseline: existing components (SettingsSheet, AdminPage, CredentialSheet, CalendarShell).

---

## Spacing Scale

No new spacing tokens are introduced. Phase 17 uses the existing 4px-based scale unchanged.

| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| --space-1 | 4px | Icon gaps, tight label margins |
| --space-2 | 8px | Tab strip inner gap, section label bottom margin |
| --space-3 | 12px | Input row padding, tab content gap |
| --space-4 | 16px | Toast horizontal padding, button padding, between-field gap |
| --space-6 | 24px | Sheet/card padding, FAB clearance (base of calc expression) |
| --space-8 | 32px | Section gap in admin two-tab content |
| --space-12 | 48px | Page-level top/bottom padding |

**New layout-chrome token (Workstream A):**

```css
/* Added to tokens.css :root alongside existing spacing scale */
--bottom-chrome-h: calc(56px + env(safe-area-inset-bottom, 0px));
```

This single token is the source of truth for BottomTabBar height. It is consumed
by the FAB offset (`bottom: calc(var(--bottom-chrome-h) + var(--space-6))`) and the
phone content padding (`padding-bottom: var(--bottom-chrome-h)`). All three sites
agree via one value.

Exceptions:
- FAB: `width: 56px; height: 56px` (Rule 1 minimum: ≥56×56px per layout.spec.ts). Not a
  spacing-scale value — this is the FAB's own intrinsic size.
- All interactive elements: `minHeight: 44px; minWidth: 44px` (WCAG 2.5.5 Touch Target).
- Admin two-tab strip: tab items use `minHeight: 44px` to meet touch-target minimum.
- Dialog/sheet (centered, D-09): `maxWidth: 480px` centered via
  `left: 50%; transform: translateX(-50%)` (desktop); phone retains full-width
  bottom-sheet `bottom: 0; left: 0; right: 0`.

---

## Typography

All values from `tokens.css`. No new sizes or weights.

| Role | Size | Weight | Line Height | Variable |
|------|------|--------|-------------|----------|
| Body | 15px | 400 | 1.5 | `var(--text-body-size)` / `var(--text-body-weight)` / `var(--text-body-line-height)` |
| Label | 13px | 400 | 1.4 | `var(--text-label-size)` / `var(--text-label-weight)` / `var(--text-label-line-height)` |
| Heading | 18px | 600 | 1.25 | `var(--text-heading-size)` / `var(--text-heading-weight)` / `var(--text-heading-line-height)` |
| Display | 24px | 600 | 1.2 | `var(--text-display-size)` / `var(--text-display-weight)` / `var(--text-display-line-height)` |

Usage in this phase:
- Admin two-tab strip label: Label (13px/400/1.4) — inactive state; active state weight 600
- Toast notification body: Label (13px/400/1.4)
- Toast notification icon: 16px lucide icon
- Logout button label: Body (15px/400/1.5) — matches existing SettingsSheet row pattern
- Admin section content: inherits existing AdminPage typography (no change)

---

## Color

All values from `tokens.css`. No new hex values in this phase.

| Role | Value | Variable | Usage |
|------|-------|----------|-------|
| Dominant (60%) | #ffffff | `var(--color-surface)` | Page background, sheet background, tab strip background |
| Secondary (30%) | #f7f7f8 | `var(--color-surface-dim)` | Tab strip inactive background, toast background |
| Accent (10%) | #4a90d9 | `var(--color-member-0)` | Active tab indicator, active tab label, toast success icon, logout destructive separator |
| Destructive | #dc2626 | `var(--color-destructive)` | Logout button text color (destructive row style) |

Accent (`var(--color-member-0)`) reserved for:
- Active tab bottom-border indicator in the admin two-tab strip (2px solid)
- Active tab label text color
- Toast icon for success feedback
- Focus ring on all new interactive elements (`var(--color-focus-ring)`, 2px outline, 2px offset)

Additional semantic colors (already in tokens.css — no new values):
- `var(--color-border)` #e2e4e9 — tab strip bottom border, dialog border, toast border
- `var(--color-border-subtle)` #eceef2 — separator above logout button in SettingsSheet
- `var(--color-text-primary)` #111318 — tab labels (active), sheet headings
- `var(--color-text-secondary)` #6b7280 — tab labels (inactive), toast body text
- `var(--color-text-muted)` #9ca3af — section labels (uppercase, 13px/600/0.06em letter-spacing)
- `var(--color-overlay)` rgba(0,0,0,0.32) — sheet/dialog backdrop

### Brand accent checkpoint (answered question Q1)

The accent direction is a **checkpoint decision** — both variants must be producible
and comparable. The token restructure in Workstream C makes this a single-file swap.

**Variant A — keep cool-blue:**
- No token changes: `--color-member-0: #4a90d9`, `theme-color` stays `#4A90D9`

**Variant B — warm rose/amber:**
- `--color-member-0: #f25c7a` (rose, already the `--color-shared-family` value)
  OR a warm amber `#e8915a` — one of these two candidates to compare at the checkpoint
- Acceptance lens: warm/rounded/at-home; contrast ratio ≥3:1 on `#ffffff` (WCAG AA for
  large text/UI components; the current rose #f25c7a passes at 3.0:1)
- Files that flip for Variant B: `tokens.css` (`--color-member-0`), `index.html`
  (`theme-color` meta content), `vite.config.ts` manifest (`theme_color`)
- Note: `--sx-color-primary` already maps to `var(--color-member-0)` — it follows
  the accent automatically

**Default if checkpoint skipped:** keep Variant A (#4a90d9).

Light-theme scope only. No dark palette values authored this phase.

---

## Workstream A — Phone-Layout Overlap Fix

### Visual invariants (hard rules — must pass on iphone + pixel profiles)

1. **FAB never intersects the BottomTabBar rect.** The FAB's bottom edge must be
   at or above the BottomTabBar's top edge. On a 390×844 viewport with `safe-area-inset=0`:
   BottomTabBar top edge = 844 - 56 = 788px. FAB bottom edge must be ≤ 788px.
2. **Content fully scrollable above the bar.** The phone content area's scroll-bottom
   must clear the BottomTabBar height so no content is occluded at rest. The colour-legend
   chips ("Dev User" / member legend) and any bottom-of-page content must be visible
   without needing to manually offset-scroll.
3. **Safe-area-inset composes correctly.** On notched devices, `env(safe-area-inset-bottom)`
   is non-zero; the token `calc(56px + env(safe-area-inset-bottom, 0px))` absorbs both the
   bar height and the notch.
4. **No horizontal overflow** — existing Rule 2 must continue to pass after the fix.
5. **Tap targets preserved** — all existing ≥44px/≥56px assertions in layout.spec.ts
   must pass after the fix.

### Fix contract

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

**FAB offset (CalendarShell.tsx):**
- Before: `bottom: var(--space-6)` (~24px)
- After: `bottom: calc(var(--bottom-chrome-h) + var(--space-6))`
- The FAB sits `var(--space-6)` (24px) above the BottomTabBar top edge regardless
  of safe-area-inset value.

**Content padding (App.tsx `contentStyle`):**
- Phone branch only (inside `if (phone)` or via `isPhone()` conditional)
- Add: `paddingBottom: 'var(--bottom-chrome-h)'`
- Desktop `contentStyle` is unchanged (no BottomTabBar on desktop).

**BottomTabBar height (BottomTabBar.tsx):**
- The bar's `height` calculation already uses `calc(56px + env(safe-area-inset-bottom, 0px))`
  inline. This remains correct and unchanged — `--bottom-chrome-h` resolves to the same
  value so both the token and the component agree. For consistency, the planner MAY choose
  to reference the token from the bar's height property as well, but the visual invariant
  is met either way.

### Regression guard (D-02 decision)

**Recommendation: add a permanent overlap CI assertion to `layout.spec.ts`.**

Evidence basis: the defect was long-standing (Phase 04 — months), was invisible to desktop
testing, and CI has iphone/pixel profiles running. Adding a geometry assertion to the
existing spec is the lowest-regress mechanism. The assertion has no runtime cost beyond
one `boundingBox()` call.

**Assertion contract to add to `layout.spec.ts`:**

```
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();
  // FAB bottom edge must be at or above the BottomTabBar top edge
  expect(fabBox!.y + fabBox!.height).toBeLessThanOrEqual(navBox!.y);
});
```

Run profiles: iphone + pixel (skipped on desktop).

### Small-viewport sweep (D-01)

After the FAB/content-padding fix, run the full `layout.spec.ts` suite on all three
profiles. Fix any violations flagged (per D-01: checklist-driven, within no-behaviour-change
boundary). Expected areas to verify:

- Admin tab (when isAdmin=true): ensure it still meets ≥44px tap target after layout fix
- ColorLegend chips: confirm they are fully visible (not occluded) once content padding is added
- Any Phase 19 additions (LoginPage, SettingsSheet rows): no overflow on phone profiles

---

## Workstream B — Branding Assets

### Brand brief (acceptance lens)

All generated assets must feel: **warm / rounded / at-home / caricature-family vibes**.
Not corporate, not geometric. The operator reviews and approves before assets are final.
This is a **checkpoint** — the checkpoint fires before wiring is committed.

### Logo asset contract

| Asset | Dimensions | Format | Filename | Purpose |
|-------|-----------|--------|----------|---------|
| Logo mark | 192×192px (source; scale up for 512) | SVG preferred; PNG fallback | `logo.svg` or `logo.png` | BrandSlot `<img>` + derivation source for icon set |
| Favicon (modern) | 32×32px (scalable) | SVG | `favicon.svg` | Browser tab icon (modern browsers) |
| Favicon (legacy) | 16×16 + 32×32 ICO | ICO | `favicon.ico` | Browser tab icon (legacy, IE/older Safari) |
| PWA icon 192 | 192×192px | PNG | `icon-192.png` | PWA manifest — standard purpose |
| PWA icon 512 | 512×512px | PNG | `icon-512.png` | PWA manifest — standard purpose (splash screen) |
| PWA maskable 512 | 512×512px | PNG | `icon-maskable-512.png` | PWA manifest — maskable purpose (separate file) |
| Apple touch icon | 180×180px | PNG | `apple-touch-icon.png` | iOS home screen icon |

All files placed in `apps/pwa/public/`.

**Maskable safe-zone rule:** The maskable icon (`icon-maskable-512.png`) must place
the logo mark entirely within the 80% safe-zone circle (radius 204px on a 512×512 canvas,
centered). The outer 10% on each edge may be cropped by the OS adaptive-icon mask. The
current manifest incorrectly reuses `icon-512.png` (no safe zone) for the maskable
purpose — this is the defect being fixed.

### BrandSlot swap contract (D-05)

**What changes in `BrandSlot.tsx`:**
- Replace the placeholder `<div aria-hidden="true">FS</div>` with
  `<img src="/logo.svg" alt="" aria-hidden="true" />`
- Apply the existing `--brand-logo-*` token dimensions to the `<img>`:
  `width: var(--brand-logo-size, 48px)`, `height: var(--brand-logo-size, 48px)`,
  `borderRadius: var(--brand-logo-border-radius, 50%)`, aspect-ratio: 1/1
- The `--brand-logo-bg` token (placeholder circle background) is no longer used
  as a background when a real `<img>` is present — it may be set to `transparent`
  or removed from the element's style (keep the token in `tokens.css` for forward
  compat if needed)
- The `<h1>` with "FamilySync" text remains unchanged (screen readers still read the
  name; the logo is purely decorative)
- The tagline `<p>` remains unchanged
- `LoginPage` layout is NOT touched — the seam contract from Phase 19 is honored

**`--brand-logo-border-radius` update:**
- The placeholder used `50%` (circle). The real logo may be a rounded square or have
  its own shape baked in. Phase 17 sets this token to the shape that suits the logo:
  - If the logo SVG is a circle/rounded shape by design: set to `0` (no extra clipping)
  - If the logo is a square mark needing rounding: set to `12px` (warm/rounded brief)
  - Final value determined when the logo is generated and reviewed at checkpoint

**No `LoginPage` changes** — this is enforced by the Phase 19 seam contract.

### `index.html` wiring contract

Current state: one `<link rel="apple-touch-icon">`, one `<meta name="theme-color">`,
no `<link rel="icon">`.

After Phase 17:

```html
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="theme-color" content="{ACCENT_HEX}" />
  <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" />
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
  <meta name="apple-mobile-web-app-title" content="FamilySync" />
  <title>FamilySync</title>
</head>
```

`{ACCENT_HEX}` = the checkpoint-selected accent value (`#4A90D9` default or warm variant).

The SVG favicon takes precedence in modern browsers; the ICO fallback covers legacy.
Order matters: SVG first, ICO second (browsers pick the first supported type).

### `vite.config.ts` manifest wiring contract

Current state: 3 icon entries, last entry incorrectly reuses `icon-512.png` for maskable.

After Phase 17:

```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' },
],
```

The `theme_color` value in the manifest must match the `index.html` `theme-color` meta
and the checkpoint-selected accent. Change from `'#4A90D9'` to the chosen value at checkpoint.

---

## Workstream C — Theme-Token Groundwork

### Contract

**Groundwork only.** No dark palette values, no `prefers-color-scheme` media query wired
to flip themes, no theme toggle UI. Light is and remains the only shipped theme.

**Restructure `tokens.css` `:root` into a `data-theme`-capable pattern:**

```css
/* tokens.css — after restructure */

:root,
[data-theme="light"] {
  /* All existing :root declarations move here verbatim. */
  /* No value changes. */
  /* ...all existing tokens... */
}

/* Dark theme stub — values intentionally absent (Phase 17 groundwork only).
   Phase 999.20 fills these values and wires prefers-color-scheme. */
/* [data-theme="dark"] { ... } */
```

The change is purely structural: `:root` is extended with `[data-theme="light"]` as a
second selector on the same rule block. This allows a future `data-theme="dark"` attribute
on `<html>` to override without touching component files.

**Schedule-X `--sx-color-*` overrides must remain working.** They are currently at the
bottom of the same `:root` rule — after the restructure they remain inside the same
combined `:root, [data-theme="light"]` rule block. The cascade order (after
`@schedule-x/theme-default`) is unchanged; the overrides continue to win.

**`--brand-logo-*` tokens** remain in the same `:root, [data-theme="light"]` block.
Phase 17's logo swap updates their values here (e.g. `--brand-logo-border-radius`).

**New token added in this workstream (in addition to Workstream A's
`--bottom-chrome-h`):**

No additional tokens beyond `--bottom-chrome-h` (Workstream A) and any updated
`--brand-logo-*` values (Workstream B). The theme restructure introduces no new
semantic names — only the selector change.

**Invariant check:** after restructure, `grep -rn 'var(--' apps/pwa/src/components/`
must show only `var(--token-name)` references, no hard-coded hex or px values in
component files. This is the existing invariant; the restructure must not break it.

---

## Workstream D — UAT-Surfaced UI Fixes

### D-07 — Logout control

**Surfaces:** SettingsSheet (primary) and optionally AppNav phone header (secondary).

**Primary placement: SettingsSheet.**
A "Sign out" row is added below all existing SettingsSheet content, separated by a
`var(--color-border-subtle)` horizontal rule (same divider pattern as the existing
Account section). This mirrors the established section-separator pattern already used
in SettingsSheet between Notifications and Account.

Layout within SettingsSheet (bottom of sheet, after all other rows):
```
─────────────────────────   ← 1px var(--color-border-subtle) divider, margin var(--space-4) top/bottom
[LogOut icon 16px] Sign out  ← full-width button, minHeight 44px, Body 15px/400, var(--color-destructive)
```

**Logout button style:**
- `background: none; border: none; cursor: pointer`
- Full width (`width: 100%`), `display: flex; alignItems: center; gap: var(--space-2)`
- `LogOut` lucide icon (16px, `var(--color-destructive)`)
- Label "Sign out" — Body (15px/400), `var(--color-destructive)`
- `minHeight: 44px` (WCAG tap target)
- `textAlign: left`, `padding: var(--space-2, 8px) 0`
- On click: calls `fetchLocalLogout()` (already exists in `apps/pwa/src/api/client.ts:127`),
  then navigates to `/login` (react-router `useNavigate` or `window.location.replace`)
- No confirmation dialog — logout is not destructive in the "data loss" sense for a
  household app; the user is simply signed out and can re-sign in immediately

**Accessibility:**
- `aria-label="Sign out"` on the button
- Icon is `aria-hidden="true"`
- Standard focus ring (`var(--color-focus-ring)`, 2px outline, 2px offset)

**No backend work required** — `fetchLocalLogout()` calls the existing
`POST /api/auth/local/logout` endpoint.

### D-08 — Admin success feedback

**Surfaces:** AdminPage.tsx — create-member and reset-password flows.

**Toast notification design contract:**

A lightweight transient toast appears after a successful admin action. Reuses the
existing `SyncStateToast` visual pattern (already in the codebase) if possible; if not,
implement a minimal inline variant.

Toast style:
- Position: `fixed; bottom: calc(var(--bottom-chrome-h) + var(--space-4))` on phone;
  `fixed; bottom: var(--space-6); left: 50%; transform: translateX(-50%)` on desktop
- Background: `var(--color-surface)`, border: `1px solid var(--color-border)`,
  `borderRadius: var(--space-2)`, `boxShadow: 0 2px 8px rgba(0,0,0,0.12)`
- Padding: `var(--space-3) var(--space-4)` (12px 16px)
- Content: `CheckCircle` (16px, `var(--color-member-0)`) + toast message text (Label 13px/400,
  `var(--color-text-primary)`)
- Auto-dismiss: after 3 seconds (no dismiss button needed for a household app)
- `role="status"`, `aria-live="polite"` — screen readers announce the success

**Toast copy variants:**

| Action | Toast copy |
|--------|------------|
| Create member success | "Member added." |
| Reset password success | "Password reset." |

**Placement note:** the toast bottom offset on phone uses `var(--bottom-chrome-h)`
(introduced in Workstream A) so it clears the BottomTabBar.

### D-09 — Dialog/sheet centering fix

**Surfaces:** SettingsSheet, ChangePasswordSheet, LinkOidcSheet in SettingsSheet.tsx;
AdminPage CredentialSheet; AdminPage reset-password sheet (Surface 11B).

**Problem:** sheets currently use `bottom: 0; left: 0; right: 0; maxWidth: 480px;
margin: 0 auto` — on desktop this places them bottom-center, not truly centered.

**Fix contract:**

Phone (`≤767px`): bottom-sheet behavior is correct and intentional. No change.
- `position: fixed; bottom: 0; left: 0; right: 0; borderRadius: 12px 12px 0 0`

Desktop (`≥768px`): centered modal.
- `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%)`
- `maxWidth: 480px; width: calc(100% - var(--space-8)); borderRadius: 12px`
- `maxHeight: calc(100dvh - var(--space-8)); overflowY: auto`
- Remove `bottom: 0; left: 0; right: 0; margin: 0 auto; borderRadius: 12px 12px 0 0`
- Box shadow: `0 8px 32px rgba(0,0,0,0.18)` (deeper shadow for centered modal feel)

**Breakpoint:** use `window.matchMedia('(max-width: 767px)')` — same as `isPhone()`
in the existing codebase. The behavior is determined at render time; no CSS-only
media query approach is used (consistent with project pattern of inline React styles).

**Applies to all sheets:** SettingsSheet, ChangePasswordSheet, LinkOidcSheet,
CredentialSheet, AdminPage reset-password sheet. Each gets a phone/desktop style
branch for its outer `<div role="dialog">`.

**Backdrop:** unchanged — `position: fixed; inset: 0; background: var(--color-overlay)`.

**Verification:** confirm via playwright-cli at both 390×844 (phone — bottom sheet) and
1280×720 (desktop — centered modal) before merging.

### D-10 — Admin two-tab navigation

**Surface:** AdminPage.tsx

**Structure:** horizontal tab strip at the top of the AdminPage content column,
replacing the current single-page long-scroll layout.

**Two tabs:**

| Tab | Label | Contents |
|-----|-------|----------|
| Tab 1 | "Members & Accounts" | MEMBERS section (credential management) + LOCAL ACCOUNTS section (create member + reset-password) |
| Tab 2 | "Settings" | SHARED CALENDAR section + TIMEZONE section |

**Tab strip visual contract:**

```
┌────────────────────────────────────────────────────────────┐
│  [Members & Accounts]  [Settings]                          │
│  ─────────────────── 2px active border-bottom              │
│  1px var(--color-border-subtle) full-width rule below strip │
└────────────────────────────────────────────────────────────┘
```

Tab strip container:
- `display: flex; borderBottom: 1px solid var(--color-border-subtle)`
- `marginBottom: var(--space-6)` (24px gap before first section)

Individual tab button:
- `background: none; border: none; cursor: pointer`
- `padding: var(--space-3) var(--space-4)` (12px 16px)
- `minHeight: 44px` (WCAG tap target)
- `fontSize: var(--text-label-size, 13px)` (13px)
- 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)`
- `transition: color 0.1s ease, border-color 0.1s ease`
- `fontFamily: var(--font-family-base)`

**Accessibility (roving tabindex / ARIA tabs pattern):**
- Tab strip container: `role="tablist"`
- Each tab button: `role="tab"`, `aria-selected={isActive}`,
  `aria-controls="{panel-id}"`, `id="{tab-id}"`
- Inactive tabs: `tabIndex={-1}` (roving tabindex — only active tab is in tab order)
- Active tab: `tabIndex={0}`
- Keyboard navigation within the tablist: `ArrowLeft`/`ArrowRight` move focus + activate tab
- Tab panel: `role="tabpanel"`, `aria-labelledby="{tab-id}"`, `id="{panel-id}"`
  Each panel receives `tabIndex={0}` so keyboard users can enter the panel content after
  the tablist

**Tab IDs:**
- `id="admin-tab-members"` / `aria-controls="admin-panel-members"`
- `id="admin-tab-settings"` / `aria-controls="admin-panel-settings"`

**Default active tab on mount:** "Members & Accounts" (Tab 1).

**Phone behavior:** the two-tab strip eliminates the long-scroll on phone. Each tab's
content replaces the other. The tab labels are short enough that both fit without
overflow at 390px width (verify via playwright-cli). No horizontal scroll on the
tab strip.

**No new tokens** — the tab strip uses only existing spacing, color, and typography tokens.

---

## Copywriting Contract

### Workstream D new copy

| Element | Copy |
|---------|------|
| Logout button label | "Sign out" |
| Logout button aria-label | "Sign out" |
| Toast — create member success | "Member added." |
| Toast — reset password success | "Password reset." |
| Admin tab 1 label | "Members & Accounts" |
| Admin tab 2 label | "Settings" |

### Copywriting rules (inherited from Phase 19)

- Never use "Authelia" in any user-facing copy.
- Admin copy ("Reset password", "Sign out") is direct — admins are comfortable with the vocabulary.
- End-user copy is warm and low-friction.
- "Sign out" (not "Log out" or "Logout") — consistent with friendly household tone.
- Toast copy is declarative past-tense ("Member added.") not celebratory — keeps the
  admin UI professional.

### Empty states

No new empty states introduced by Phase 17. The existing AdminPage empty states
(no members / no calendars synced) remain unchanged.

### Error states

No new error states introduced by Phase 17. Workstream D's logout has no error path
(if the API call fails, the user is navigated to `/login` regardless —
`fetchLocalLogout()` is fire-and-best-effort for a cookie clear).

---

## Surface Architecture

### Surface A-1 — Phone layout (App.tsx `contentStyle`, phone branch)

After fix:
```ts
const contentStyle: React.CSSProperties = {
  flex: 1,
  minWidth: 0,
  minHeight: 0,
  display: 'flex',
  flexDirection: 'column',
  overflow: 'hidden',
  position: 'relative',
  // Phone-only: reserve space for the fixed BottomTabBar
  ...(phone ? { paddingBottom: 'var(--bottom-chrome-h)' } : {}),
};
```

The `overflow: 'hidden'` on the outer contentStyle traps scroll. The inner route content
(CalendarShell, ListsIndex, etc.) must handle its own scroll; the padding-bottom ensures
their scrollable area clears the tab bar.

### Surface A-2 — New Event FAB (CalendarShell.tsx)

After fix (phone-only FAB style):
```ts
// Phone FAB positioning — clears BottomTabBar + adds breathing room
position: 'fixed',
bottom: 'calc(var(--bottom-chrome-h) + var(--space-6))',
right: 'var(--space-6)',
// size unchanged
width: '56px',
height: '56px',
```

### Surface B-1 — BrandSlot (BrandSlot.tsx)

After swap:
```tsx
<div style={{ textAlign: 'center' }}>
  <img
    src="/logo.svg"
    alt=""
    aria-hidden="true"
    style={{
      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,
    }}
  />
  {/* <h1> and <p> tagline unchanged */}
</div>
```

`--brand-logo-border-radius` updated in `tokens.css` to suit the logo shape
(determined at checkpoint; likely `12px` for warm/rounded brief or `50%` if circular).

### Surface C-1 — tokens.css structural change

Before:
```css
:root { /* all tokens */ }
```

After:
```css
:root,
[data-theme="light"] { /* all tokens, values unchanged */ }
/* [data-theme="dark"] { ... } intentionally absent — Phase 999.20 fills this */
```

### Surface D-1 — SettingsSheet logout row

After the existing Account section divider (or at the bottom of the sheet):
```tsx
{/* Sign out */}
<div style={{ height: '1px', background: 'var(--color-border-subtle)', margin: 'var(--space-4) 0' }} />
<button
  type="button"
  onClick={handleSignOut}
  aria-label="Sign out"
  style={{
    display: 'flex',
    alignItems: 'center',
    gap: 'var(--space-2)',
    width: '100%',
    minHeight: '44px',
    background: 'none',
    border: 'none',
    cursor: 'pointer',
    padding: 'var(--space-2) 0',
    fontSize: 'var(--text-body-size)',
    fontWeight: 400,
    color: 'var(--color-destructive)',
    fontFamily: 'var(--font-family-base)',
    textAlign: 'left',
  }}
>
  <LogOut size={16} aria-hidden="true" />
  Sign out
</button>
```

`handleSignOut` calls `fetchLocalLogout()` then navigates to `/login`.

### Surface D-2 — Admin two-tab strip (AdminPage.tsx)

Rendered above all AdminPage content, inside the content column:
- `<div role="tablist">` containing two `<button role="tab">` elements
- Active tab panel renders below the strip, replacing the full-page scroll

### Surface D-3 — Dialog/sheet centering

All existing sheets (SettingsSheet, ChangePasswordSheet, LinkOidcSheet, CredentialSheet,
admin reset-password sheet) gain a phone/desktop style branch on their outer
`<div role="dialog">` element. Desktop: centered modal. Phone: unchanged bottom-sheet.

---

## Interaction Contract

### Logout flow (D-07)

```
User taps "Sign out" in SettingsSheet
  → fetchLocalLogout() called (POST /api/auth/local/logout, fire-and-best-effort)
  → SettingsSheet closes (setSettingsOpen(false) in App.tsx or internal close)
  → navigate('/login') (react-router useNavigate or window.location.replace('/login'))
  → LoginPage renders
```

No confirmation step. The action is low-stakes (re-login is immediate; no data is lost).

### Toast lifecycle (D-08)

```
Admin action succeeds (mutation onSuccess)
  → toast state set with message
  → toast renders in DOM with role="status" aria-live="polite"
  → after 3000ms: toast state cleared, toast unmounts
```

One toast at a time (a second action while a toast is showing replaces the message).

### Admin tab switching (D-10)

```
User taps inactive tab
  → active tab state updates
  → previous panel hidden (or unmounted)
  → new panel shown
  → ArrowLeft/ArrowRight keyboard: focus moves between tabs + activates
  → Tab key: enters the active panel content (tabIndex={0} on tabpanel)
```

Tab state is local `useState` in AdminPage. No URL routing change — the admin route
stays `/admin`; tab state does not persist across navigation.

---

## Accessibility Contract

### Workstream A

- All existing layout.spec.ts assertions continue to pass
- The overlap CI assertion added to layout.spec.ts runs on iphone + pixel profiles
- No new accessibility concerns introduced by CSS-only padding/offset changes

### Workstream B (BrandSlot)

- `<img alt="" aria-hidden="true">` — logo is decorative; `<h1>FamilySync</h1>` is the
  accessible page label
- No layout shift on the login page (`aspect-ratio: 1/1` + explicit width preserves intrinsic size)
- Tagline `<p>` and `<h1>` remain as text (never replaced with an image)

### Workstream C

- Purely structural CSS change; no accessibility impact

### Workstream D (logout, toast, dialogs, tabs)

- Logout button: `aria-label="Sign out"`, `minHeight: 44px`, standard focus ring
- Toast: `role="status"`, `aria-live="polite"`, `aria-atomic="true"` — announced by screen readers
- Sheet centering (D-09): `role="dialog"`, `aria-modal="true"`, `aria-label` matching heading,
  Escape closes, focus returns to trigger — these invariants are unchanged; only the
  CSS position changes
- Admin tabs: full ARIA tabs pattern — `role="tablist"`, `role="tab"`, `aria-selected`,
  `aria-controls`, roving tabindex, `ArrowLeft`/`ArrowRight` keyboard navigation,
  `role="tabpanel"`, `aria-labelledby`
- All interactive elements: `minHeight: 44px`, focus ring via `var(--color-focus-ring)`

---

## Responsive Behavior

| Surface | Phone (≤767px) | Desktop (≥768px) |
|---------|---------------|-----------------|
| FAB | `bottom: calc(var(--bottom-chrome-h) + var(--space-6))` — clears bar | Desktop toolbar button (no FAB); unchanged |
| Content area | `paddingBottom: var(--bottom-chrome-h)` | No padding-bottom (no bar) |
| Sheets/dialogs | Bottom-sheet (`bottom: 0; left: 0; right: 0; borderRadius: 12px 12px 0 0`) | Centered modal (`top: 50%; left: 50%; transform: translate(-50%, -50%); borderRadius: 12px`) |
| Admin tab strip | Full-width, both tabs fit at 390px; tab labels short enough to avoid overflow | Same — no change at desktop (tab strip still renders, content is narrower anyway) |
| Toast | `bottom: calc(var(--bottom-chrome-h) + var(--space-4))` — above BottomTabBar | `bottom: var(--space-6); left: 50%; transform: translateX(-50%)` |
| BrandSlot | Phone-first (LoginPage layout unchanged) | Centered, same as phone |

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none — not initialized | not applicable |
| third-party | none | not applicable |

No third-party component registries. All components hand-rolled following existing project
convention. No new npm dependencies for UI are required by this phase.

---

## Pre-Population Sources

| Decision | Source | Value |
|----------|--------|-------|
| Spacing scale | apps/pwa/src/styles/tokens.css | --space-1 through --space-12; one new token --bottom-chrome-h |
| Typography scale | apps/pwa/src/styles/tokens.css | 4 sizes (13/15/18/24px), 2 weights (400/600) |
| Color palette | apps/pwa/src/styles/tokens.css | All hex values; no new colors |
| Component library | apps/pwa convention | Hand-rolled inline React.CSSProperties; no shadcn |
| Icon library | apps/pwa imports | lucide-react (already installed; LogOut needed for D-07) |
| Phone breakpoint | App.tsx, BottomTabBar.tsx, CalendarShell.tsx | `isPhone()` = `window.matchMedia('(max-width: 767px)')` |
| BottomTabBar height | BottomTabBar.tsx | `calc(56px + env(safe-area-inset-bottom, 0px))` |
| FAB current position | CalendarShell.tsx | `bottom: var(--space-6); right: var(--space-6)` |
| BrandSlot seam | 19-UI-SPEC.md §Brand Slot + BrandSlot.tsx | Phase 19 contract; Phase 17 swaps internals only |
| --brand-logo-* tokens | apps/pwa/src/styles/tokens.css | Placeholder defaults set in Phase 19 |
| Sheet/dialog pattern | SettingsSheet.tsx, CredentialSheet.tsx | role=dialog, aria-modal, Escape closes, focus-return |
| Admin section label style | AdminPage.tsx `sectionLabelStyle` | 13px/600/uppercase/0.06em |
| Logout function | apps/pwa/src/api/client.ts:127 | `fetchLocalLogout()` — already exists, no backend work |
| Toast pattern | SyncStateToast.tsx | Visual baseline for success toast |
| FAB overlap defect evidence | .planning/todos/pending/2026-06-13-pwa-phone-bottombar-overlap.md | Reproduced at 390×844; FAB over Admin tab; legend clipped |
| Brand accent checkpoint | 17-CONTEXT.md Q1 answer | Two candidates producible; checkpoint before committing |
| Admin two-tab layout | 17-CONTEXT.md Q2 answer | Two tabs: "Members & Accounts" + "Settings"; roving tabindex |
| Dark theme / toggle | 17-CONTEXT.md §Deferred | Out of scope — backlog 999.20 |
| Visual refresh | 17-CONTEXT.md §Deferred | Out of scope — backlog 999.21 |

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
