Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/components/AdminUserManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,9 @@ export function AdminUserManagement() {
</h2>
</div>

<div className="flex-1 overflow-auto">
{/* 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. */}
<div className="flex-1 overflow-auto" data-audit-scroll-ok>
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-900">
<tr>
Expand Down
5 changes: 3 additions & 2 deletions packages/web/src/components/CreateWorkItemModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -336,7 +337,7 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio

if (!isOpen) return null;

return (
return createPortal((
<div className="fixed inset-0 z-[9999] overflow-y-auto backdrop-blur-sm" style={{position: 'fixed', top: 0, left: 0, right: 0, bottom: 0}}>
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
Expand Down Expand Up @@ -787,5 +788,5 @@ export function CreateWorkItemModal({ isOpen, onClose, parentWorkItemId, positio
</div>
</div>
</div>
);
), document.body);
}
10 changes: 5 additions & 5 deletions packages/web/src/pages/Backend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
};

Expand Down Expand Up @@ -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"
>
<AlertCircle className="h-4 w-4" />
<span>Check Data</span>
Expand Down
71 changes: 71 additions & 0 deletions tests/e2e/mobile-audit.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions tests/e2e/mobile-dialogs.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
171 changes: 171 additions & 0 deletions tests/helpers/mobileAudit.ts
Original file line number Diff line number Diff line change
@@ -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<LayoutFindings> {
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<ContrastFinding[]> {
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<string>();
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);
}
Loading