diff --git a/.planning/phases/17-ui-optimization-polish/17-UI-SPEC.md b/.planning/phases/17-ui-optimization-polish/17-UI-SPEC.md
new file mode 100644
index 0000000..a636d2a
--- /dev/null
+++ b/.planning/phases/17-ui-optimization-polish/17-UI-SPEC.md
@@ -0,0 +1,851 @@
+---
+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 `` + 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 `
FS
` with
+ ``
+- Apply the existing `--brand-logo-*` token dimensions to the ``:
+ `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 `` 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 `
` with "FamilySync" text remains unchanged (screen readers still read the
+ name; the logo is purely decorative)
+- The tagline `
` 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 ``, one ``,
+no ``.
+
+After Phase 17:
+
+```html
+
+
+
+
+
+
+
+
+
+
+ FamilySync
+
+```
+
+`{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 `` 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 `
`.
+
+**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
+
+
+ {/*
and
tagline unchanged */}
+
+```
+
+`--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 */}
+
+
+```
+
+`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:
+- `