--- 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 `