From a1457a5b30318cee64bd78077ae0292e7f7142bc Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 10:59:25 -0400 Subject: [PATCH] docs(17): UI design contract --- .../17-ui-optimization-polish/17-UI-SPEC.md | 851 ++++++++++++++++++ 1 file changed, 851 insertions(+) create mode 100644 .planning/phases/17-ui-optimization-polish/17-UI-SPEC.md 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 `` 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: +- `
` containing two `