---
phase: 07-mobile-test-harness
plan: '03'
subsystem: test-harness
tags: [playwright, e2e, layout, tap-targets, overflow, accessibility, self-validation]
dependency_graph:
  requires:
    - "apps/pwa/playwright.config.ts (07-01) — iphone/pixel project matrix, serviceWorkers: 'block'"
    - 'apps/pwa/e2e/global-setup.ts (07-02) — /health readiness gate + DB seed (calendar_id=10, E2E Grocery List)'
  provides:
    - 'apps/pwa/e2e/layout.spec.ts — UI-SPEC Rules 1-4 assertions (tap targets, overflow, in-viewport, accessible names)'
    - 'Harness self-validation: addStyleTag injected-defect proofs for Rule 1 + Rule 2'
    - '30 tests (15 per profile) — all passing on iphone (WebKit) and pixel (Chromium)'
  affects:
    - 'Phase 07 plan 04 (calendar.spec.ts / lists.spec.ts share the same harness foundation)'
    - 'Phase 08 CI (layout.spec.ts is a PR regression step)'
tech_stack:
  added: []
  patterns:
    - 'boundingBox() — rendered geometry measurement, not CSS-declared values'
    - 'page.evaluate(() => scrollWidth/clientWidth) — DOM overflow measurement'
    - 'page.addStyleTag + handle.evaluate(el => el.remove()) — injected-defect proof pattern'
    - "getByRole('navigation', { name }) scoping — avoids strict-mode collision between BottomTabBar and DesktopNav"
    - "getByText('FamilySync', { exact: true }) — avoids matching 'Install FamilySync' install-prompt"
key_files:
  created:
    - apps/pwa/e2e/layout.spec.ts
  modified:
    - apps/pwa/e2e/global-setup.ts
decisions:
  - "D-03-SCOPE-NAV: On mobile profiles (390px/412px), AppNav renders PhoneNav as <header> (not a nav landmark) — only BottomTabBar exposes <nav aria-label='Main navigation'>. No strict-mode collision in practice, but tap-target locators are scoped inside the nav landmark for robustness."
  - "D-03-PHONENAV-TEXT: getByText('FamilySync', { exact: true }) required — the InstallPrompt renders 'Install FamilySync', which getByText('FamilySync') without exact:true matches as a substring, causing a strict-mode violation on WebKit."
  - 'D-03-SELF-VALIDATION: Self-validation proofs use addStyleTag + handle.evaluate(el => el.remove()) to inject and remove the defect style within the same test. No page.reload() needed — handle removal is synchronous and immediately clears the injected CSS.'
metrics:
  duration_seconds: 480
  completed_date: '2026-06-11'
  tasks_completed: 2
  files_changed: 2
---

# Phase 07 Plan 03: layout.spec.ts Layout Assertions Summary

**One-liner:** layout.spec.ts enforcing UI-SPEC Rules 1-4 (tap targets ≥44/56px, no horizontal overflow, in-viewport, accessible names) on iPhone/WebKit + Pixel/Chromium with addStyleTag injected-defect proofs — 30 tests, 0 failures.

## What Was Built

`apps/pwa/e2e/layout.spec.ts` with four describe blocks covering:

**Rule 1/3/4 — BottomTabBar on /calendar (8 tests per profile)**

- Navigation landmark visible (Rule 4 — accessible name proof)
- Calendar tab boundingBox ≥ 44×44px (Rule 1)
- Lists tab boundingBox ≥ 44×44px (Rule 1)
- BottomTabBar bottom edge ≤ viewport height (Rule 3 — in-viewport, safe-area-inset)
- PhoneNav header "FamilySync" visible (Rule 3)
- Settings button boundingBox ≥ 44×44px (`getByRole('button', { name: /open settings/i })`)
- New Event FAB boundingBox ≥ 56×56px (Rule 1 — larger threshold)

**Rule 1/3/4 — BottomTabBar on /lists (4 tests per profile)**

- Navigation landmark visible on /lists
- Calendar tab ≥ 44×44px on /lists
- Lists tab ≥ 44×44px on /lists
- BottomTabBar in-viewport on /lists

**Rule 2 — No horizontal overflow (2 tests per profile)**

- `scrollWidth ≤ clientWidth` on /calendar
- `scrollWidth ≤ clientWidth` on /lists

**Harness self-validation — injected defects (2 tests per profile)**

- Rule 1 proof: injects `nav[aria-label="Main navigation"] a { height: 20px !important }`, asserts height < 44, removes, asserts height ≥ 44 — proves boundingBox tracks rendered geometry
- Rule 2 proof: injects `body { width: 2000px !important }`, asserts scrollWidth > clientWidth, removes, asserts scrollWidth ≤ clientWidth — proves overflow detection is live

**Total: 30 tests (15 iphone, 15 pixel), 0 failures.**

## Verification Evidence

- `playwright test e2e/layout.spec.ts` (both profiles): `30 passed`
- `grep -E "https?://" apps/pwa/e2e/layout.spec.ts` → empty (no absolute URLs)
- `grep -c addStyleTag apps/pwa/e2e/layout.spec.ts` → 2 (both self-validation proofs present)
- All locators use `getByRole(..., { name })` or scoped-within-nav — no CSS selector fallback
- No strict-mode "resolved to N elements" errors in either profile run

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] WebKit strict-mode violation: `getByText('FamilySync')` matched 2 elements**

- **Found during:** Task 1 first iphone run
- **Issue:** `getByText('FamilySync')` without `exact:true` also matched the `<div>Install FamilySync</div>` text in the InstallPrompt component, causing a strict-mode violation on WebKit (where the install prompt was visible).
- **Fix:** Changed to `getByText('FamilySync', { exact: true })` — matches only the `<span>FamilySync</span>` in PhoneNav.
- **Files modified:** `apps/pwa/e2e/layout.spec.ts`
- **Commit:** 52e14a8

**2. [Rule 3 - Blocking] global-setup.ts: MariaDB TIMESTAMP rejected ISO 8601 format**

- **Found during:** Task 1 execution — global-setup failed before any spec could run
- **Issue:** `futureStart.toISOString().replace(/\.\d+Z$/, 'Z')` produces `'2026-06-12T05:58:35Z'` (with `T` separator), which MariaDB TIMESTAMP rejects with `Incorrect datetime value`. MariaDB requires `'YYYY-MM-DD HH:MM:SS'` format.
- **Fix:** Added `.replace('T', ' ')` and removed the trailing `Z` — produces `'2026-06-12 05:58:35'` which MariaDB TIMESTAMP accepts.
- **Files modified:** `apps/pwa/e2e/global-setup.ts`
- **Commit:** d3c6726 (fix(07-02))

**3. [Observation] DesktopNav nav landmark absent on mobile profiles — no strict-mode risk**

- **Found during:** Component analysis before writing locators
- **Issue:** The plan warned about strict-mode collision between BottomTabBar nav and DesktopNav nav, both named "Main navigation". In practice, on mobile profiles (390px/412px), `AppNav` renders `PhoneNav` (a `<header>`, not a nav), so DesktopNav's nav is absent. No collision occurs.
- **Fix:** Still scoped tap-target locators inside `getByRole('navigation', { name: 'Main navigation' })` for defensive robustness against any future layout change.
- **Files modified:** None (design decision, no code change)

## Known Stubs

None.

## Threat Surface Scan

No new network endpoints, auth paths, or schema changes. `layout.spec.ts` is a test-only file. Threat mitigations from plan:

- **T-07-07 (injected style leakage):** Each self-validation test removes the injected style via `handle.evaluate(el => el.remove())` within the same test before completing. Styles are page-scoped and do not persist across navigations or test contexts.
- **T-07-08 (hardcoded host):** `grep -E "https?://" apps/pwa/e2e/layout.spec.ts` returns empty — all navigation uses relative paths (`/calendar`, `/lists`) that resolve against `playwright.config.ts` `baseURL`.
- **T-07-12 (SW intercept):** `serviceWorkers: 'block'` is set per-context in `playwright.config.ts` (Plan 01). Geometry assertions (boundingBox, scrollWidth) cannot be satisfied by a cached SW response, so the assertions remain authoritative even if the block were bypassed.

## Self-Check: PASSED

- `apps/pwa/e2e/layout.spec.ts` — exists (`git show --stat 52e14a8`)
- `apps/pwa/e2e/global-setup.ts` — modified (fix commit d3c6726)
- Fix commit `d3c6726` — exists
- Task commit `52e14a8` — exists
- 30 tests passing on both profiles — verified by final run output
