Files
familysync/.planning/milestones/v1.1-phases/07-mobile-test-harness/07-UI-SPEC.md
T
2026-06-18 22:21:38 -04:00

21 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created, framing
phase slug status shadcn_initialized preset created framing
7 mobile-test-harness draft false none 2026-06-11 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.tsxPhoneNav
Settings button aria-label contains open settings AppNav.tsxPhoneNav

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:

// 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:

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:

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 15 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:

// 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