Files
2026-06-18 22:21:38 -04:00

36 KiB
Raw Permalink Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
17 ui-optimization-polish draft false none 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):

/* 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:

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

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

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:

/* 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 requiredfetchLocalLogout() 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:

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):

// 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:

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

:root { /* all tokens */ }

After:

: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):

{/* 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