From df1b4628d8d3bfd6deac4917260db986c0559eb5 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 18:37:55 -0700 Subject: [PATCH] test(mobile): automated mobile-UI audit system (layout / contrast / dialogs) in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build repeatable detectors for the bug classes we kept finding by hand, and run them on every screen in CI so they're caught automatically. New auditors — tests/helpers/mobileAudit.ts: - auditLayout: horizontal page overflow, swipe-sideways scroll containers (with a greppable `data-audit-scroll-ok` opt-out for intentional cases), squeezed labels. - auditContrast: WCAG contrast of every visible text vs its effective background; catches invisible / black-on-dark text (the dark:-variant-not-applying class). Skips gradient (bg-clip-text) headings. - auditDialog: a modal fits the viewport AND sits on top (not clipped by/under the bottom nav) — catches the stacking-context trap. New suites (@audit, run via `npm run test:mobile` + a new CI step): - mobile-audit.spec.ts: sweeps every screen at 390px — 7 workspace views, 6 pages, sign-in (14 tests). - mobile-dialogs.spec.ts: the edit modal (tap a card) + create modal (FAB). Real issues the system found & fixed to reach a green baseline: - Backend status colors used light-mode -600 shades on the dark page (e.g. red-600 "down", ratio 2.13) → -400 shades; the yellow "Check Data" button was white-on- yellow (2.94) → dark text. - CreateWorkItemModal wasn't portaled → painted under the nav (same trap as the edit modal); now createPortal(document.body). - Admin user table is a dense data grid that scrolls horizontally by design → marked data-audit-scroll-ok. 26/26 mobile+audit green; smoke 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 + package.json | 1 + .../src/components/AdminUserManagement.tsx | 4 +- .../src/components/CreateWorkItemModal.tsx | 5 +- packages/web/src/pages/Backend.tsx | 10 +- tests/e2e/mobile-audit.spec.ts | 71 ++++++++ tests/e2e/mobile-dialogs.spec.ts | 42 +++++ tests/helpers/mobileAudit.ts | 171 ++++++++++++++++++ 8 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/mobile-audit.spec.ts create mode 100644 tests/e2e/mobile-dialogs.spec.ts create mode 100644 tests/helpers/mobileAudit.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f50d2e25..5b4a4b7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,11 @@ jobs: env: TEST_URL: http://localhost:3127 CI: true + - name: Mobile UI audit (layout / contrast / dialogs across every screen) + run: npm run test:mobile + env: + TEST_URL: http://localhost:3127 + CI: true - name: Living-graph effects render (LIVE-*) run: npx playwright test tests/e2e/living-graph.spec.ts --project="GraphDone-Core/dev-neo4j/chromium" --reporter=line env: diff --git a/package.json b/package.json index f363cb8b..08d6e606 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "docker:dev": "docker compose -f deployment/docker-compose.dev.yml up", "docker:prod": "docker compose -f deployment/docker-compose.yml up", "test:smoke": "playwright test tests/e2e/user-smoke.spec.ts --reporter=line", + "test:mobile": "playwright test --grep \"@mobile|@audit\" --project=\"GraphDone-Core/dev-neo4j/chromium\" --reporter=line", "report:showcase": "playwright test --project=showcase && node tests/generate-showcase-report.mjs", "test:perf": "playwright test --project=perf --reporter=line", "test:perf:scale": "playwright test --project=perf-scale --reporter=line && node tests/generate-perf-report.mjs", diff --git a/packages/web/src/components/AdminUserManagement.tsx b/packages/web/src/components/AdminUserManagement.tsx index 8c50b335..cb5e93ef 100644 --- a/packages/web/src/components/AdminUserManagement.tsx +++ b/packages/web/src/components/AdminUserManagement.tsx @@ -351,7 +351,9 @@ export function AdminUserManagement() { -
+ {/* Admin user table: a dense data grid that scrolls horizontally on a + phone by design (power-user surface), so it's exempt from the audit. */} +
diff --git a/packages/web/src/components/CreateWorkItemModal.tsx b/packages/web/src/components/CreateWorkItemModal.tsx index e2876fa6..ab742ab0 100644 --- a/packages/web/src/components/CreateWorkItemModal.tsx +++ b/packages/web/src/components/CreateWorkItemModal.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { createPortal } from 'react-dom'; import { useDialog } from '../hooks/useDialogManager'; import { useMutation, useQuery } from '@apollo/client'; import { X, Link, ChevronDown, Plus } from 'lucide-react'; @@ -336,7 +337,7 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio if (!isOpen) return null; - return ( + return createPortal((
- ); + ), document.body); } \ No newline at end of file diff --git a/packages/web/src/pages/Backend.tsx b/packages/web/src/pages/Backend.tsx index e1d02eda..6626c54d 100644 --- a/packages/web/src/pages/Backend.tsx +++ b/packages/web/src/pages/Backend.tsx @@ -422,10 +422,10 @@ export function Backend() { const getStatusColor = (status: string) => { switch (status) { - case 'healthy': return 'text-green-600'; - case 'degraded': return 'text-yellow-600'; - case 'down': return 'text-red-600'; - default: return 'text-gray-600'; + case 'healthy': return 'text-green-400'; + case 'degraded': return 'text-yellow-400'; + case 'down': return 'text-red-400'; + default: return 'text-gray-400'; } }; @@ -966,7 +966,7 @@ export function Backend() { setDebugInfo(debug); } }} - className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center space-x-2" + className="bg-yellow-500 hover:bg-yellow-600 text-gray-900 px-4 py-2 rounded-lg transition-colors flex items-center justify-center space-x-2" > Check Data diff --git a/tests/e2e/mobile-audit.spec.ts b/tests/e2e/mobile-audit.spec.ts new file mode 100644 index 00000000..8c0135c8 --- /dev/null +++ b/tests/e2e/mobile-audit.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL } from '../helpers/auth'; +import { auditLayout, auditContrast } from '../helpers/mobileAudit'; + +/** + * Automated mobile sweep — runs the layout + contrast auditors against EVERY + * screen at phone width so regressions (sideways scroll, squeezed labels, + * black-on-dark text) are caught here instead of by hand. Tagged @audit. + */ +test.use({ viewport: { width: 390, height: 844 } }); + +const VIEWS = ['cards', 'dashboard', 'table', 'kanban', 'gantt', 'calendar', 'activity']; +const PAGES = [ + { path: '/ontology', name: 'Ontology' }, + { path: '/settings', name: 'Settings' }, + { path: '/admin', name: 'Admin' }, + { path: '/backend', name: 'System' }, + { path: '/agents', name: 'Agents' }, + { path: '/analytics', name: 'Analytics' }, +]; + +async function assertClean(page: import('@playwright/test').Page, scope: string, label: string, errs: string[]) { + const layout = await auditLayout(page, scope); + const contrast = await auditContrast(page, scope); + expect(layout.pageOverflowPx, `${label}: page overflows sideways`).toBeLessThanOrEqual(1); + expect(layout.sideScroll, `${label}: content needs sideways scrolling`).toEqual([]); + expect(layout.squeezed, `${label}: labels squeezed unreadable`).toEqual([]); + expect(contrast, `${label}: invisible / low-contrast text`).toEqual([]); + expect(errs, `${label}: uncaught JS errors`).toEqual([]); +} + +test.describe('mobile audit: every screen is usable on a phone @audit', () => { + for (const mode of VIEWS) { + test(`workspace ${mode} view`, async ({ page }) => { + const errs: string[] = []; + page.on('pageerror', (e) => errs.push(e.message)); + await login(page, TEST_USERS.ADMIN); + await page.evaluate((m) => localStorage.setItem('graphdone:viewMode', m), mode); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + // The graph view is a canvas; only its chrome (page overflow + errors) is + // auditable. Every other view is real DOM and gets the full sweep. + if (mode === 'graph') { + const layout = await auditLayout(page, 'body'); + expect(layout.pageOverflowPx, 'graph: page overflows sideways').toBeLessThanOrEqual(1); + expect(errs, 'graph: uncaught JS errors').toEqual([]); + return; + } + await assertClean(page, '[data-testid="view-content"]', `view:${mode}`, errs); + }); + } + + for (const pg of PAGES) { + test(`page ${pg.name}`, async ({ page }) => { + const errs: string[] = []; + page.on('pageerror', (e) => errs.push(e.message)); + await login(page, TEST_USERS.ADMIN); + await page.goto(`${getBaseURL()}${pg.path}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2500); + await assertClean(page, 'main', `page:${pg.name}`, errs); + }); + } + + test('signin (logged out)', async ({ page }) => { + const errs: string[] = []; + page.on('pageerror', (e) => errs.push(e.message)); + await page.goto(`${getBaseURL()}/login`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + await assertClean(page, 'body', 'signin', errs); + }); +}); diff --git a/tests/e2e/mobile-dialogs.spec.ts b/tests/e2e/mobile-dialogs.spec.ts new file mode 100644 index 00000000..c557b741 --- /dev/null +++ b/tests/e2e/mobile-dialogs.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL } from '../helpers/auth'; +import { auditDialog } from '../helpers/mobileAudit'; + +/** + * Automated dialog sweep — opens the modals a phone user actually hits and + * verifies each fits the viewport AND is painted on top (not clipped by / under + * the bottom nav). Catches the stacking-context bug class (a modal trapped in a + * z-20 ancestor losing to the z-30 nav). Tagged @audit. + */ +test.use({ viewport: { width: 390, height: 844 } }); + +async function openListView(page: import('@playwright/test').Page) { + await login(page, TEST_USERS.ADMIN); + await page.evaluate(() => localStorage.setItem('graphdone:viewMode', 'cards')); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); +} + +test.describe('mobile dialogs fit the screen and sit above the nav @audit', () => { + test('work-item edit modal (tap a list card)', async ({ page }) => { + await openListView(page); + await page.locator('[data-testid="view-content"] .grid > div').first().click(); + await page.waitForTimeout(1200); + const d = await auditDialog(page, 'Work Item Details'); + expect(d.found, 'edit modal opened').toBe(true); + expect(d.fitsWidth, 'edit modal fits viewport width').toBe(true); + expect(d.fitsHeight, 'edit modal fits viewport height').toBe(true); + expect(d.coveredBy, 'edit modal is on top (nothing painted over it)').toBeNull(); + }); + + test('create-work-item modal (the + FAB)', async ({ page }) => { + await openListView(page); + await page.locator('[aria-label="New work item"]').click(); + await page.waitForTimeout(1200); + const d = await auditDialog(page, 'Create New Work Item'); + expect(d.found, 'create modal opened').toBe(true); + expect(d.fitsWidth, 'create modal fits viewport width').toBe(true); + expect(d.fitsHeight, 'create modal fits viewport height').toBe(true); + expect(d.coveredBy, 'create modal is on top (nothing painted over it)').toBeNull(); + }); +}); diff --git a/tests/helpers/mobileAudit.ts b/tests/helpers/mobileAudit.ts new file mode 100644 index 00000000..e9f93986 --- /dev/null +++ b/tests/helpers/mobileAudit.ts @@ -0,0 +1,171 @@ +import { Page } from '@playwright/test'; + +/** + * Reusable mobile-UI auditors. These run in the browser and return structured + * findings so specs can assert "no problems" across every route/dialog instead + * of someone eyeballing screenshots. They encode the bug classes we keep hitting: + * - layout: horizontal page overflow, swipe-sideways scroll containers, + * labels squeezed to an unreadable width. + * - contrast: text that is (nearly) invisible against its background — e.g. the + * `dark:` variants not applying on a light-OS device → black-on-dark. + * - dialogs: modals that don't fit the viewport or are painted under the nav. + */ + +export interface LayoutFindings { + pageOverflowPx: number; + sideScroll: { tag: string; cls: string; scrollW: number; clientW: number }[]; + squeezed: { tag: string; cls: string; clientW: number; txt: string }[]; +} + +export interface ContrastFinding { + txt: string; + color: string; + ratio: number; + tag: string; + cls: string; +} + +export async function auditLayout(page: Page, rootSelector = 'body'): Promise { + return page.evaluate((sel) => { + const root = document.querySelector(sel) || document.body; + const vw = window.innerWidth; + const sideScroll: LayoutFindings['sideScroll'] = []; + const squeezed: LayoutFindings['squeezed'] = []; + root.querySelectorAll('*').forEach((d) => { + const e = d as HTMLElement; + const cs = getComputedStyle(e); + if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return; + const r = e.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return; + const cw = e.clientWidth; + const sw = e.scrollWidth; + // A horizontally-scrollable box with clipped content forces sideways swiping — + // unless it's explicitly opted out (e.g. a wide data table on an admin page), + // marked with data-audit-scroll-ok so the exception is greppable. + if ((cs.overflowX === 'auto' || cs.overflowX === 'scroll') && cw > 0 && sw > cw + 16 && !e.closest('[data-audit-scroll-ok]')) { + sideScroll.push({ tag: e.tagName, cls: (e.className?.toString?.() || '').slice(0, 48), scrollW: sw, clientW: cw }); + } + // A multi-character leaf label collapsed to ~nothing is unreadable. + const txt = (e.textContent || '').trim(); + if (e.children.length === 0 && txt.length > 2 && cw > 0 && cw < 12) { + squeezed.push({ tag: e.tagName, cls: (e.className?.toString?.() || '').slice(0, 48), clientW: cw, txt: txt.slice(0, 16) }); + } + }); + return { + pageOverflowPx: document.documentElement.scrollWidth - vw, + sideScroll: sideScroll.slice(0, 8), + squeezed: squeezed.slice(0, 8), + }; + }, rootSelector); +} + +/** + * Flags text whose contrast against its (effective) background is below `minRatio`. + * Default 3.0 is a "severe" gate — it reliably catches invisible/near-invisible + * text (black-on-dark ≈ 1.2) without false-flagging legitimate muted grays (≈ 4-7). + */ +export async function auditContrast(page: Page, rootSelector = 'body', minRatio = 3.0): Promise { + return page.evaluate(({ sel, minRatio }) => { + const root = document.querySelector(sel) || document.body; + const parse = (c: string) => { + const m = c.match(/rgba?\(([^)]+)\)/); + if (!m) return null; + const p = m[1].split(',').map((s) => parseFloat(s)); + return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : p[3] }; + }; + const lum = ({ r, g, b }: { r: number; g: number; b: number }) => { + const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + const ratio = (a: any, b: any) => { const L1 = lum(a), L2 = lum(b); return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); }; + const blend = (fg: any, bg: any) => ({ r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 }); + const effBg = (el: Element) => { + let n: Element | null = el; + while (n) { + const bg = parse(getComputedStyle(n).backgroundColor); + if (bg && bg.a > 0.5) return bg; + n = n.parentElement; + } + return { r: 17, g: 24, b: 39, a: 1 }; // app surface fallback (gray-900) + }; + const findings: any[] = []; + const seen = new Set(); + root.querySelectorAll('*').forEach((el) => { + const cs = getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0) return; + if (el.closest('svg')) return; // graph text handled elsewhere + // Gradient text (bg-clip-text) has a transparent `color`; the gradient is the + // visible fill, so contrast can't be measured from `color` — skip it. + const clip = (cs as any).backgroundClip || (cs as any).webkitBackgroundClip; + if (clip === 'text') return; + const hasOwnText = [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent || '').trim().length > 1); + if (!hasOwnText) return; + const r = el.getBoundingClientRect(); + if (r.width < 4 || r.height < 4) return; + let fg = parse(cs.color); + if (!fg) return; + const bg = effBg(el); + if (fg.a < 1) fg = blend(fg, bg); + const cr = ratio(fg, bg); + if (cr < minRatio) { + const txt = (el.textContent || '').trim().slice(0, 24); + const key = txt + '|' + cs.color; + if (seen.has(key)) return; + seen.add(key); + findings.push({ txt, color: cs.color, ratio: Math.round(cr * 100) / 100, tag: el.tagName, cls: (el.className?.toString?.() || '').slice(0, 40) }); + } + }); + return findings.slice(0, 12); + }, { sel: rootSelector, minRatio }); +} + +/** + * For an open dialog: is its panel within the viewport AND actually on top + * (not painted under the bottom nav / a sibling stacking context)? + */ +export async function auditDialog(page: Page, panelTextMatch: string) { + return page.evaluate((match) => { + const vw = window.innerWidth, vh = window.innerHeight; + // The overlay = a fixed, ~full-viewport container holding the dialog text. + const overlays = [...document.querySelectorAll('div')].filter((d) => { + const cs = getComputedStyle(d); + const r = d.getBoundingClientRect(); + return cs.position === 'fixed' && r.width >= vw * 0.9 && r.height >= vh * 0.9 && (d.textContent || '').includes(match); + }); + if (!overlays.length) return { found: false } as any; + const overlay = overlays[overlays.length - 1]; // innermost (the dialog root) + // The card = the largest descendant with a visible background that holds the + // text (the panel itself), so we can check it fits the viewport. + let card: HTMLElement | null = null; + let best = 0; + overlay.querySelectorAll('*').forEach((d) => { + const e = d as HTMLElement; + if (!(e.textContent || '').includes(match)) return; + const cs = getComputedStyle(e); + const r = e.getBoundingClientRect(); + const hasBg = !/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) || /gradient|url/.test(cs.backgroundImage); + const area = r.width * r.height; + if (hasBg && area > best && area <= vw * vh * 1.02) { best = area; card = e; } + }); + const rect = (card || overlay).getBoundingClientRect(); + const cx = Math.min(Math.max(rect.left + rect.width / 2, 1), vw - 1); + // On-top: at points down the dialog's column (incl. the very bottom, where the + // nav would intrude), the topmost element must belong to the overlay. + const ys = [rect.top + 8, (rect.top + rect.bottom) / 2, Math.min(rect.bottom - 8, vh - 8), vh - 4]; + let coveredBy: string | null = null; + for (const y of ys) { + if (y < 1 || y > vh - 1) continue; + const top = document.elementFromPoint(cx, y); + if (top && !overlay.contains(top) && top !== overlay) { + coveredBy = top.tagName + '.' + (top.className?.toString?.() || '').slice(0, 40); + break; + } + } + return { + found: true, + fitsWidth: rect.left >= -1 && rect.right <= vw + 1, + fitsHeight: rect.top >= -1 && rect.bottom <= vh + 1, + coveredBy, + }; + }, panelTextMatch); +}