diff --git a/.planning/phases/07-mobile-test-harness/07-RESEARCH.md b/.planning/phases/07-mobile-test-harness/07-RESEARCH.md
new file mode 100644
index 0000000..b487285
--- /dev/null
+++ b/.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@@ -0,0 +1,852 @@
+# Phase 7: Mobile Test Harness — Research
+
+**Researched:** 2026-06-10
+**Domain:** Playwright E2E test infrastructure — mobile device emulation, authenticated dev bypass, DB seeding, CI portability
+**Confidence:** HIGH (primary Playwright API verified via Context7 + npm registry)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-01:** Auth via `DEV_AUTH_BYPASS=true` on the host-side dev stack — **never** a checked-in `storage-state.json` with an expiring session cookie (Pitfall 14). No Authelia/OIDC mocking. Dev-bypass resolves to Dev User id 1.
+- **D-02:** Playwright context uses `serviceWorkers: 'block'` so the PWA's `injectManifest` SW (`sw.js`, `registerType: 'autoUpdate'`) cannot intercept requests / return stale cached responses (Pitfall 15). Verify the trace shows no SW-sourced responses.
+- **D-03:** Run a **two-profile matrix: iPhone + Pixel** — covers both household ecosystems (Apple + Android/Fastmail). The iPhone profile satisfies the hard non-technical-Apple-member UX constraint; Pixel covers Chrome-viewport defects.
+- **D-04:** Use **faithful browser engines** per profile: iPhone → **WebKit**, Pixel → **Chromium**. Adds a WebKit browser to the harness/CI image. (Note: this exceeds the existing global `playwright-cli` Chromium tooling — the harness brings its own `@playwright/test` browsers.) SW-block + dev-bypass apply to both profiles.
+- **D-05:** **Hybrid** — seed deterministic DB fixtures for populated views **and** keep explicit empty-state assertions. Dev-bypass user 1 natively has no calendars (calendar/list views render empty, live create 422s), so populated coverage requires seeding.
+- **D-06:** Seeding is **deterministic and reset per run** (truncate/reset → insert, not insert-if-absent) to guarantee repeatable day-over-day results with no stale state (SC #3). Seed onto the shared calendar (id 10, per prior project memory) + list items so user 1's views render populated.
+- **D-07:** Seeding runs in **global-setup** against the dev MariaDB (already port-bound on 3306 via `docker-compose.dev.yml`); teardown/reset keeps runs idempotent.
+- **D-08:** Harness targets a **configurable `baseURL`** (env-driven: operator's vite dev server locally, CI service host in Phase 8) with a **readiness gate in global-setup** (wait on `/health` before any spec; mirrors the PITFALLS CI-readiness guidance to avoid flaky ECONNREFUSED).
+- **D-09:** **Stack bring-up is the caller's responsibility** — operator's already-running dev stack locally, compose orchestration in Phase 8 CI. The harness never depends on a pre-running stack; it waits for one. Satisfies SC #4.
+- **D-10:** Optionally use Playwright `webServer` for **vite only** with `reuseExistingServer: !process.env.CI` (reuse the operator's `pnpm dev` locally, start vite fresh in CI). The API + MariaDB + Redis always stay compose-managed — `webServer` cannot own a multi-container stack.
+
+### Claude's Discretion
+- **Assertion approach:** lead with structural/role-based locator assertions + explicit tap-target measurements; add `toHaveScreenshot` only if non-flaky cross-environment snapshots are achievable. Schedule-X drift risk weighted heavily.
+- **Stack lifecycle (D-08–D-10):** planner may refine exact env-var name and webServer wiring.
+- Spec file location/structure, trace/artifact capture on failure, and npm-script + Makefile wiring — planner's discretion (follow existing conventions).
+
+### Deferred Ideas (OUT OF SCOPE)
+- Gitea CI pipeline itself (Phase 8 owns it).
+- Real production-service-worker behavior.
+- iOS-Safari standalone-PWA behavior (Home-Screen install, standalone OIDC redirect, iOS push).
+- Live event-create against Fastmail (dev-bypass user 1 has no CalDAV credential).
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| TEST-01 | The assistant can drive the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) for automated UI/layout verification. | `devices['iPhone 14']` + `devices['Pixel 7']` confirmed in Playwright `@playwright/test` 1.60.0; `projects:` config pattern documented via Context7. |
+| TEST-02 | Automated runs reach the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack (no manual login, no Authelia/OIDC mocking). Harness specs consumed by Phase 8 CI as UI-regression step. | `DEV_AUTH_BYPASS` already wired in API; global-setup pattern for readiness gate + DB seeding documented; `baseURL` env-var pattern confirmed. |
+
+
+---
+
+## Summary
+
+This phase adds `@playwright/test` as a `devDependency` in `apps/pwa` and creates a mobile-emulated E2E harness. The harness runs two device profiles (iPhone 14/WebKit and Pixel 7/Chromium), authenticates via the existing `DEV_AUTH_BYPASS=true` mechanism, seeds deterministic fixtures into the dev MariaDB in `globalSetup`, and asserts structural quality rules (tap targets, overflow, visibility, accessible names, empty/error states). No visual screenshot assertions are included — Schedule-X's date-driven dynamic layout makes cross-environment snapshots unworkable without a high false-positive rate.
+
+The primary research question — assertion strategy — is answered: **use structural assertions only** (role/name locators + `boundingBox()` measurements + `scrollWidth ≤ clientWidth` + `isVisible()` + `page.route()` for error-state simulation). This is the well-documented Playwright-idiomatic approach; `toHaveScreenshot` is explicitly excluded for this phase due to Schedule-X date-dependent rendering and font-pipeline variance across host↔CI WebKit.
+
+`@playwright/test` 1.60.0 is the current release. [VERIFIED: npm registry] The `devices` descriptors for `'iPhone 14'` (390×844 viewport, WebKit UA) and `'Pixel 7'` (412×915 viewport, Chrome Android UA) are confirmed in the Playwright source. [VERIFIED: playwright deviceDescriptorsSource.json via WebFetch] `mysql2` is the existing project DB driver; the same credentials pattern (`DB_HOST=127.0.0.1`, `DB_PASSWORD` from env) used by the API integration tests applies to the global-setup seed script.
+
+**Primary recommendation:** One `playwright.config.ts` in `apps/pwa/` with two projects (`iphone`/`pixel`), `globalSetup` for health-polling + DB seeding, `serviceWorkers: 'block'` on both contexts, env-driven `baseURL`, `webServer` for vite with `reuseExistingServer: !process.env.CI`, and spec files under `apps/pwa/e2e/` using `*.spec.ts` glob (isolated from Vitest's `*.test.ts` glob).
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Mobile viewport + UA emulation | Test Harness (`@playwright/test`) | — | Playwright `devices[...]` spread into project `use:` options; no app-layer change needed |
+| Auth bypass | API (existing `devBypass.ts`) | Test Harness (sets `DEV_AUTH_BYPASS=true`) | Bypass is already implemented; harness only ensures env var is set before API starts |
+| DB seeding | Test Harness (`globalSetup`) | Dev MariaDB (port 3306) | Direct mysql2 connection from global-setup; no API endpoint for seed data |
+| Stack readiness gate | Test Harness (`globalSetup`) | — | `GET /health` poll via `fetch` with retry loop before any spec runs |
+| Service worker suppression | Test Harness (context option) | — | `serviceWorkers: 'block'` in `playwright.config.ts` context options; stops Workbox intercept |
+| Structural assertions (tap target, overflow, visibility, a11y) | Test Harness (spec files) | — | `boundingBox()`, `page.evaluate(scrollWidth)`, `isVisible()`, role-based locators |
+| API error-state simulation | Test Harness (`page.route()`) | — | Fulfill `/api/events*` with status 500 for error-state tests; no backend change needed |
+| Vite dev server lifecycle | Test Harness (`webServer`) or Operator | — | `webServer` starts vite if not running; `reuseExistingServer: !process.env.CI` avoids double-start locally |
+| CI portability | Test Harness (env-driven config) | — | `PLAYWRIGHT_BASE_URL` + `DB_HOST` env vars; no hardcoded `localhost` in spec files |
+
+---
+
+## Standard Stack
+
+### Core (new additions for this phase)
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `@playwright/test` | 1.60.0 | Mobile-emulated E2E test runner + assertions | Official Playwright test runner; includes device descriptors, `projects:`, `globalSetup`, `page.route()`, `boundingBox()`, `toHaveScreenshot` (omitted this phase) — the only credible option for WebKit-on-Linux emulation [VERIFIED: npm registry] |
+
+### Supporting (already in project, used in harness)
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `mysql2` | 3.22.5 (already a project dep) | DB connection in global-setup seed script | Direct mysql2 `createConnection` (not Drizzle — global-setup runs outside the API; Drizzle schema not needed for raw INSERT/TRUNCATE) [VERIFIED: npm registry, `SUS` flag — see audit] |
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `mysql2` in global-setup | Drizzle ORM | Drizzle is fine but adds unnecessary indirection for 3 TRUNCATE + INSERT statements; raw mysql2 is simpler and already project-resident |
+| `serviceWorkers: 'block'` | Manual SW unregister in test | `block` is one line of config; unregister requires per-test async setup and is easy to forget |
+| `page.route()` for error states | Mocking API server | route interception is in-process and doesn't require a separate mock server; the canonical Playwright approach |
+
+**Installation:**
+```bash
+pnpm --filter @familysync/pwa add -D @playwright/test
+# Install browser engines (both projects: WebKit + Chromium)
+pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromium
+```
+
+**Version verification:**
+```bash
+npm view @playwright/test version # → 1.60.0 (verified 2026-06-10)
+npm view mysql2 version # → 3.22.5 (verified 2026-06-10)
+```
+
+---
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| `@playwright/test` | npm | Published 2026-05-11 (latest 1.60.0) | 38.6M/wk | github.com/microsoft/playwright | OK | Approved |
+| `mysql2` | npm | Published 2026-06-06 (latest 3.22.5) | 11.4M/wk | github.com/sidorares/node-mysql2 | SUS (too-new flag for latest patch) | Approved — already a project dependency; legitimate package, flag is recency of latest patch, not package itself |
+
+**Packages removed due to SLOP verdict:** none
+
+**Packages flagged as suspicious SUS:** `mysql2` — the SUS flag is triggered by the `too-new` heuristic on the latest patch (3.22.5, published 2026-06-06). This package is well-established (11.4M weekly downloads, Drizzle explicit dependency, already in the project), and the harness uses the same version already present in `apps/api`. No additional review needed; the SUS flag is a false positive caused by a very recent patch release. [ASSUMED: "false positive" characterisation — gsd-tools `SUS` verdict cannot be overridden by provenance alone; planner notes no `checkpoint:human-verify` is required since mysql2 is already installed in the project.]
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Operator / CI runner
+ |
+ | sets env: PLAYWRIGHT_BASE_URL, DB_HOST, DB_PASSWORD, DEV_AUTH_BYPASS=true
+ v
+[ playwright.config.ts ]
+ |
+ |-- globalSetup ──────────────────────────────────────────────────┐
+ | 1. poll GET {baseURL}/health until 200 (60s timeout) |
+ | 2. mysql2.createConnection(DB_HOST:3306, creds) |
+ | 3. TRUNCATE calendar_events, lists, list_items, ... |
+ | 4. INSERT deterministic fixtures onto calendar id=10 |
+ | 5. INSERT list (owner_id=1) + list_items (≥2 items) |
+ | 6. INSERT list_shares (list_id, user_id=1) |
+ | connection.end() |
+ | |
+ |-- webServer (vite, reuseExistingServer: !CI) ───────────────────┘
+ | starts vite :5173 if not already running
+ |
+ |-- project: iphone ──────────────────────────────────────────────┐
+ | engine: WebKit |
+ | use: devices['iPhone 14'] |
+ | contextOptions: { serviceWorkers: 'block' } |
+ | |
+ |-- project: pixel ───────────────────────────────────────────────┘
+ engine: Chromium
+ use: devices['Pixel 7']
+ contextOptions: { serviceWorkers: 'block' }
+
+ |
+ | both projects run against same spec files
+ v
+ [ apps/pwa/e2e/*.spec.ts ]
+ |
+ | page.goto(baseURL + '/calendar'), assertions
+ | page.goto(baseURL + '/lists'), assertions
+ | page.route('/api/events*', fulfill 500), goto /calendar, assertions
+ v
+ [ Vite dev server :5173 ] ← proxy /api,/health,/callback → :3000
+ |
+ v
+ [ API :3000 (DEV_AUTH_BYPASS=true, NODE_ENV=development) ]
+ |
+ v
+ [ Dev MariaDB :3306 ] ← seeded fixtures from globalSetup
+```
+
+### Recommended Project Structure
+
+```
+apps/pwa/
+├── e2e/ # Playwright E2E specs — *.spec.ts glob
+│ ├── calendar.spec.ts # calendar route: populated, empty, error state
+│ ├── lists.spec.ts # lists route: populated, empty state
+│ ├── layout.spec.ts # cross-route: tap targets, overflow, BottomTabBar
+│ └── global-setup.ts # health poll + DB seed (no Playwright deps)
+├── playwright.config.ts # project matrix, globalSetup, webServer, artifacts
+├── vitest.config.ts # unchanged — *.test.ts glob, jsdom env
+└── package.json # add @playwright/test devDependency + e2e script
+```
+
+**Key isolation rule:** `vitest.config.ts` has no explicit `include` pattern, so by default Vitest scans for `*.test.ts` / `*.test.tsx` files. Playwright's `testMatch` in `playwright.config.ts` targets `e2e/**/*.spec.ts`. These globs do not overlap — no runner collision. [CITED: TESTING.md — existing convention uses `*.test.ts` for Vitest]
+
+### Pattern 1: Two-Project Device Matrix with `serviceWorkers: 'block'`
+
+```typescript
+// apps/pwa/playwright.config.ts
+// Source: Context7 /microsoft/playwright.dev — emulation.mdx + test-global-setup-teardown.mdx
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ testMatch: '**/*.spec.ts',
+ fullyParallel: true,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? 'github' : 'list',
+ globalSetup: './e2e/global-setup.ts',
+
+ use: {
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
+ trace: 'on-first-retry',
+ video: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+
+ projects: [
+ {
+ name: 'iphone',
+ use: {
+ ...devices['iPhone 14'],
+ // serviceWorkers: 'block' prevents the injectManifest sw.js from
+ // intercepting any requests — satisfies D-02 / Pitfall 15
+ serviceWorkers: 'block',
+ },
+ },
+ {
+ name: 'pixel',
+ use: {
+ ...devices['Pixel 7'],
+ serviceWorkers: 'block',
+ },
+ },
+ ],
+
+ // D-10: manage Vite only; API+MariaDB+Redis are compose-managed
+ webServer: {
+ command: 'pnpm --filter @familysync/pwa dev',
+ url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+})
+```
+
+**Device descriptor confirmed properties:**
+- `devices['iPhone 14']`: viewport `390×844`, userAgent `Mobile Safari`, `hasTouch: true`, `isMobile: true`, `defaultBrowserType: 'webkit'` [VERIFIED: playwright deviceDescriptorsSource.json]
+- `devices['Pixel 7']`: viewport `412×915`, userAgent `Chrome/Android`, `hasTouch: true`, `isMobile: true`, `defaultBrowserType: 'chromium'` [VERIFIED: playwright deviceDescriptorsSource.json]
+
+### Pattern 2: `globalSetup` — Health Poll + DB Seed
+
+```typescript
+// apps/pwa/e2e/global-setup.ts
+// Source: Context7 /microsoft/playwright.dev — test-global-setup-teardown.mdx
+import mysql from 'mysql2/promise'
+
+export default async function globalSetup() {
+ // Step 1: Wait for /health — D-08 readiness gate
+ const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'
+ const deadline = Date.now() + 60_000
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(`${baseURL}/health`)
+ if (res.ok) break
+ } catch {
+ // ECONNREFUSED — not ready yet
+ }
+ await new Promise((r) => setTimeout(r, 1_000))
+ }
+ // will throw if never resolved — test run fails fast with a clear message
+
+ // Step 2: Seed — D-06 deterministic reset-per-run
+ const conn = await mysql.createConnection({
+ host: process.env.DB_HOST ?? '127.0.0.1',
+ port: Number(process.env.DB_PORT ?? 3306),
+ user: process.env.DB_USER ?? 'familysync',
+ password: process.env.DB_PASSWORD ?? '',
+ database: process.env.DB_NAME ?? 'familysync',
+ })
+
+ try {
+ // Disable FK checks for TRUNCATE ordering
+ await conn.execute('SET FOREIGN_KEY_CHECKS=0')
+ await conn.execute('TRUNCATE TABLE list_items')
+ await conn.execute('TRUNCATE TABLE list_shares')
+ await conn.execute('TRUNCATE TABLE lists')
+ await conn.execute('TRUNCATE TABLE calendar_events')
+ await conn.execute('SET FOREIGN_KEY_CHECKS=1')
+
+ // Seed: one calendar event on shared calendar id=10 (timed, not all-day)
+ // Minimal VCALENDAR string — enough for the API to expand and the UI to show it
+ const futureStart = new Date(Date.now() + 24 * 60 * 60 * 1000) // tomorrow
+ const futureStartUtc = futureStart.toISOString().replace('T', 'T').replace(/\.\d+Z$/, 'Z')
+ const uid = 'e2e-seed-event-001'
+ const rawVevent = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'BEGIN:VEVENT',
+ `UID:${uid}`,
+ `DTSTART:${futureStart.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`,
+ `SUMMARY:Seeded Test Event`,
+ 'END:VEVENT',
+ 'END:VCALENDAR',
+ ].join('\r\n')
+ await conn.execute(
+ `INSERT INTO calendar_events
+ (calendar_id, uid, etag, raw_vevent, title, dtstart_utc, all_day, has_rrule)
+ VALUES (10, ?, 'e2e-etag-001', ?, 'Seeded Test Event', ?, false, false)`,
+ [uid, rawVevent, futureStartUtc],
+ )
+
+ // Seed: one list with two items for user 1
+ const [listResult] = await conn.execute(
+ `INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`,
+ ) as any[]
+ const listId = listResult.insertId
+ await conn.execute(
+ `INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`,
+ [listId],
+ )
+ await conn.execute(
+ `INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`,
+ [listId, listId],
+ )
+ } finally {
+ await conn.end()
+ }
+}
+```
+
+**Notes for planner:**
+- The `calendar_events.dtstart_utc` type is `TIMESTAMP`, which MariaDB stores in UTC. Pass an ISO UTC string.
+- `calendar_id=10` is the confirmed shared calendar from project memory `dev-data-user1-no-calendars`. The seed assumes this row pre-exists (it does on the dev stack); the planner may add an `INSERT IGNORE INTO calendars ...` guard for CI resilience.
+- `list_shares` ensures user 1 can see the list in `/api/lists`.
+- `fractional-indexing` rank strings `'a0'`, `'a1'` are valid initial ranks per the project's sort pattern. [ASSUMED: exact rank string format — verify against a real row or the fractional-indexing library docs if different from `'a0'/'a1'`]
+
+### Pattern 3: Structural Assertions — Tap Targets (Rule 1)
+
+```typescript
+// Source: Context7 /microsoft/playwright.dev — api/class-locator.mdx + UI-SPEC.md Rule 1
+import { test, expect } from '@playwright/test'
+
+test('BottomTabBar tabs meet 44px touch target', async ({ page }) => {
+ await page.goto('/calendar')
+
+ const calTab = page.getByRole('link', { name: 'Calendar' })
+ const listsTab = page.getByRole('link', { name: 'Lists' })
+
+ for (const el of [calTab, listsTab]) {
+ const box = await el.boundingBox()
+ expect(box).not.toBeNull()
+ expect(box!.width).toBeGreaterThanOrEqual(44)
+ expect(box!.height).toBeGreaterThanOrEqual(44)
+ }
+})
+```
+
+**API confirmation:** `locator.boundingBox()` returns `{ x, y, width, height }` in CSS pixels (logical pixels at `scale: 'css'`). Returns `null` if element not visible. [CITED: context7 /microsoft/playwright.dev — api/class-locator.mdx]
+
+### Pattern 4: Structural Assertions — No Horizontal Overflow (Rule 2)
+
+```typescript
+// Source: UI-SPEC.md Rule 2 — confirmed as standard Playwright JS evaluation pattern
+const overflow = await page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+}))
+expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth)
+```
+
+This is `page.evaluate()` — standard Playwright API, no special library needed. [CITED: context7 /microsoft/playwright.dev]
+
+### Pattern 5: API Error-State Simulation via `page.route()`
+
+```typescript
+// Source: Context7 /microsoft/playwright.dev — network.mdx
+// Use BEFORE page.goto() — route must be registered before navigation
+await page.route('/api/events*', (route) =>
+ route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }),
+)
+await page.goto('/calendar')
+await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible()
+await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
+```
+
+**Important:** Call `await page.unroute('/api/events*')` or use `page.route` with `{ times: 1 }` to prevent the mock from leaking to subsequent tests. [CITED: context7 /microsoft/playwright.dev — network.mdx]
+
+### Pattern 6: Vite `webServer` with `reuseExistingServer` (D-10)
+
+```typescript
+// Source: Context7 /microsoft/playwright.dev — playwright.config.ts example
+webServer: {
+ command: 'pnpm --filter @familysync/pwa dev',
+ url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+},
+```
+
+- Locally: Playwright checks if :5173 is already answering; if yes, it reuses the operator's `pnpm dev` session without starting a new one.
+- In CI: `process.env.CI` is set by Gitea Actions → `reuseExistingServer: false` → Playwright starts its own vite process from scratch. [CITED: context7 /microsoft/playwright.dev — playwright.config.ts]
+
+**Interaction with DEV_AUTH_BYPASS:** The API process is not managed by `webServer`. `DEV_AUTH_BYPASS=true` must be set in the environment that starts the API (compose env or runner env). Locally, the operator sets it; in Phase 8 CI, the workflow YAML sets it before starting the compose stack. The harness itself does not control API env.
+
+### Anti-Patterns to Avoid
+
+- **`storageState` in playwright.config.ts `use.storageState`:** stores OIDC session cookies that expire. Using `DEV_AUTH_BYPASS` eliminates the need entirely. [CITED: PITFALLS.md §Pitfall 14]
+- **Omitting `serviceWorkers: 'block'`:** Workbox cache-first responses from a prior Playwright run will appear as SW-sourced in traces. The `block` option prevents registration entirely. [CITED: PITFALLS.md §Pitfall 15]
+- **Hardcoded `localhost:5173` in spec files:** breaks CI where baseURL may differ. Use `page.goto('/calendar')` with a configured `baseURL` — relative paths resolve against it. [CITED: context7 /microsoft/playwright.dev — test-parameterize.mdx]
+- **`webServer` managing API + compose services:** `webServer` can only manage one process. API needs `DEV_AUTH_BYPASS=true`, MariaDB, and Redis — use compose for those. [ASSUMED: webServer single-process limitation — consistent with docs pattern]
+- **Calling `pnpm playwright install` without `--with-deps` in CI:** WebKit on Linux requires system libraries. `playwright install --with-deps webkit chromium` installs both engines and their system deps. [CITED: context7 /microsoft/playwright.dev — browsers.mdx]
+- **`INSERT IGNORE` instead of `TRUNCATE + INSERT` for seed:** insert-if-absent leaves stale rows from a prior run. D-06 mandates truncate-first for determinism. [CITED: 07-CONTEXT.md D-06]
+- **Vitest picking up `*.spec.ts` files:** The existing `vitest.config.ts` has no explicit `include`, so Vitest uses its default `**/*.{test,spec}.{js,ts,tsx}` glob. This means `*.spec.ts` files in `e2e/` WOULD be picked up by Vitest unless excluded. The planner must add `exclude: ['e2e/**']` to `vitest.config.ts`. [VERIFIED: vitest.config.ts read — no explicit include; spec files would be caught by default glob]
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Mobile viewport + touch + UA string | Custom browser launch flags | `devices['iPhone 14']` spread | `devices` includes DPR, hasTouch, isMobile, UA — reproducing this by hand misses fields and drifts with Playwright updates |
+| Service worker suppression | Per-test SW unregister evaluate | `serviceWorkers: 'block'` context option | One config line; unregister requires async setup in every test and is easy to omit |
+| Stack readiness polling | `sleep 10 && run tests` | `globalSetup` health-poll loop | Sleep is non-deterministic; a poll with timeout gives fast-pass and hard-fail |
+| API error states | Separate mock API server | `page.route()` inline fulfill | route() is in-process, zero infrastructure, the Playwright-canonical approach |
+| DB seeding from the API | POST requests to API endpoints | Direct mysql2 INSERT in globalSetup | Dev-bypass user 1 cannot create calendar events via API (422, no CalDAV credential); direct DB insert bypasses that constraint and is faster |
+
+**Key insight:** Playwright's device descriptors, `serviceWorkers` context option, and `page.route()` network interception are designed precisely for this use case. The only custom code needed is the globalSetup health poll and DB seed script.
+
+---
+
+## Primary Research Question: Assertion Strategy
+
+**Recommendation: structural assertions only — no `toHaveScreenshot` for this phase.**
+
+### Why structural assertions are sufficient and correct
+
+The UI-SPEC.md defines five concrete quality rules, all of which map directly to Playwright structural APIs with zero rendering-pipeline variance:
+
+| Rule | Playwright API | Variance Risk |
+|------|---------------|--------------|
+| Touch target ≥ 44px | `locator.boundingBox()` → measure width/height | None — CSS pixel dimensions are layout-engine output, consistent across OS font settings |
+| No horizontal overflow | `page.evaluate(() => scrollWidth/clientWidth)` | None — DOM measurement, not pixel comparison |
+| Elements in viewport on load | `locator.isVisible()` + `boundingBox().y + height ≤ viewportHeight` | None — geometry check |
+| Accessible names present | `page.getByRole(role, { name })` — if locatable, name exists | None — ARIA tree query |
+| Empty/error states render | `getByText()`, `getByRole()` visibility | None — presence check |
+
+### Why `toHaveScreenshot` is excluded
+
+1. **Schedule-X date-dependent layout.** The calendar widget highlights today's date, places event chips by its internal layout engine, and renders the current week/month by default. The "current date" changes every day, so a snapshot taken on 2026-06-10 will fail on 2026-06-11 even with identical code. [CITED: 07-UI-SPEC.md §Rule 6]
+
+2. **WebKit font rendering on Linux vs macOS.** The iPhone project uses WebKit engine. Font hinting on Linux (CI runner likely Ubuntu) differs from macOS — sub-pixel differences accumulate across text-heavy layouts. Even with `maxDiffPixelRatio: 0.03`, Schedule-X's event chip labels cause breaches. [ASSUMED: Linux WebKit font difference vs macOS — this is well-documented in the Playwright community but not formally cited; LOW confidence]
+
+3. **CI-generated baseline workflow adds operational burden.** Using `--update-snapshots` in CI on first run, committing baselines, and keeping them per-engine (`snapshots/webkit/`, `snapshots/chromium/`) is achievable — but adds a mandatory workflow step that is not self-healing when the app's UI legitimately changes. For a two-person household app with a small team, this maintenance overhead outweighs the pixel-accuracy benefit.
+
+4. **The structural assertions catch actual defects.** Schedule-X has historically caused horizontal overflow on narrow viewports (memory entry `schedule-x-allday-event-styling`). The `scrollWidth ≤ clientWidth` assertion catches that. Tap targets below 44px are the other mobile-only class of defect — `boundingBox()` catches that. Screenshots would add noise without catching additional real bugs.
+
+**If snapshots are added in a later phase:** scope to static, non-dynamic regions only (e.g., BottomTabBar clipped to its bounding box, not the full viewport). Use CI-generated baselines committed by a dedicated "update-snapshots" workflow. Mask the calendar grid area with `mask: [page.locator('.sx__calendar-wrapper')]`.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Vitest Glob Collision with `*.spec.ts`
+
+**What goes wrong:** Vitest's default `testMatch` includes `**/*.spec.ts`. Adding `apps/pwa/e2e/*.spec.ts` files without an explicit `exclude` in `vitest.config.ts` causes Vitest to pick them up and fail (Playwright APIs like `devices` are not available in the Vitest jsdom environment).
+
+**Why it happens:** `vitest.config.ts` has no explicit `include`/`exclude` — relies on Vitest defaults. [VERIFIED: vitest.config.ts read]
+
+**How to avoid:** Add `exclude: ['e2e/**']` to the `test:` block in `apps/pwa/vitest.config.ts`. Alternatively, scope Vitest's `include` to `src/**/*.test.ts`. Either prevents collision.
+
+**Warning signs:** Vitest run fails with `ReferenceError: devices is not defined` or Playwright import errors.
+
+### Pitfall 2: `globalSetup` has no access to Playwright fixtures
+
+**What goes wrong:** `globalSetup` runs outside the Playwright worker context. It cannot use `page`, `browser`, or any Playwright fixture. Only plain Node.js (fetch, mysql2, fs) is available.
+
+**Why it happens:** `globalSetup` runs once before any worker is spawned. [CITED: context7 /microsoft/playwright.dev — test-global-setup-teardown.mdx]
+
+**How to avoid:** The health poll and DB seed use only `fetch` (global in Node 18+) and `mysql2` — both are plain Node.js. No Playwright imports in `global-setup.ts`.
+
+**Warning signs:** `ReferenceError: test is not defined` in global-setup.
+
+### Pitfall 3: Vite Proxy Not Active When `webServer` Starts Fresh Vite
+
+**What goes wrong:** In CI, `webServer` starts `pnpm dev` for the PWA. The API at `:3000` must already be running (compose-managed) before Playwright navigates to `/calendar` — the Vite proxy to `:3000` will 502 if the API is not up.
+
+**Why it happens:** `webServer` only gates on the Vite URL being reachable, not on the proxied API being up. The globalSetup `/health` poll gates on the health endpoint (which IS proxied through Vite to `:3000`), so it handles this correctly — but only if the health poll runs AFTER Vite is started by `webServer`.
+
+**How to avoid:** Playwright starts `webServer` before running `globalSetup`, so the ordering is: compose brings up API+DB+Redis → Playwright starts Vite (webServer) → globalSetup polls `/health` (proxied to API). In CI, the workflow must start compose before running `npx playwright test`. [ASSUMED: Playwright webServer starts before globalSetup — verify in docs; treat as LOW confidence]
+
+**Warning signs:** globalSetup health poll times out in CI even though the API is healthy, because Vite isn't started yet when the poll begins.
+
+### Pitfall 4: `calendar_id=10` Not Present in CI MariaDB
+
+**What goes wrong:** The seed script does `INSERT INTO calendar_events (calendar_id=10, ...)`. In the developer's local MariaDB, calendar row 10 exists (created by the broker poller after D-16). In a fresh CI MariaDB with only Drizzle migrations applied, there is no calendar row 10.
+
+**Why it happens:** The CI DB starts from migrations only — no production data, no broker-seeded calendar rows.
+
+**How to avoid:** The globalSetup should `INSERT IGNORE INTO calendars (id, user_id, url, display_name, is_shared) VALUES (10, 1, ...)` before inserting calendar_events. This ensures the FK constraint is satisfied in both fresh and populated environments. [CITED: 07-CONTEXT.md D-06 + schema.ts FK reference]
+
+**Warning signs:** globalSetup throws `ER_NO_REFERENCED_ROW_2: Cannot add or update a child row: a foreign key constraint fails` on the calendar_events INSERT.
+
+### Pitfall 5: `DEV_AUTH_BYPASS` Not Propagated to API Process
+
+**What goes wrong:** The harness assumes `DEV_AUTH_BYPASS=true` is active in the API process. If the API was started without it (or the env var was not exported), all `/api/*` calls return 401/302 and the PWA renders an auth redirect instead of the calendar.
+
+**Why it happens:** `DEV_AUTH_BYPASS` is checked at API startup and gated by `NODE_ENV !== 'production'`. The harness cannot set it — it must be set in the API's environment before the API process starts.
+
+**How to avoid:** Document in the harness README that the dev stack must be started with `DEV_AUTH_BYPASS=true`. In CI (Phase 8), the workflow YAML must set it in the environment before launching the compose stack. The globalSetup can assert `DEV_AUTH_BYPASS` is active by checking that `GET /health` returns `{ ok: true }` — if the API is running without bypass, `/api/me` will redirect, which isn't directly testable in globalSetup, but the first spec failing on unexpected auth redirect is a clear signal.
+
+**Warning signs:** All specs fail with unexpected redirect to Authelia login page.
+
+### Pitfall 6: WebKit Not Installed in CI Image
+
+**What goes wrong:** Running `playwright install` without `--with-deps` in CI installs the Playwright browser binaries but not the system-level libraries WebKit needs on Linux. WebKit then fails to launch with library errors.
+
+**Why it happens:** WebKit on Linux requires `libwebkit2gtk` or similar system deps that are not present in the base CI runner image.
+
+**How to avoid:** Use `playwright install --with-deps webkit chromium` in CI. This is the documented approach for CI environments. Expect this to add ~500MB to the CI step. [CITED: Playwright docs on browsers.mdx — `--with-deps` flag]
+
+**Warning signs:** CI step fails with `libnss3.so: cannot open shared object file` or similar.
+
+---
+
+## Code Examples
+
+### playwright.config.ts (complete)
+
+```typescript
+// apps/pwa/playwright.config.ts
+// Source: Context7 /microsoft/playwright.dev
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ testMatch: '**/*.spec.ts',
+ fullyParallel: true,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: process.env.CI ? 'github' : 'list',
+ globalSetup: './e2e/global-setup.ts',
+
+ use: {
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
+ trace: 'on-first-retry',
+ video: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+
+ projects: [
+ {
+ name: 'iphone',
+ use: {
+ ...devices['iPhone 14'],
+ serviceWorkers: 'block',
+ },
+ },
+ {
+ name: 'pixel',
+ use: {
+ ...devices['Pixel 7'],
+ serviceWorkers: 'block',
+ },
+ },
+ ],
+
+ webServer: {
+ command: 'pnpm --filter @familysync/pwa dev',
+ url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+})
+```
+
+### vitest.config.ts patch (add exclude)
+
+```typescript
+// apps/pwa/vitest.config.ts — add exclude to prevent Vitest from picking up e2e specs
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test-setup.ts'],
+ env: { TZ: 'UTC' },
+ exclude: ['e2e/**', 'node_modules/**'], // ← ADD THIS
+ },
+})
+```
+
+### package.json scripts additions
+
+```json
+{
+ "scripts": {
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "test:e2e:update-snapshots": "playwright test --update-snapshots"
+ }
+}
+```
+
+**Root workspace script (for pnpm filter):**
+```bash
+pnpm --filter @familysync/pwa test:e2e
+```
+
+### layout.spec.ts skeleton
+
+```typescript
+// apps/pwa/e2e/layout.spec.ts
+// Source: UI-SPEC.md Rules 1-4 + Context7 /microsoft/playwright.dev
+import { test, expect } from '@playwright/test'
+
+test.describe('BottomTabBar presence and tap targets', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/calendar')
+ })
+
+ test('BottomTabBar is present at mobile width', async ({ page }) => {
+ await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
+ })
+
+ test('Calendar tab meets 44px touch target', async ({ page }) => {
+ const tab = page.getByRole('link', { name: 'Calendar' })
+ const box = await tab.boundingBox()
+ expect(box).not.toBeNull()
+ expect(box!.width).toBeGreaterThanOrEqual(44)
+ expect(box!.height).toBeGreaterThanOrEqual(44)
+ })
+
+ test('no horizontal overflow on /calendar', async ({ page }) => {
+ const overflow = await page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ }))
+ expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth)
+ })
+})
+
+test.describe('Error state — /calendar', () => {
+ test('shows error heading and Retry button when API returns 500', async ({ page }) => {
+ await page.route('/api/events*', (route) =>
+ route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }),
+ )
+ await page.goto('/calendar')
+ await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
+ // error state must also pass overflow rule
+ const overflow = await page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ }))
+ expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth)
+ })
+})
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|-----------------|--------------|--------|
+| `playwright-cli` global tool (Chromium desktop only) | `@playwright/test` with `devices[...]` projects (WebKit + Chromium, mobile viewport) | This phase | Playwright-cli remains for interactive assistant smoke tests; `@playwright/test` is for automated regression |
+| No E2E tests — `playwright-cli` used ad-hoc | Structured `e2e/` spec files with globalSetup + device matrix | This phase | Mobile layout defects caught automatically instead of by operator on real devices |
+| `toHaveScreenshot` visual regression | Structural assertions (boundingBox, overflow eval, role/name locators) | Deliberate decision — UI-SPEC §Rule 6 | Lower maintenance, zero rendering-pipeline variance, sufficient quality coverage for this app |
+
+**Deprecated/outdated patterns for this codebase:**
+- `storageState.json` for Playwright auth: never appropriate here; `DEV_AUTH_BYPASS` is the correct pattern. [CITED: PITFALLS.md §Pitfall 14]
+- `serviceWorkers: 'allow'` (default): would allow Workbox cache-first to intercept API calls. [CITED: PITFALLS.md §Pitfall 15]
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `fractional-indexing` rank strings `'a0'`, `'a1'` are valid initial ranks for seed items | Pattern 2 (global-setup) | Seed succeeds but list items sort incorrectly; items may not appear in expected order in UI |
+| A2 | Playwright `webServer` starts before `globalSetup` is called | Pitfall 3 | globalSetup health poll would time out if Vite isn't started yet; ordering must be confirmed against docs |
+| A3 | `mysql2` `SUS` verdict is a false positive due to recent patch release | Package Audit | Not a concern — package is already in the project; would only matter if upgrading to the latest patch caused issues |
+| A4 | Linux WebKit font rendering differs from macOS enough to cause `toHaveScreenshot` failures | Primary Research Q | If wrong, screenshots could be added with `maxDiffPixelRatio: 0.03`; structural assertions remain the lower-risk choice |
+
+---
+
+## Open Questions
+
+1. **`calendar_id=10` in CI DB — confirmed guard needed**
+ - What we know: dev DB has calendar row 10 from production poller. CI DB starts fresh from migrations.
+ - What's unclear: does the CI compose stack do any data seeding beyond migrations?
+ - Recommendation: globalSetup does `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared) VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)` before inserting calendar_events. Safe even on local dev (IGNORE avoids duplicate key error).
+
+2. **`webServer` ordering relative to `globalSetup`**
+ - What we know: Context7 docs show `webServer` and `globalSetup` as separate config options but don't document relative ordering explicitly.
+ - What's unclear: does Playwright guarantee `webServer` starts before `globalSetup` runs?
+ - Recommendation: if unsure, move the Vite readiness check INTO globalSetup (poll `:5173` before polling `/health`). This is belt-and-suspenders but eliminates the ordering ambiguity.
+
+3. **`list_shares` row required vs. `isShared=true` flag alone**
+ - What we know: `lists.is_shared=true` is the flag; `list_shares` is the join table. The API `/api/lists` route may return lists via `listShares` join or via `is_shared` flag — need to check route handler.
+ - What's unclear: does user 1 see a list they own (ownerId=1) without a listShares row, or only via listShares?
+ - Recommendation: seed both `lists.owner_id=1` and a `list_shares` row for safety; the seed is idempotent either way.
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js 22 LTS | global-setup (fetch native, mysql2) | ✓ (assumed) | 22.x | — |
+| Dev MariaDB :3306 (port-bound) | global-setup DB seed | ✓ when dev stack is up via `docker-compose.dev.yml` | MariaDB 11 | Seed step skipped gracefully — tests run with empty DB (empty-state assertions still valid) |
+| Vite dev server :5173 | All spec files | ✓ via `webServer` or operator's `pnpm dev` | Vite 8.0.16 | — |
+| API :3000 with DEV_AUTH_BYPASS=true | All spec files (via Vite proxy) | ✓ when dev stack is up | Node 22 + Hono | — |
+| WebKit browser binary | iPhone project | ✗ (not yet installed) | — | Must run `playwright install --with-deps webkit` |
+| Chromium browser binary | Pixel project | ✓ (used by playwright-cli skill) | Chromium (via playwright-cli) | May need re-install via `@playwright/test`'s own browser store |
+
+**Missing dependencies with no fallback:**
+- WebKit browser binary — required for the `iphone` project. Must be installed via `playwright install --with-deps webkit` as part of Phase 7 Wave 0.
+
+**Missing dependencies with fallback:**
+- Dev MariaDB port binding — if compose isn't up, the seed is skipped; specs run with empty DB, exercising empty-state assertions only (partial coverage, but not a hard failure).
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | `@playwright/test` 1.60.0 |
+| Config file | `apps/pwa/playwright.config.ts` (Wave 0 — new file) |
+| Quick run command (one profile) | `pnpm --filter @familysync/pwa exec playwright test --project=pixel` |
+| Full suite command | `pnpm --filter @familysync/pwa exec playwright test` |
+| Headed (local debug) | `pnpm --filter @familysync/pwa exec playwright test --headed` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|------------------|-------------|
+| TEST-01 | PWA loads in mobile-emulated viewport (390px / 412px), touch-capable, mobile UA | E2E (Playwright) | `pnpm --filter @familysync/pwa exec playwright test` | ❌ Wave 0 — create `e2e/layout.spec.ts` |
+| TEST-01 | Tap targets ≥ 44px on BottomTabBar, FAB, Retry, settings button | E2E (Playwright) | same | ❌ Wave 0 — `e2e/layout.spec.ts` |
+| TEST-01 | No horizontal overflow on `/calendar`, `/lists` | E2E (Playwright) | same | ❌ Wave 0 — `e2e/layout.spec.ts` |
+| TEST-02 | Reaches authenticated PWA via `DEV_AUTH_BYPASS` (no manual login) | E2E (Playwright) | same | ❌ Wave 0 — `e2e/global-setup.ts` enforces auth precondition |
+| TEST-02 | Harness runs headlessly, CI-portable (env-driven baseURL, readiness gate) | E2E (Playwright) | `CI=true pnpm --filter @familysync/pwa exec playwright test` | ❌ Wave 0 — `playwright.config.ts` |
+
+### Harness Self-Validation (the harness must prove it works)
+
+This phase's deliverable IS the test infrastructure. The harness is validated when it detects real defects. Recommended self-validation approach:
+
+1. **Broken layout fixture test:** temporarily reduce BottomTabBar `minHeight` to `20px` in a test — the tap-target assertion MUST fail. Restore and verify it passes. This proves `boundingBox()` is measuring the rendered element, not the CSS declaration.
+
+2. **Overflow injection test:** add `body { overflow-x: auto; width: 2000px; }` via `page.addStyleTag` before the overflow assertion — it MUST fail. Remove and verify it passes.
+
+3. **SW-block verification:** after a run, `trace: 'on-first-retry'` generates trace artifacts. Review one trace with the Playwright trace viewer and confirm zero responses have `(ServiceWorker)` as source.
+
+4. **Auth bypass verification:** without `DEV_AUTH_BYPASS=true`, the API redirects to Authelia. Run with bypass disabled — specs MUST fail on expected content not found. With bypass enabled, specs pass. (Manual verification step.)
+
+### Sampling Rate
+
+- **Per task commit:** `pnpm --filter @familysync/pwa exec playwright test --project=pixel` (Chromium only, faster)
+- **Per wave merge:** `pnpm --filter @familysync/pwa exec playwright test` (both profiles)
+- **Phase gate:** both profiles green on the full spec suite before marking Phase 7 complete
+
+### Wave 0 Gaps
+
+- [ ] `apps/pwa/playwright.config.ts` — project matrix, globalSetup, webServer, artifact config
+- [ ] `apps/pwa/e2e/global-setup.ts` — health poll + DB seed (calendar id 10 guard + list + items)
+- [ ] `apps/pwa/e2e/layout.spec.ts` — tap targets, overflow, BottomTabBar visibility (Rules 1, 2, 3)
+- [ ] `apps/pwa/e2e/calendar.spec.ts` — populated state, empty state, error state (Rules 4, 5 for calendar)
+- [ ] `apps/pwa/e2e/lists.spec.ts` — populated state, empty state (Rules 4, 5 for lists)
+- [ ] `apps/pwa/vitest.config.ts` — add `exclude: ['e2e/**']` to prevent glob collision
+- [ ] `apps/pwa/package.json` — add `@playwright/test` devDependency + `test:e2e` script
+- [ ] Browser install: `pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromium`
+
+---
+
+## Security Domain
+
+> `security_enforcement` not set to false — section required.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | Yes (test auth path) | `DEV_AUTH_BYPASS=true` — never real credentials in test env; bypass is dev-only (guarded by `NODE_ENV !== 'production'`) |
+| V3 Session Management | No | DEV_AUTH_BYPASS bypasses session cookies entirely |
+| V4 Access Control | No | Harness tests as user 1; no privilege escalation in scope |
+| V5 Input Validation | No | Harness is read-only; no form submission in scope |
+| V6 Cryptography | No | No crypto operations in test harness |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| `DEV_AUTH_BYPASS=true` active in production | Elevation of Privilege | API guards on `NODE_ENV !== 'production'` — production compose MUST NOT set this variable. The harness README must document this. |
+| `storage-state.json` with real OIDC session committed to repo | Information Disclosure | Not applicable — `storageState` is never used in this harness (D-01). |
+| DB seed credentials in test script | Information Disclosure | Use env vars for DB credentials in globalSetup (`DB_HOST`, `DB_PASSWORD`); no hardcoded credentials. |
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+| Directive | Impact on Phase 7 |
+|-----------|-------------------|
+| MariaDB only — no PostgreSQL | seed script uses `mysql2`; no pg driver |
+| pnpm workspace | all installs via `pnpm --filter @familysync/pwa add`; scripts via `pnpm --filter @familysync/pwa exec playwright` |
+| No Makefile (root Makefile does not exist) | scripts exposed via `package.json` `scripts` in `apps/pwa` and root workspace; no Makefile to update |
+| playwright-cli is global Chromium only | `@playwright/test` brings its own browser store; no conflict with playwright-cli; the two tools coexist |
+| playwright-cli skill exception for iOS-Safari-standalone | real device checks (Home Screen install, iOS push) remain human gates — NOT in scope for this harness |
+| Vitest for unit tests | `*.spec.ts` glob collision must be resolved via `vitest.config.ts` exclude |
+| `tsc --noEmit` gate (both apps) | `playwright.config.ts` and `e2e/*.ts` files must pass typecheck; add to root `typecheck` script or ensure `apps/pwa/tsconfig.json` includes `e2e/` |
+
+---
+
+## Sources
+
+### Primary (MEDIUM confidence — Context7/High reputation source)
+
+- `/microsoft/playwright.dev` via Context7 — device emulation config, projects matrix, globalSetup pattern, `page.route()`, `trace: 'on-first-retry'`, `webServer` + `reuseExistingServer`, `baseURL` env config, `toHaveScreenshot` options
+
+### Verified (via direct tool calls)
+
+- npm registry `@playwright/test` — version 1.60.0 confirmed, 38.6M weekly downloads, Microsoft GitHub source [VERIFIED: npm registry]
+- npm registry `mysql2` — version 3.22.5 confirmed, 11.4M weekly downloads, `SUS` (too-new flag on latest patch) [VERIFIED: npm registry]
+- `playwright/deviceDescriptorsSource.json` via WebFetch — `devices['iPhone 14']` and `devices['Pixel 7']` confirmed present [VERIFIED: playwright deviceDescriptorsSource.json]
+- `apps/pwa/vitest.config.ts` — no explicit `include`; default glob catches `*.spec.ts`; `exclude` needed [VERIFIED: file read]
+- `apps/pwa/package.json` — no `@playwright/test` present; no `e2e` script [VERIFIED: file read]
+- `apps/api/src/db/schema.ts` — `calendars`, `calendar_events`, `lists`, `list_items`, `list_shares` table structure confirmed [VERIFIED: file read]
+- `apps/api/src/auth/devBypass.ts` — DEV_USER id=1, `NODE_ENV !== 'production'` guard confirmed [VERIFIED: file read]
+- `apps/api/src/db/client.ts` — DB connection reads `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` from env [VERIFIED: file read]
+
+### Cited (project documentation)
+
+- `07-CONTEXT.md` — locked decisions D-01 through D-10
+- `07-UI-SPEC.md` — assertion contract Rules 1–8, device matrix, locator anchors
+- `PITFALLS.md §Pitfall 14, §Pitfall 15` — storage-state stale, SW intercept
+- `TESTING.md` — existing Vitest setup, `*.test.ts` convention, E2E gap
+- `memory/dev-stack-bringup.md` — dev stack DB_HOST override, DEV_AUTH_BYPASS pattern
+- `memory/api-integration-test-db.md` — DB_HOST=127.0.0.1, mysql2 connection pattern
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — `@playwright/test` 1.60.0 verified via npm; device descriptors verified via source file; mysql2 confirmed existing dep
+- Architecture: HIGH — patterns derived from existing project conventions (vitest config, DB client env vars, devBypass) + Context7 Playwright docs
+- Pitfalls: HIGH — Pitfalls 1/2/5/6 derived from reading actual project files; Pitfall 3/4 from reasoning about CI ordering
+
+**Research date:** 2026-06-10
+**Valid until:** 2026-09-10 (Playwright releases frequently but the device emulation and globalSetup APIs are stable)