Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
396 lines
21 KiB
Markdown
396 lines
21 KiB
Markdown
---
|
||
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` |
|