852 lines
36 KiB
Markdown
852 lines
36 KiB
Markdown
---
|
||
phase: 17
|
||
slug: ui-optimization-polish
|
||
status: draft
|
||
shadcn_initialized: false
|
||
preset: none
|
||
created: 2026-06-18
|
||
---
|
||
|
||
# Phase 17 — UI Design Contract: UI Optimization & Polish
|
||
|
||
> Visual and interaction contract for four bounded workstreams:
|
||
> A — phone-layout overlap fix + small-viewport sweep,
|
||
> B — branding assets (logo, favicon, PWA icon set),
|
||
> C — theme-token groundwork (light-only, semantic layer),
|
||
> D — UAT-surfaced UI fixes (logout control, admin success feedback,
|
||
> dialog/sheet centering, admin two-tab nav).
|
||
>
|
||
> Generated by gsd-ui-researcher. Consume before planning or executing.
|
||
|
||
---
|
||
|
||
## Context & Approach
|
||
|
||
This is a **polish + branding + theme-token-groundwork** phase on an **existing shipped**
|
||
React 19 + Vite PWA. NOT greenfield, NOT a redesign. All design decisions extend the
|
||
established token system in `apps/pwa/src/styles/tokens.css`.
|
||
|
||
The no-hard-coded-values invariant is a hard constraint: **all hex/px values live in
|
||
`tokens.css` as CSS custom properties; component files reference variables only.**
|
||
|
||
The existing Phase 19 UI-SPEC (approved 2026-06-16) establishes the design-system
|
||
baseline this phase builds on. No new tokens are introduced except the layout-chrome
|
||
token added in Workstream A.
|
||
|
||
---
|
||
|
||
## Design System
|
||
|
||
| Property | Value |
|
||
|----------|-------|
|
||
| Tool | none (existing CSS custom properties) |
|
||
| Preset | not applicable |
|
||
| Component library | none (hand-rolled inline `React.CSSProperties`, project convention) |
|
||
| Icon library | lucide-react (already installed) |
|
||
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif (`var(--font-family-base)`) |
|
||
|
||
Source: `apps/pwa/src/styles/tokens.css` — pre-populated from codebase scan.
|
||
Pattern baseline: existing components (SettingsSheet, AdminPage, CredentialSheet, CalendarShell).
|
||
|
||
---
|
||
|
||
## Spacing Scale
|
||
|
||
No new spacing tokens are introduced. Phase 17 uses the existing 4px-based scale unchanged.
|
||
|
||
| Token | Value | Usage in this phase |
|
||
|-------|-------|---------------------|
|
||
| --space-1 | 4px | Icon gaps, tight label margins |
|
||
| --space-2 | 8px | Tab strip inner gap, section label bottom margin |
|
||
| --space-3 | 12px | Input row padding, tab content gap |
|
||
| --space-4 | 16px | Toast horizontal padding, button padding, between-field gap |
|
||
| --space-6 | 24px | Sheet/card padding, FAB clearance (base of calc expression) |
|
||
| --space-8 | 32px | Section gap in admin two-tab content |
|
||
| --space-12 | 48px | Page-level top/bottom padding |
|
||
|
||
**New layout-chrome token (Workstream A):**
|
||
|
||
```css
|
||
/* Added to tokens.css :root alongside existing spacing scale */
|
||
--bottom-chrome-h: calc(56px + env(safe-area-inset-bottom, 0px));
|
||
```
|
||
|
||
This single token is the source of truth for BottomTabBar height. It is consumed
|
||
by the FAB offset (`bottom: calc(var(--bottom-chrome-h) + var(--space-6))`) and the
|
||
phone content padding (`padding-bottom: var(--bottom-chrome-h)`). All three sites
|
||
agree via one value.
|
||
|
||
Exceptions:
|
||
- FAB: `width: 56px; height: 56px` (Rule 1 minimum: ≥56×56px per layout.spec.ts). Not a
|
||
spacing-scale value — this is the FAB's own intrinsic size.
|
||
- All interactive elements: `minHeight: 44px; minWidth: 44px` (WCAG 2.5.5 Touch Target).
|
||
- Admin two-tab strip: tab items use `minHeight: 44px` to meet touch-target minimum.
|
||
- Dialog/sheet (centered, D-09): `maxWidth: 480px` centered via
|
||
`left: 50%; transform: translateX(-50%)` (desktop); phone retains full-width
|
||
bottom-sheet `bottom: 0; left: 0; right: 0`.
|
||
|
||
---
|
||
|
||
## Typography
|
||
|
||
All values from `tokens.css`. No new sizes or weights.
|
||
|
||
| Role | Size | Weight | Line Height | Variable |
|
||
|------|------|--------|-------------|----------|
|
||
| Body | 15px | 400 | 1.5 | `var(--text-body-size)` / `var(--text-body-weight)` / `var(--text-body-line-height)` |
|
||
| Label | 13px | 400 | 1.4 | `var(--text-label-size)` / `var(--text-label-weight)` / `var(--text-label-line-height)` |
|
||
| Heading | 18px | 600 | 1.25 | `var(--text-heading-size)` / `var(--text-heading-weight)` / `var(--text-heading-line-height)` |
|
||
| Display | 24px | 600 | 1.2 | `var(--text-display-size)` / `var(--text-display-weight)` / `var(--text-display-line-height)` |
|
||
|
||
Usage in this phase:
|
||
- Admin two-tab strip label: Label (13px/400/1.4) — inactive state; active state weight 600
|
||
- Toast notification body: Label (13px/400/1.4)
|
||
- Toast notification icon: 16px lucide icon
|
||
- Logout button label: Body (15px/400/1.5) — matches existing SettingsSheet row pattern
|
||
- Admin section content: inherits existing AdminPage typography (no change)
|
||
|
||
---
|
||
|
||
## Color
|
||
|
||
All values from `tokens.css`. No new hex values in this phase.
|
||
|
||
| Role | Value | Variable | Usage |
|
||
|------|-------|----------|-------|
|
||
| Dominant (60%) | #ffffff | `var(--color-surface)` | Page background, sheet background, tab strip background |
|
||
| Secondary (30%) | #f7f7f8 | `var(--color-surface-dim)` | Tab strip inactive background, toast background |
|
||
| Accent (10%) | #4a90d9 | `var(--color-member-0)` | Active tab indicator, active tab label, toast success icon, logout destructive separator |
|
||
| Destructive | #dc2626 | `var(--color-destructive)` | Logout button text color (destructive row style) |
|
||
|
||
Accent (`var(--color-member-0)`) reserved for:
|
||
- Active tab bottom-border indicator in the admin two-tab strip (2px solid)
|
||
- Active tab label text color
|
||
- Toast icon for success feedback
|
||
- Focus ring on all new interactive elements (`var(--color-focus-ring)`, 2px outline, 2px offset)
|
||
|
||
Additional semantic colors (already in tokens.css — no new values):
|
||
- `var(--color-border)` #e2e4e9 — tab strip bottom border, dialog border, toast border
|
||
- `var(--color-border-subtle)` #eceef2 — separator above logout button in SettingsSheet
|
||
- `var(--color-text-primary)` #111318 — tab labels (active), sheet headings
|
||
- `var(--color-text-secondary)` #6b7280 — tab labels (inactive), toast body text
|
||
- `var(--color-text-muted)` #9ca3af — section labels (uppercase, 13px/600/0.06em letter-spacing)
|
||
- `var(--color-overlay)` rgba(0,0,0,0.32) — sheet/dialog backdrop
|
||
|
||
### Brand accent checkpoint (answered question Q1)
|
||
|
||
The accent direction is a **checkpoint decision** — both variants must be producible
|
||
and comparable. The token restructure in Workstream C makes this a single-file swap.
|
||
|
||
**Variant A — keep cool-blue:**
|
||
- No token changes: `--color-member-0: #4a90d9`, `theme-color` stays `#4A90D9`
|
||
|
||
**Variant B — warm rose/amber:**
|
||
- `--color-member-0: #f25c7a` (rose, already the `--color-shared-family` value)
|
||
OR a warm amber `#e8915a` — one of these two candidates to compare at the checkpoint
|
||
- Acceptance lens: warm/rounded/at-home; contrast ratio ≥3:1 on `#ffffff` (WCAG AA for
|
||
large text/UI components; the current rose #f25c7a passes at 3.0:1)
|
||
- Files that flip for Variant B: `tokens.css` (`--color-member-0`), `index.html`
|
||
(`theme-color` meta content), `vite.config.ts` manifest (`theme_color`)
|
||
- Note: `--sx-color-primary` already maps to `var(--color-member-0)` — it follows
|
||
the accent automatically
|
||
|
||
**Default if checkpoint skipped:** keep Variant A (#4a90d9).
|
||
|
||
Light-theme scope only. No dark palette values authored this phase.
|
||
|
||
---
|
||
|
||
## Workstream A — Phone-Layout Overlap Fix
|
||
|
||
### Visual invariants (hard rules — must pass on iphone + pixel profiles)
|
||
|
||
1. **FAB never intersects the BottomTabBar rect.** The FAB's bottom edge must be
|
||
at or above the BottomTabBar's top edge. On a 390×844 viewport with `safe-area-inset=0`:
|
||
BottomTabBar top edge = 844 - 56 = 788px. FAB bottom edge must be ≤ 788px.
|
||
2. **Content fully scrollable above the bar.** The phone content area's scroll-bottom
|
||
must clear the BottomTabBar height so no content is occluded at rest. The colour-legend
|
||
chips ("Dev User" / member legend) and any bottom-of-page content must be visible
|
||
without needing to manually offset-scroll.
|
||
3. **Safe-area-inset composes correctly.** On notched devices, `env(safe-area-inset-bottom)`
|
||
is non-zero; the token `calc(56px + env(safe-area-inset-bottom, 0px))` absorbs both the
|
||
bar height and the notch.
|
||
4. **No horizontal overflow** — existing Rule 2 must continue to pass after the fix.
|
||
5. **Tap targets preserved** — all existing ≥44px/≥56px assertions in layout.spec.ts
|
||
must pass after the fix.
|
||
|
||
### Fix contract
|
||
|
||
**New token added to `tokens.css` `:root`:**
|
||
```css
|
||
--bottom-chrome-h: calc(56px + env(safe-area-inset-bottom, 0px));
|
||
```
|
||
|
||
**FAB offset (CalendarShell.tsx):**
|
||
- Before: `bottom: var(--space-6)` (~24px)
|
||
- After: `bottom: calc(var(--bottom-chrome-h) + var(--space-6))`
|
||
- The FAB sits `var(--space-6)` (24px) above the BottomTabBar top edge regardless
|
||
of safe-area-inset value.
|
||
|
||
**Content padding (App.tsx `contentStyle`):**
|
||
- Phone branch only (inside `if (phone)` or via `isPhone()` conditional)
|
||
- Add: `paddingBottom: 'var(--bottom-chrome-h)'`
|
||
- Desktop `contentStyle` is unchanged (no BottomTabBar on desktop).
|
||
|
||
**BottomTabBar height (BottomTabBar.tsx):**
|
||
- The bar's `height` calculation already uses `calc(56px + env(safe-area-inset-bottom, 0px))`
|
||
inline. This remains correct and unchanged — `--bottom-chrome-h` resolves to the same
|
||
value so both the token and the component agree. For consistency, the planner MAY choose
|
||
to reference the token from the bar's height property as well, but the visual invariant
|
||
is met either way.
|
||
|
||
### Regression guard (D-02 decision)
|
||
|
||
**Recommendation: add a permanent overlap CI assertion to `layout.spec.ts`.**
|
||
|
||
Evidence basis: the defect was long-standing (Phase 04 — months), was invisible to desktop
|
||
testing, and CI has iphone/pixel profiles running. Adding a geometry assertion to the
|
||
existing spec is the lowest-regress mechanism. The assertion has no runtime cost beyond
|
||
one `boundingBox()` call.
|
||
|
||
**Assertion contract to add to `layout.spec.ts`:**
|
||
|
||
```
|
||
test('New Event FAB does not overlap BottomTabBar (A — phone only)', async ({ page }, testInfo) => {
|
||
test.skip(testInfo.project.name === 'desktop', 'Phone-only assertion');
|
||
await page.goto('/calendar');
|
||
const fab = page.getByRole('button', { name: 'New Event' });
|
||
const nav = page.getByRole('navigation', { name: 'Main navigation' });
|
||
const fabBox = await fab.boundingBox();
|
||
const navBox = await nav.boundingBox();
|
||
expect(fabBox).not.toBeNull();
|
||
expect(navBox).not.toBeNull();
|
||
// FAB bottom edge must be at or above the BottomTabBar top edge
|
||
expect(fabBox!.y + fabBox!.height).toBeLessThanOrEqual(navBox!.y);
|
||
});
|
||
```
|
||
|
||
Run profiles: iphone + pixel (skipped on desktop).
|
||
|
||
### Small-viewport sweep (D-01)
|
||
|
||
After the FAB/content-padding fix, run the full `layout.spec.ts` suite on all three
|
||
profiles. Fix any violations flagged (per D-01: checklist-driven, within no-behaviour-change
|
||
boundary). Expected areas to verify:
|
||
|
||
- Admin tab (when isAdmin=true): ensure it still meets ≥44px tap target after layout fix
|
||
- ColorLegend chips: confirm they are fully visible (not occluded) once content padding is added
|
||
- Any Phase 19 additions (LoginPage, SettingsSheet rows): no overflow on phone profiles
|
||
|
||
---
|
||
|
||
## Workstream B — Branding Assets
|
||
|
||
### Brand brief (acceptance lens)
|
||
|
||
All generated assets must feel: **warm / rounded / at-home / caricature-family vibes**.
|
||
Not corporate, not geometric. The operator reviews and approves before assets are final.
|
||
This is a **checkpoint** — the checkpoint fires before wiring is committed.
|
||
|
||
### Logo asset contract
|
||
|
||
| Asset | Dimensions | Format | Filename | Purpose |
|
||
|-------|-----------|--------|----------|---------|
|
||
| Logo mark | 192×192px (source; scale up for 512) | SVG preferred; PNG fallback | `logo.svg` or `logo.png` | BrandSlot `<img>` + derivation source for icon set |
|
||
| Favicon (modern) | 32×32px (scalable) | SVG | `favicon.svg` | Browser tab icon (modern browsers) |
|
||
| Favicon (legacy) | 16×16 + 32×32 ICO | ICO | `favicon.ico` | Browser tab icon (legacy, IE/older Safari) |
|
||
| PWA icon 192 | 192×192px | PNG | `icon-192.png` | PWA manifest — standard purpose |
|
||
| PWA icon 512 | 512×512px | PNG | `icon-512.png` | PWA manifest — standard purpose (splash screen) |
|
||
| PWA maskable 512 | 512×512px | PNG | `icon-maskable-512.png` | PWA manifest — maskable purpose (separate file) |
|
||
| Apple touch icon | 180×180px | PNG | `apple-touch-icon.png` | iOS home screen icon |
|
||
|
||
All files placed in `apps/pwa/public/`.
|
||
|
||
**Maskable safe-zone rule:** The maskable icon (`icon-maskable-512.png`) must place
|
||
the logo mark entirely within the 80% safe-zone circle (radius 204px on a 512×512 canvas,
|
||
centered). The outer 10% on each edge may be cropped by the OS adaptive-icon mask. The
|
||
current manifest incorrectly reuses `icon-512.png` (no safe zone) for the maskable
|
||
purpose — this is the defect being fixed.
|
||
|
||
### BrandSlot swap contract (D-05)
|
||
|
||
**What changes in `BrandSlot.tsx`:**
|
||
- Replace the placeholder `<div aria-hidden="true">FS</div>` with
|
||
`<img src="/logo.svg" alt="" aria-hidden="true" />`
|
||
- Apply the existing `--brand-logo-*` token dimensions to the `<img>`:
|
||
`width: var(--brand-logo-size, 48px)`, `height: var(--brand-logo-size, 48px)`,
|
||
`borderRadius: var(--brand-logo-border-radius, 50%)`, aspect-ratio: 1/1
|
||
- The `--brand-logo-bg` token (placeholder circle background) is no longer used
|
||
as a background when a real `<img>` is present — it may be set to `transparent`
|
||
or removed from the element's style (keep the token in `tokens.css` for forward
|
||
compat if needed)
|
||
- The `<h1>` with "FamilySync" text remains unchanged (screen readers still read the
|
||
name; the logo is purely decorative)
|
||
- The tagline `<p>` remains unchanged
|
||
- `LoginPage` layout is NOT touched — the seam contract from Phase 19 is honored
|
||
|
||
**`--brand-logo-border-radius` update:**
|
||
- The placeholder used `50%` (circle). The real logo may be a rounded square or have
|
||
its own shape baked in. Phase 17 sets this token to the shape that suits the logo:
|
||
- If the logo SVG is a circle/rounded shape by design: set to `0` (no extra clipping)
|
||
- If the logo is a square mark needing rounding: set to `12px` (warm/rounded brief)
|
||
- Final value determined when the logo is generated and reviewed at checkpoint
|
||
|
||
**No `LoginPage` changes** — this is enforced by the Phase 19 seam contract.
|
||
|
||
### `index.html` wiring contract
|
||
|
||
Current state: one `<link rel="apple-touch-icon">`, one `<meta name="theme-color">`,
|
||
no `<link rel="icon">`.
|
||
|
||
After Phase 17:
|
||
|
||
```html
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<meta name="theme-color" content="{ACCENT_HEX}" />
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||
<meta name="apple-mobile-web-app-title" content="FamilySync" />
|
||
<title>FamilySync</title>
|
||
</head>
|
||
```
|
||
|
||
`{ACCENT_HEX}` = the checkpoint-selected accent value (`#4A90D9` default or warm variant).
|
||
|
||
The SVG favicon takes precedence in modern browsers; the ICO fallback covers legacy.
|
||
Order matters: SVG first, ICO second (browsers pick the first supported type).
|
||
|
||
### `vite.config.ts` manifest wiring contract
|
||
|
||
Current state: 3 icon entries, last entry incorrectly reuses `icon-512.png` for maskable.
|
||
|
||
After Phase 17:
|
||
|
||
```ts
|
||
icons: [
|
||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||
],
|
||
```
|
||
|
||
The `theme_color` value in the manifest must match the `index.html` `theme-color` meta
|
||
and the checkpoint-selected accent. Change from `'#4A90D9'` to the chosen value at checkpoint.
|
||
|
||
---
|
||
|
||
## Workstream C — Theme-Token Groundwork
|
||
|
||
### Contract
|
||
|
||
**Groundwork only.** No dark palette values, no `prefers-color-scheme` media query wired
|
||
to flip themes, no theme toggle UI. Light is and remains the only shipped theme.
|
||
|
||
**Restructure `tokens.css` `:root` into a `data-theme`-capable pattern:**
|
||
|
||
```css
|
||
/* tokens.css — after restructure */
|
||
|
||
:root,
|
||
[data-theme="light"] {
|
||
/* All existing :root declarations move here verbatim. */
|
||
/* No value changes. */
|
||
/* ...all existing tokens... */
|
||
}
|
||
|
||
/* Dark theme stub — values intentionally absent (Phase 17 groundwork only).
|
||
Phase 999.20 fills these values and wires prefers-color-scheme. */
|
||
/* [data-theme="dark"] { ... } */
|
||
```
|
||
|
||
The change is purely structural: `:root` is extended with `[data-theme="light"]` as a
|
||
second selector on the same rule block. This allows a future `data-theme="dark"` attribute
|
||
on `<html>` to override without touching component files.
|
||
|
||
**Schedule-X `--sx-color-*` overrides must remain working.** They are currently at the
|
||
bottom of the same `:root` rule — after the restructure they remain inside the same
|
||
combined `:root, [data-theme="light"]` rule block. The cascade order (after
|
||
`@schedule-x/theme-default`) is unchanged; the overrides continue to win.
|
||
|
||
**`--brand-logo-*` tokens** remain in the same `:root, [data-theme="light"]` block.
|
||
Phase 17's logo swap updates their values here (e.g. `--brand-logo-border-radius`).
|
||
|
||
**New token added in this workstream (in addition to Workstream A's
|
||
`--bottom-chrome-h`):**
|
||
|
||
No additional tokens beyond `--bottom-chrome-h` (Workstream A) and any updated
|
||
`--brand-logo-*` values (Workstream B). The theme restructure introduces no new
|
||
semantic names — only the selector change.
|
||
|
||
**Invariant check:** after restructure, `grep -rn 'var(--' apps/pwa/src/components/`
|
||
must show only `var(--token-name)` references, no hard-coded hex or px values in
|
||
component files. This is the existing invariant; the restructure must not break it.
|
||
|
||
---
|
||
|
||
## Workstream D — UAT-Surfaced UI Fixes
|
||
|
||
### D-07 — Logout control
|
||
|
||
**Surfaces:** SettingsSheet (primary) and optionally AppNav phone header (secondary).
|
||
|
||
**Primary placement: SettingsSheet.**
|
||
A "Sign out" row is added below all existing SettingsSheet content, separated by a
|
||
`var(--color-border-subtle)` horizontal rule (same divider pattern as the existing
|
||
Account section). This mirrors the established section-separator pattern already used
|
||
in SettingsSheet between Notifications and Account.
|
||
|
||
Layout within SettingsSheet (bottom of sheet, after all other rows):
|
||
```
|
||
───────────────────────── ← 1px var(--color-border-subtle) divider, margin var(--space-4) top/bottom
|
||
[LogOut icon 16px] Sign out ← full-width button, minHeight 44px, Body 15px/400, var(--color-destructive)
|
||
```
|
||
|
||
**Logout button style:**
|
||
- `background: none; border: none; cursor: pointer`
|
||
- Full width (`width: 100%`), `display: flex; alignItems: center; gap: var(--space-2)`
|
||
- `LogOut` lucide icon (16px, `var(--color-destructive)`)
|
||
- Label "Sign out" — Body (15px/400), `var(--color-destructive)`
|
||
- `minHeight: 44px` (WCAG tap target)
|
||
- `textAlign: left`, `padding: var(--space-2, 8px) 0`
|
||
- On click: calls `fetchLocalLogout()` (already exists in `apps/pwa/src/api/client.ts:127`),
|
||
then navigates to `/login` (react-router `useNavigate` or `window.location.replace`)
|
||
- No confirmation dialog — logout is not destructive in the "data loss" sense for a
|
||
household app; the user is simply signed out and can re-sign in immediately
|
||
|
||
**Accessibility:**
|
||
- `aria-label="Sign out"` on the button
|
||
- Icon is `aria-hidden="true"`
|
||
- Standard focus ring (`var(--color-focus-ring)`, 2px outline, 2px offset)
|
||
|
||
**No backend work required** — `fetchLocalLogout()` calls the existing
|
||
`POST /api/auth/local/logout` endpoint.
|
||
|
||
### D-08 — Admin success feedback
|
||
|
||
**Surfaces:** AdminPage.tsx — create-member and reset-password flows.
|
||
|
||
**Toast notification design contract:**
|
||
|
||
A lightweight transient toast appears after a successful admin action. Reuses the
|
||
existing `SyncStateToast` visual pattern (already in the codebase) if possible; if not,
|
||
implement a minimal inline variant.
|
||
|
||
Toast style:
|
||
- Position: `fixed; bottom: calc(var(--bottom-chrome-h) + var(--space-4))` on phone;
|
||
`fixed; bottom: var(--space-6); left: 50%; transform: translateX(-50%)` on desktop
|
||
- Background: `var(--color-surface)`, border: `1px solid var(--color-border)`,
|
||
`borderRadius: var(--space-2)`, `boxShadow: 0 2px 8px rgba(0,0,0,0.12)`
|
||
- Padding: `var(--space-3) var(--space-4)` (12px 16px)
|
||
- Content: `CheckCircle` (16px, `var(--color-member-0)`) + toast message text (Label 13px/400,
|
||
`var(--color-text-primary)`)
|
||
- Auto-dismiss: after 3 seconds (no dismiss button needed for a household app)
|
||
- `role="status"`, `aria-live="polite"` — screen readers announce the success
|
||
|
||
**Toast copy variants:**
|
||
|
||
| Action | Toast copy |
|
||
|--------|------------|
|
||
| Create member success | "Member added." |
|
||
| Reset password success | "Password reset." |
|
||
|
||
**Placement note:** the toast bottom offset on phone uses `var(--bottom-chrome-h)`
|
||
(introduced in Workstream A) so it clears the BottomTabBar.
|
||
|
||
### D-09 — Dialog/sheet centering fix
|
||
|
||
**Surfaces:** SettingsSheet, ChangePasswordSheet, LinkOidcSheet in SettingsSheet.tsx;
|
||
AdminPage CredentialSheet; AdminPage reset-password sheet (Surface 11B).
|
||
|
||
**Problem:** sheets currently use `bottom: 0; left: 0; right: 0; maxWidth: 480px;
|
||
margin: 0 auto` — on desktop this places them bottom-center, not truly centered.
|
||
|
||
**Fix contract:**
|
||
|
||
Phone (`≤767px`): bottom-sheet behavior is correct and intentional. No change.
|
||
- `position: fixed; bottom: 0; left: 0; right: 0; borderRadius: 12px 12px 0 0`
|
||
|
||
Desktop (`≥768px`): centered modal.
|
||
- `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%)`
|
||
- `maxWidth: 480px; width: calc(100% - var(--space-8)); borderRadius: 12px`
|
||
- `maxHeight: calc(100dvh - var(--space-8)); overflowY: auto`
|
||
- Remove `bottom: 0; left: 0; right: 0; margin: 0 auto; borderRadius: 12px 12px 0 0`
|
||
- Box shadow: `0 8px 32px rgba(0,0,0,0.18)` (deeper shadow for centered modal feel)
|
||
|
||
**Breakpoint:** use `window.matchMedia('(max-width: 767px)')` — same as `isPhone()`
|
||
in the existing codebase. The behavior is determined at render time; no CSS-only
|
||
media query approach is used (consistent with project pattern of inline React styles).
|
||
|
||
**Applies to all sheets:** SettingsSheet, ChangePasswordSheet, LinkOidcSheet,
|
||
CredentialSheet, AdminPage reset-password sheet. Each gets a phone/desktop style
|
||
branch for its outer `<div role="dialog">`.
|
||
|
||
**Backdrop:** unchanged — `position: fixed; inset: 0; background: var(--color-overlay)`.
|
||
|
||
**Verification:** confirm via playwright-cli at both 390×844 (phone — bottom sheet) and
|
||
1280×720 (desktop — centered modal) before merging.
|
||
|
||
### D-10 — Admin two-tab navigation
|
||
|
||
**Surface:** AdminPage.tsx
|
||
|
||
**Structure:** horizontal tab strip at the top of the AdminPage content column,
|
||
replacing the current single-page long-scroll layout.
|
||
|
||
**Two tabs:**
|
||
|
||
| Tab | Label | Contents |
|
||
|-----|-------|----------|
|
||
| Tab 1 | "Members & Accounts" | MEMBERS section (credential management) + LOCAL ACCOUNTS section (create member + reset-password) |
|
||
| Tab 2 | "Settings" | SHARED CALENDAR section + TIMEZONE section |
|
||
|
||
**Tab strip visual contract:**
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────┐
|
||
│ [Members & Accounts] [Settings] │
|
||
│ ─────────────────── 2px active border-bottom │
|
||
│ 1px var(--color-border-subtle) full-width rule below strip │
|
||
└────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Tab strip container:
|
||
- `display: flex; borderBottom: 1px solid var(--color-border-subtle)`
|
||
- `marginBottom: var(--space-6)` (24px gap before first section)
|
||
|
||
Individual tab button:
|
||
- `background: none; border: none; cursor: pointer`
|
||
- `padding: var(--space-3) var(--space-4)` (12px 16px)
|
||
- `minHeight: 44px` (WCAG tap target)
|
||
- `fontSize: var(--text-label-size, 13px)` (13px)
|
||
- Inactive: `fontWeight: 400; color: var(--color-text-secondary); borderBottom: 2px solid transparent`
|
||
- Active: `fontWeight: 600; color: var(--color-text-primary); borderBottom: 2px solid var(--color-member-0)`
|
||
- `transition: color 0.1s ease, border-color 0.1s ease`
|
||
- `fontFamily: var(--font-family-base)`
|
||
|
||
**Accessibility (roving tabindex / ARIA tabs pattern):**
|
||
- Tab strip container: `role="tablist"`
|
||
- Each tab button: `role="tab"`, `aria-selected={isActive}`,
|
||
`aria-controls="{panel-id}"`, `id="{tab-id}"`
|
||
- Inactive tabs: `tabIndex={-1}` (roving tabindex — only active tab is in tab order)
|
||
- Active tab: `tabIndex={0}`
|
||
- Keyboard navigation within the tablist: `ArrowLeft`/`ArrowRight` move focus + activate tab
|
||
- Tab panel: `role="tabpanel"`, `aria-labelledby="{tab-id}"`, `id="{panel-id}"`
|
||
Each panel receives `tabIndex={0}` so keyboard users can enter the panel content after
|
||
the tablist
|
||
|
||
**Tab IDs:**
|
||
- `id="admin-tab-members"` / `aria-controls="admin-panel-members"`
|
||
- `id="admin-tab-settings"` / `aria-controls="admin-panel-settings"`
|
||
|
||
**Default active tab on mount:** "Members & Accounts" (Tab 1).
|
||
|
||
**Phone behavior:** the two-tab strip eliminates the long-scroll on phone. Each tab's
|
||
content replaces the other. The tab labels are short enough that both fit without
|
||
overflow at 390px width (verify via playwright-cli). No horizontal scroll on the
|
||
tab strip.
|
||
|
||
**No new tokens** — the tab strip uses only existing spacing, color, and typography tokens.
|
||
|
||
---
|
||
|
||
## Copywriting Contract
|
||
|
||
### Workstream D new copy
|
||
|
||
| Element | Copy |
|
||
|---------|------|
|
||
| Logout button label | "Sign out" |
|
||
| Logout button aria-label | "Sign out" |
|
||
| Toast — create member success | "Member added." |
|
||
| Toast — reset password success | "Password reset." |
|
||
| Admin tab 1 label | "Members & Accounts" |
|
||
| Admin tab 2 label | "Settings" |
|
||
|
||
### Copywriting rules (inherited from Phase 19)
|
||
|
||
- Never use "Authelia" in any user-facing copy.
|
||
- Admin copy ("Reset password", "Sign out") is direct — admins are comfortable with the vocabulary.
|
||
- End-user copy is warm and low-friction.
|
||
- "Sign out" (not "Log out" or "Logout") — consistent with friendly household tone.
|
||
- Toast copy is declarative past-tense ("Member added.") not celebratory — keeps the
|
||
admin UI professional.
|
||
|
||
### Empty states
|
||
|
||
No new empty states introduced by Phase 17. The existing AdminPage empty states
|
||
(no members / no calendars synced) remain unchanged.
|
||
|
||
### Error states
|
||
|
||
No new error states introduced by Phase 17. Workstream D's logout has no error path
|
||
(if the API call fails, the user is navigated to `/login` regardless —
|
||
`fetchLocalLogout()` is fire-and-best-effort for a cookie clear).
|
||
|
||
---
|
||
|
||
## Surface Architecture
|
||
|
||
### Surface A-1 — Phone layout (App.tsx `contentStyle`, phone branch)
|
||
|
||
After fix:
|
||
```ts
|
||
const contentStyle: React.CSSProperties = {
|
||
flex: 1,
|
||
minWidth: 0,
|
||
minHeight: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
// Phone-only: reserve space for the fixed BottomTabBar
|
||
...(phone ? { paddingBottom: 'var(--bottom-chrome-h)' } : {}),
|
||
};
|
||
```
|
||
|
||
The `overflow: 'hidden'` on the outer contentStyle traps scroll. The inner route content
|
||
(CalendarShell, ListsIndex, etc.) must handle its own scroll; the padding-bottom ensures
|
||
their scrollable area clears the tab bar.
|
||
|
||
### Surface A-2 — New Event FAB (CalendarShell.tsx)
|
||
|
||
After fix (phone-only FAB style):
|
||
```ts
|
||
// Phone FAB positioning — clears BottomTabBar + adds breathing room
|
||
position: 'fixed',
|
||
bottom: 'calc(var(--bottom-chrome-h) + var(--space-6))',
|
||
right: 'var(--space-6)',
|
||
// size unchanged
|
||
width: '56px',
|
||
height: '56px',
|
||
```
|
||
|
||
### Surface B-1 — BrandSlot (BrandSlot.tsx)
|
||
|
||
After swap:
|
||
```tsx
|
||
<div style={{ textAlign: 'center' }}>
|
||
<img
|
||
src="/logo.svg"
|
||
alt=""
|
||
aria-hidden="true"
|
||
style={{
|
||
width: 'var(--brand-logo-size, 48px)',
|
||
height: 'var(--brand-logo-size, 48px)',
|
||
borderRadius: 'var(--brand-logo-border-radius)',
|
||
margin: '0 auto var(--space-2, 8px)',
|
||
display: 'block',
|
||
aspectRatio: '1 / 1',
|
||
objectFit: 'contain',
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
{/* <h1> and <p> tagline unchanged */}
|
||
</div>
|
||
```
|
||
|
||
`--brand-logo-border-radius` updated in `tokens.css` to suit the logo shape
|
||
(determined at checkpoint; likely `12px` for warm/rounded brief or `50%` if circular).
|
||
|
||
### Surface C-1 — tokens.css structural change
|
||
|
||
Before:
|
||
```css
|
||
:root { /* all tokens */ }
|
||
```
|
||
|
||
After:
|
||
```css
|
||
:root,
|
||
[data-theme="light"] { /* all tokens, values unchanged */ }
|
||
/* [data-theme="dark"] { ... } intentionally absent — Phase 999.20 fills this */
|
||
```
|
||
|
||
### Surface D-1 — SettingsSheet logout row
|
||
|
||
After the existing Account section divider (or at the bottom of the sheet):
|
||
```tsx
|
||
{/* Sign out */}
|
||
<div style={{ height: '1px', background: 'var(--color-border-subtle)', margin: 'var(--space-4) 0' }} />
|
||
<button
|
||
type="button"
|
||
onClick={handleSignOut}
|
||
aria-label="Sign out"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 'var(--space-2)',
|
||
width: '100%',
|
||
minHeight: '44px',
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: 'var(--space-2) 0',
|
||
fontSize: 'var(--text-body-size)',
|
||
fontWeight: 400,
|
||
color: 'var(--color-destructive)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<LogOut size={16} aria-hidden="true" />
|
||
Sign out
|
||
</button>
|
||
```
|
||
|
||
`handleSignOut` calls `fetchLocalLogout()` then navigates to `/login`.
|
||
|
||
### Surface D-2 — Admin two-tab strip (AdminPage.tsx)
|
||
|
||
Rendered above all AdminPage content, inside the content column:
|
||
- `<div role="tablist">` containing two `<button role="tab">` elements
|
||
- Active tab panel renders below the strip, replacing the full-page scroll
|
||
|
||
### Surface D-3 — Dialog/sheet centering
|
||
|
||
All existing sheets (SettingsSheet, ChangePasswordSheet, LinkOidcSheet, CredentialSheet,
|
||
admin reset-password sheet) gain a phone/desktop style branch on their outer
|
||
`<div role="dialog">` element. Desktop: centered modal. Phone: unchanged bottom-sheet.
|
||
|
||
---
|
||
|
||
## Interaction Contract
|
||
|
||
### Logout flow (D-07)
|
||
|
||
```
|
||
User taps "Sign out" in SettingsSheet
|
||
→ fetchLocalLogout() called (POST /api/auth/local/logout, fire-and-best-effort)
|
||
→ SettingsSheet closes (setSettingsOpen(false) in App.tsx or internal close)
|
||
→ navigate('/login') (react-router useNavigate or window.location.replace('/login'))
|
||
→ LoginPage renders
|
||
```
|
||
|
||
No confirmation step. The action is low-stakes (re-login is immediate; no data is lost).
|
||
|
||
### Toast lifecycle (D-08)
|
||
|
||
```
|
||
Admin action succeeds (mutation onSuccess)
|
||
→ toast state set with message
|
||
→ toast renders in DOM with role="status" aria-live="polite"
|
||
→ after 3000ms: toast state cleared, toast unmounts
|
||
```
|
||
|
||
One toast at a time (a second action while a toast is showing replaces the message).
|
||
|
||
### Admin tab switching (D-10)
|
||
|
||
```
|
||
User taps inactive tab
|
||
→ active tab state updates
|
||
→ previous panel hidden (or unmounted)
|
||
→ new panel shown
|
||
→ ArrowLeft/ArrowRight keyboard: focus moves between tabs + activates
|
||
→ Tab key: enters the active panel content (tabIndex={0} on tabpanel)
|
||
```
|
||
|
||
Tab state is local `useState` in AdminPage. No URL routing change — the admin route
|
||
stays `/admin`; tab state does not persist across navigation.
|
||
|
||
---
|
||
|
||
## Accessibility Contract
|
||
|
||
### Workstream A
|
||
|
||
- All existing layout.spec.ts assertions continue to pass
|
||
- The overlap CI assertion added to layout.spec.ts runs on iphone + pixel profiles
|
||
- No new accessibility concerns introduced by CSS-only padding/offset changes
|
||
|
||
### Workstream B (BrandSlot)
|
||
|
||
- `<img alt="" aria-hidden="true">` — logo is decorative; `<h1>FamilySync</h1>` is the
|
||
accessible page label
|
||
- No layout shift on the login page (`aspect-ratio: 1/1` + explicit width preserves intrinsic size)
|
||
- Tagline `<p>` and `<h1>` remain as text (never replaced with an image)
|
||
|
||
### Workstream C
|
||
|
||
- Purely structural CSS change; no accessibility impact
|
||
|
||
### Workstream D (logout, toast, dialogs, tabs)
|
||
|
||
- Logout button: `aria-label="Sign out"`, `minHeight: 44px`, standard focus ring
|
||
- Toast: `role="status"`, `aria-live="polite"`, `aria-atomic="true"` — announced by screen readers
|
||
- Sheet centering (D-09): `role="dialog"`, `aria-modal="true"`, `aria-label` matching heading,
|
||
Escape closes, focus returns to trigger — these invariants are unchanged; only the
|
||
CSS position changes
|
||
- Admin tabs: full ARIA tabs pattern — `role="tablist"`, `role="tab"`, `aria-selected`,
|
||
`aria-controls`, roving tabindex, `ArrowLeft`/`ArrowRight` keyboard navigation,
|
||
`role="tabpanel"`, `aria-labelledby`
|
||
- All interactive elements: `minHeight: 44px`, focus ring via `var(--color-focus-ring)`
|
||
|
||
---
|
||
|
||
## Responsive Behavior
|
||
|
||
| Surface | Phone (≤767px) | Desktop (≥768px) |
|
||
|---------|---------------|-----------------|
|
||
| FAB | `bottom: calc(var(--bottom-chrome-h) + var(--space-6))` — clears bar | Desktop toolbar button (no FAB); unchanged |
|
||
| Content area | `paddingBottom: var(--bottom-chrome-h)` | No padding-bottom (no bar) |
|
||
| Sheets/dialogs | Bottom-sheet (`bottom: 0; left: 0; right: 0; borderRadius: 12px 12px 0 0`) | Centered modal (`top: 50%; left: 50%; transform: translate(-50%, -50%); borderRadius: 12px`) |
|
||
| Admin tab strip | Full-width, both tabs fit at 390px; tab labels short enough to avoid overflow | Same — no change at desktop (tab strip still renders, content is narrower anyway) |
|
||
| Toast | `bottom: calc(var(--bottom-chrome-h) + var(--space-4))` — above BottomTabBar | `bottom: var(--space-6); left: 50%; transform: translateX(-50%)` |
|
||
| BrandSlot | Phone-first (LoginPage layout unchanged) | Centered, same as phone |
|
||
|
||
---
|
||
|
||
## Registry Safety
|
||
|
||
| Registry | Blocks Used | Safety Gate |
|
||
|----------|-------------|-------------|
|
||
| shadcn official | none — not initialized | not applicable |
|
||
| third-party | none | not applicable |
|
||
|
||
No third-party component registries. All components hand-rolled following existing project
|
||
convention. No new npm dependencies for UI are required by this phase.
|
||
|
||
---
|
||
|
||
## Pre-Population Sources
|
||
|
||
| Decision | Source | Value |
|
||
|----------|--------|-------|
|
||
| Spacing scale | apps/pwa/src/styles/tokens.css | --space-1 through --space-12; one new token --bottom-chrome-h |
|
||
| Typography scale | apps/pwa/src/styles/tokens.css | 4 sizes (13/15/18/24px), 2 weights (400/600) |
|
||
| Color palette | apps/pwa/src/styles/tokens.css | All hex values; no new colors |
|
||
| Component library | apps/pwa convention | Hand-rolled inline React.CSSProperties; no shadcn |
|
||
| Icon library | apps/pwa imports | lucide-react (already installed; LogOut needed for D-07) |
|
||
| Phone breakpoint | App.tsx, BottomTabBar.tsx, CalendarShell.tsx | `isPhone()` = `window.matchMedia('(max-width: 767px)')` |
|
||
| BottomTabBar height | BottomTabBar.tsx | `calc(56px + env(safe-area-inset-bottom, 0px))` |
|
||
| FAB current position | CalendarShell.tsx | `bottom: var(--space-6); right: var(--space-6)` |
|
||
| BrandSlot seam | 19-UI-SPEC.md §Brand Slot + BrandSlot.tsx | Phase 19 contract; Phase 17 swaps internals only |
|
||
| --brand-logo-* tokens | apps/pwa/src/styles/tokens.css | Placeholder defaults set in Phase 19 |
|
||
| Sheet/dialog pattern | SettingsSheet.tsx, CredentialSheet.tsx | role=dialog, aria-modal, Escape closes, focus-return |
|
||
| Admin section label style | AdminPage.tsx `sectionLabelStyle` | 13px/600/uppercase/0.06em |
|
||
| Logout function | apps/pwa/src/api/client.ts:127 | `fetchLocalLogout()` — already exists, no backend work |
|
||
| Toast pattern | SyncStateToast.tsx | Visual baseline for success toast |
|
||
| FAB overlap defect evidence | .planning/todos/pending/2026-06-13-pwa-phone-bottombar-overlap.md | Reproduced at 390×844; FAB over Admin tab; legend clipped |
|
||
| Brand accent checkpoint | 17-CONTEXT.md Q1 answer | Two candidates producible; checkpoint before committing |
|
||
| Admin two-tab layout | 17-CONTEXT.md Q2 answer | Two tabs: "Members & Accounts" + "Settings"; roving tabindex |
|
||
| Dark theme / toggle | 17-CONTEXT.md §Deferred | Out of scope — backlog 999.20 |
|
||
| Visual refresh | 17-CONTEXT.md §Deferred | Out of scope — backlog 999.21 |
|
||
|
||
---
|
||
|
||
## Checker Sign-Off
|
||
|
||
- [ ] Dimension 1 Copywriting: PASS
|
||
- [ ] Dimension 2 Visuals: PASS
|
||
- [ ] Dimension 3 Color: PASS
|
||
- [ ] Dimension 4 Typography: PASS
|
||
- [ ] Dimension 5 Spacing: PASS
|
||
- [ ] Dimension 6 Registry Safety: PASS
|
||
|
||
**Approval:** pending
|