---
phase: 7
slug: mobile-test-harness
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-11
framing: quality-bar-contract
---

# Phase 7 — Mobile UI Quality-Bar Contract

> This phase builds **no new UI**. The harness asserts against the existing
> FamilySync PWA. This document is a **quality-bar contract**, not a design
> system spec. Its job is to pin every measurable threshold the harness must
> enforce so the planner can turn each rule into a concrete Playwright
> assertion. Template sections that have no assertable content for a test
> harness are marked N/A with a one-line reason.

---

## Design System

N/A — test harness, no new UI. The existing design system is declared in
`apps/pwa/src/styles/tokens.css` and consumed by the assertions below.

| Property | Value |
|----------|-------|
| Tool | none (no shadcn; inline CSS custom properties) |
| Preset | not applicable |
| Component library | none (lucide-react icons; Schedule-X calendar widget) |
| Icon library | lucide-react (via npm dep, no CDN) |
| Font | `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` |

---

## Spacing Scale

N/A — test harness, no new UI. Spacing tokens are declared in `tokens.css`
and are not re-specified here. Assertions reference computed pixel values
derived from those tokens where needed (e.g. BottomTabBar height = 56px +
safe-area-inset).

---

## Typography

N/A — test harness, no new UI. Typography tokens exist in `tokens.css`; the
harness does not assert on font metrics unless a visible-text / accessible-name
check requires it (captured in Assertion Contract below).

---

## Color

N/A — test harness, no new UI. The 60/30/10 color split is declared in
`tokens.css`. The harness does not assert computed colors — color drift is
out of scope and prone to rendering-pipeline variance.

---

## Copywriting Contract

Copywriting that the harness **must** be able to locate by text in assertions.
These are the exact strings emitted by the existing components; the harness
uses them as stable locator anchors.

| Element | Exact String | Source Component |
|---------|-------------|------------------|
| Calendar empty-state heading | `Nothing here` | `EmptyState.tsx` |
| Calendar empty-state body | `No events in this period. Try a different date or switch views.` | `EmptyState.tsx` |
| Lists empty-state heading | `No lists yet` | `ListsEmptyState.tsx` |
| Lists empty-state body | `Tap + to create your first shared list` (contains) | `ListsEmptyState.tsx` |
| Calendar error heading | `Couldn't load events` | `CalendarShell.tsx` |
| Calendar error CTA | `Retry` (button text) | `CalendarShell.tsx` |
| New Event FAB | `aria-label="New Event"` | `CalendarShell.tsx` |
| Bottom nav — Calendar tab | `aria-label="Calendar"` | `BottomTabBar.tsx` |
| Bottom nav — Lists tab | `aria-label="Lists"` | `BottomTabBar.tsx` |
| Top nav (phone) | `FamilySync` (visible text) | `AppNav.tsx` — `PhoneNav` |
| Settings button | `aria-label` contains `open settings` | `AppNav.tsx` — `PhoneNav` |

> Stable copywriting anchor rule: **always locate interactive elements by
> `aria-label` or `role` + accessible name first.** Text-content locators
> (`getByText`) are second resort — acceptable for static headings/bodies
> that have no ARIA role.

---

## Registry Safety

N/A — test harness, no new UI components. `@playwright/test` is a new dev
dependency in `apps/pwa`; it is the official Playwright package from the
Playwright team and requires no safety vetting under this gate.

---

## Assertion Contract

This section is the primary deliverable for Phase 7. It replaces the
design-system sections of the standard template with the measurable
quality-bar rules that the harness enforces.

### Device / Viewport Matrix

| Profile ID | Playwright Descriptor | Engine | Viewport | UA Type |
|---|---|---|---|---|
| `iphone` | `'iPhone 14'` | WebKit | 390×844 logical px | Mobile Safari |
| `pixel` | `'Pixel 7'` | Chromium | 412×915 logical px | Chrome Android |

**Source:** D-03 (iPhone + Pixel matrix), D-04 (WebKit for iPhone, Chromium
for Pixel). These are the exact Playwright device descriptor strings to pass
to `devices['iPhone 14']` and `devices['Pixel 7']` in `playwright.config.ts`.

Both profiles run with `serviceWorkers: 'block'` (D-02 / Pitfall 15) and
`DEV_AUTH_BYPASS=true` (D-01 / Pitfall 14). No `storageState` file.

**CI note:** Both engines must be installed in the Phase 8 CI image. The
harness adds WebKit beyond the existing global `playwright-cli` (Chromium
only). Accept the larger CI image cost — this was a deliberate call (D-04,
07-CONTEXT.md §Specifics).

---

### Rule 1 — Touch-Target Minimum

**Threshold:** Every interactive element (button, link, `role="button"`) must
have a computed bounding box of **≥ 44 × 44 logical pixels**.

**Basis:**
- Apple Human Interface Guidelines: minimum touch target 44×44 pt.
- WCAG 2.5.5 (Level AAA): minimum 44×44 CSS px.
- The existing codebase declares this as a hard constraint: `BottomTabBar`
  uses `minHeight: '44px'`; `AppNav` `PhoneNav` settings button uses
  `minWidth: '44px', minHeight: '44px'`; calendar FAB is `56×56px`; Retry
  button uses `minHeight: '44px'`; nav links use `minHeight: '44px'`.

**Measurement approach:**
```typescript
// Use boundingBox() on the element handle, not CSS-declared values.
const box = await element.boundingBox()
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
```

**What counts as an interactive target:**
- `<button>` elements (including FAB, Retry, settings avatar button)
- `<a>` and `NavLink` elements (BottomTabBar tabs, sidebar nav links)
- Any element with `role="button"`, `role="link"`, or `tabindex="0"` that
  has a click/tap handler

**Explicit elements to assert on both profiles:**

| Element | Expected min size | Locator strategy |
|---|---|---|
| BottomTabBar Calendar tab | 44×44 | `getByRole('link', { name: 'Calendar' })` |
| BottomTabBar Lists tab | 44×44 | `getByRole('link', { name: 'Lists' })` |
| PhoneNav settings button | 44×44 | `getByRole('button', { name: /open settings/i })` |
| New Event FAB | 56×56 | `getByRole('button', { name: 'New Event' })` |
| Retry button (error state) | 44×44 | `getByRole('button', { name: 'Retry' })` |

**BottomTabBar phone-only gate:** `BottomTabBar` renders `null` on desktop
(`matchMedia('(max-width: 767px)')`). Assert it is present on both mobile
profiles (390px and 412px width) and absent on desktop (1280px). Both test
profiles qualify as phone-width so the bar must be visible.

---

### Rule 2 — No Horizontal Overflow

**Threshold:** On every tested route, `document.documentElement.scrollWidth`
must equal `document.documentElement.clientWidth`. No horizontal scrollbar;
no content overflow.

**Measurement approach:**
```typescript
const overflow = await page.evaluate(() => ({
  scrollWidth: document.documentElement.scrollWidth,
  clientWidth: document.documentElement.clientWidth,
}))
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth)
```

**Routes to assert on both profiles:**

| Route | State to assert |
|---|---|
| `/calendar` | populated (seeded events) |
| `/calendar` | error state (simulated — mock API to 500) |
| `/lists` | populated (seeded list + items) |
| `/lists` | empty state (no lists — dev-bypass user 1 native state) |

**Allowed exceptions:** none. The Schedule-X calendar widget historically
caused overflow on narrow viewports (see memory entry `schedule-x-allday-event-styling`).
If a Schedule-X internal element overflows, the assertion must still fail —
this is the defect the harness exists to catch.

---

### Rule 3 — Critical Elements Visible and In-Viewport

Assertion: each element below must be visible (`isVisible() === true`) **and**
within the viewport (`boundingBox().y >= 0`, `boundingBox().y + height <=
viewport.height`) on initial load, before any scroll.

| Element | Route | Profile |
|---|---|---|
| BottomTabBar | `/calendar`, `/lists` | iPhone + Pixel |
| PhoneNav header | `/calendar`, `/lists` | iPhone + Pixel |
| Schedule-X calendar grid | `/calendar` (populated) | iPhone + Pixel |
| New Event FAB | `/calendar` | iPhone + Pixel |
| Lists index cards (≥1 card) | `/lists` (seeded) | iPhone + Pixel |

**BottomTabBar position assertion (safe-area-inset):** the bar uses
`env(safe-area-inset-bottom, 0px)`. In the emulated context there is no
safe-area-inset, so the bar's bottom edge must be ≤ the viewport height.
Assert `boundingBox().y + boundingBox().height <= page.viewportSize().height`.

---

### Rule 4 — Accessible Names on All Interactive Elements

Assertion: every interactive element exposed to the assertions above must
have a non-empty accessible name, locatable via Playwright's ARIA role
queries without needing a CSS selector fallback.

**Required accessible names (exact or pattern):**

| Element | Role | Expected accessible name |
|---|---|---|
| BottomTabBar Calendar tab | `link` | `"Calendar"` |
| BottomTabBar Lists tab | `link` | `"Lists"` |
| PhoneNav settings button | `button` | matches `/open settings/i` |
| New Event FAB | `button` | `"New Event"` |
| Retry button | `button` | `"Retry"` |
| Main navigation landmark | `navigation` | `"Main navigation"` |

Locator pattern:
```typescript
page.getByRole('link', { name: 'Calendar' })
page.getByRole('button', { name: /open settings/i })
```

If an element cannot be found by role + name, the test fails. This doubles as
a regression gate for accessible-name regressions (e.g. a button losing its
`aria-label`).

---

### Rule 5 — Empty States Render Correctly

**Context (D-05):** DEV_AUTH_BYPASS user 1 natively has no CalDAV credentials
or calendars. Without seeding, calendar views render empty and list views
render empty. This is the "native empty" state.

**Seeded populated state:** D-06 seeds deterministic fixtures before each run
(global-setup truncate/insert). Seeding targets shared calendar id 10 (from
project memory `dev-data-user1-no-calendars`) and creates ≥1 list with ≥2
items for user 1 (via direct MariaDB insert, not the API, since live
event-create 422s for user 1).

**Assertions:**

| State | Route | Assert |
|---|---|---|
| Populated calendar | `/calendar` (after seeding) | Schedule-X grid is visible; `<EmptyState>` is NOT in DOM |
| Populated lists | `/lists` (after seeding) | ≥1 list card is visible; `ListsEmptyState` is NOT in DOM |
| Empty lists (pre-seed teardown or clean run) | `/lists` | `getByText('No lists yet')` is visible; `getByText(/Tap \+ to create/)` is visible |
| Calendar error | `/calendar` (API mocked to 500) | `getByRole('heading', { name: "Couldn't load events" })` is visible; `getByRole('button', { name: 'Retry' })` is visible |

**Empty-state assertion depth:** each empty state must additionally pass Rule
1 (touch targets on any interactive elements within it) and Rule 2 (no
horizontal overflow).

---

### Rule 6 — Visual Snapshots

**Decision: OMIT `toHaveScreenshot()` assertions entirely for this phase.**

Rationale (from D-08-area steer in 07-CONTEXT.md):
- The Schedule-X calendar widget renders dynamic content (current date
  highlighted, event chips placed by the widget's internal layout engine)
  that will differ between host and CI renderers on different dates and OS
  font-rendering pipelines.
- CI-generated baselines + tolerance configuration (`maxDiffPixelRatio`,
  `threshold`) address pixel variance but not date-dependent layout changes
  (today's date highlight shifts every day; event chip wrapping varies by
  viewport pixel density).
- There is no established prior art in this codebase for non-flaky
  Schedule-X snapshot tests across host↔CI WebKit.
- The structural + role-based locator assertions in Rules 1–5 cover the
  quality bar with zero rendering-pipeline variance.

**If added later:** snapshots must use CI-generated baselines only
(`--update-snapshots` run in the CI environment on first run), store baselines
per browser engine under `apps/pwa/e2e/snapshots/{browser}/`, and set
`maxDiffPixelRatio: 0.03`. Snapshots must be scoped to static UI elements
(e.g. BottomTabBar only, clipped), not the full viewport containing
Schedule-X.

---

### Rule 7 — Auth and Service Worker Preconditions

These are not UI-quality assertions but are preconditions that must hold for
all other assertions to be valid. They are enforced in global-setup and
browser context options.

| Precondition | Enforcement | Source |
|---|---|---|
| `DEV_AUTH_BYPASS=true` in API process | Env var set before dev-server launch | D-01 / Pitfall 14 |
| `serviceWorkers: 'block'` on every context | `playwright.config.ts` contextOptions | D-02 / Pitfall 15 |
| No `storageState` file | `playwright.config.ts` — omit `storageState` | D-01 / Pitfall 14 |
| PWA reachable before specs run | global-setup polls `GET /health` until 200 | D-08 / SC #3 |
| DB fixtures reset before run | global-setup truncate + insert | D-06 |
| No SW-sourced responses | Playwright trace shows no `(ServiceWorker)` source | D-02 / Pitfall 15 |

**SW-source verification (in trace):** after a run, if a test fails with
unexpected data, inspect the `.zip` trace artifact. Any response with source
`(ServiceWorker)` is a contract violation — the `serviceWorkers: 'block'`
option should prevent this. Log a test failure if detected programmatically:

```typescript
// In each test: attach a route listener to flag SW-sourced responses
page.on('response', (resp) => {
  // Playwright does not expose SW-source in the Response object directly;
  // rely on serviceWorkers: 'block' and trace inspection for post-hoc audit.
})
```

---

### Rule 8 — CI Portability

Assertions and harness configuration must produce identical pass/fail results
when run:
1. Locally against the operator's already-running dev stack (Vite PWA +
   API + compose MariaDB/Redis).
2. In Gitea CI against a runner-brought-up dev stack (Phase 8).

**Contract rules:**

| Rule | Enforcement |
|---|---|
| `baseURL` is env-driven (`PLAYWRIGHT_BASE_URL`, fallback `http://localhost:5173`) | `playwright.config.ts` `use.baseURL` |
| No hardcoded `localhost:5173` in spec files | Lint / code review gate |
| Readiness gate in global-setup polls `baseURL + '/health'` until 200 or timeout 60s | `playwright.config.ts` `globalSetup` |
| DB seed uses `DB_HOST` env (fallback `127.0.0.1`), port 3306, same `.env` creds | global-setup `mysql2` connection |
| No spec imports a dev-only module path that does not exist in CI | Jest/Playwright import resolution |
| Browser binaries installed at `apps/pwa` level via `@playwright/test` dep | `apps/pwa/package.json` `devDependencies` |

---

## Checker Sign-Off

> For this phase the checker validates the quality-bar contract dimensions,
> not the standard design-system dimensions.

- [ ] Dimension 1 Copywriting: stable text anchors declared for all empty/error/nav states
- [ ] Dimension 2 Structural: role+name locators declared for all interactive elements
- [ ] Dimension 3 Touch Targets: ≥44px threshold declared with measurement approach
- [ ] Dimension 4 Overflow: `scrollWidth ≤ clientWidth` rule declared with approach
- [ ] Dimension 5 Viewport Matrix: two profiles with correct engines declared (D-03/D-04)
- [ ] Dimension 6 Registry Safety: N/A — `@playwright/test` is official, no vetting required

**Approval:** pending

---

## Source Decisions

| Decision | Source |
|---|---|
| D-01 DEV_AUTH_BYPASS, no storage-state | 07-CONTEXT.md |
| D-02 serviceWorkers: 'block' | 07-CONTEXT.md |
| D-03 iPhone + Pixel two-profile matrix | 07-CONTEXT.md |
| D-04 WebKit for iPhone, Chromium for Pixel | 07-CONTEXT.md |
| D-05 hybrid seed strategy | 07-CONTEXT.md |
| D-06 deterministic reset-per-run seed | 07-CONTEXT.md |
| D-07 global-setup for seeding | 07-CONTEXT.md |
| D-08 readiness gate + configurable baseURL | 07-CONTEXT.md |
| D-09 stack lifecycle is caller's responsibility | 07-CONTEXT.md |
| D-10 optional webServer for Vite | 07-CONTEXT.md |
| 44px threshold | Apple HIG; WCAG 2.5.5; existing codebase pattern |
| Screenshot omission | D-08-area steer; Schedule-X drift risk; 07-CONTEXT.md |
| Pitfall 14 (storage-state stale) | PITFALLS.md §Pitfall 14 |
| Pitfall 15 (SW intercept) | PITFALLS.md §Pitfall 15 |
| Existing tokens/copy strings | `tokens.css`, `EmptyState.tsx`, `ListsEmptyState.tsx`, `CalendarShell.tsx`, `AppNav.tsx`, `BottomTabBar.tsx` |
