diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 27b7376f..512cd728 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -52,6 +52,7 @@ import { computeNodeLayerY, isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_S import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; import { directionStrategy, arrowVisibility, labelVisibility, perpendicularOffset } from '../lib/edgeLOD'; import { assignParallelEdgeIndices } from '../lib/parallelEdges'; +import { isTap, touchHitSize, isCoarsePointer } from '../lib/touchInteraction'; import { spawnCelebration } from '../lib/celebration'; import { buildNeighborhood } from '../lib/graphAdjacency'; import { UndoStack } from '../lib/undoStack'; @@ -2826,6 +2827,24 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (d.subgraphId) descendIntoRef.current(d.subgraphId); }); + // 44px touch targets (#29): the header icon buttons draw at iconSize (16-24px), + // far below the 44px minimum, so they're hard to hit on a phone. On a coarse + // pointer, prepend a transparent hit-rect of touchHitSize() to each icon group + // (pointer-events bubble to the group's handlers) — bigger tap area, identical + // look. The rect lives in the group's counter-zoomed local space, so it tracks. + if (isCoarsePointer()) { + const hit = touchHitSize(iconSize, true); + nodeElements.selectAll('.node-edit-icon, .node-relationship-icon, .node-expand-icon, .node-descend-icon') + .insert('rect', ':first-child') + .attr('class', 'touch-hit-area') + .attr('x', -hit / 2) + .attr('y', -hit / 2) + .attr('width', hit) + .attr('height', hit) + .attr('fill', 'transparent') + .style('pointer-events', 'all'); + } + // Child-graph count line (LOD-gated like the description text). sheetNodes.append('text') .attr('class', 'node-subgraph-count') @@ -3568,7 +3587,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i } } }) - .on('touchend', function() { + .on('touchend', function(event: TouchEvent, d: any) { // Clean up long-press detection const element = d3.select(this); const touchData = (element.node() as any).__touchData; @@ -3577,6 +3596,16 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (touchData.timeout) { clearTimeout(touchData.timeout); } + // TAP-TO-OPEN (#29): a quick touch that didn't move = a tap → select/open + // the node, deterministically. preventDefault suppresses the flaky + // synthetic mouse-click so the tap doesn't double-fire (open then toggle + // shut). Long-press (handled by the touchstart timeout) and drag/pan + // (moved past the slop) are excluded by isTap. + const duration = Date.now() - touchData.startTime; + if (isTap(duration, touchData.moved ? 999 : 0)) { + event.preventDefault(); + handleNodeClick(event as unknown as MouseEvent, d); + } delete (element.node() as any).__touchData; } diff --git a/packages/web/src/lib/__tests__/touchInteraction.test.ts b/packages/web/src/lib/__tests__/touchInteraction.test.ts new file mode 100644 index 00000000..3b1d054d --- /dev/null +++ b/packages/web/src/lib/__tests__/touchInteraction.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + MIN_TOUCH_TARGET, isTap, isLongPress, touchHitSize, distance, isCoarsePointer, +} from '../touchInteraction'; + +describe('isTap', () => { + it('quick + still = tap', () => { + expect(isTap(120, 3)).toBe(true); + expect(isTap(0, 0)).toBe(true); + }); + it('moved past slop = not a tap (it is a drag/pan)', () => { + expect(isTap(120, 40)).toBe(false); + }); + it('held too long = not a tap (it is a long-press)', () => { + expect(isTap(600, 2)).toBe(false); + }); +}); + +describe('isLongPress', () => { + it('held + still = long-press', () => { + expect(isLongPress(600, 4)).toBe(true); + }); + it('moved = not a long-press', () => { + expect(isLongPress(600, 40)).toBe(false); + }); + it('quick = not a long-press', () => { + expect(isLongPress(120, 2)).toBe(false); + }); + it('tap and long-press are mutually exclusive across the boundary', () => { + expect(isTap(499, 0)).toBe(true); + expect(isLongPress(499, 0)).toBe(false); + expect(isTap(500, 0)).toBe(false); + expect(isLongPress(500, 0)).toBe(true); + }); +}); + +describe('touchHitSize', () => { + it('enforces the 44px minimum on touch, never shrinking a larger control', () => { + expect(touchHitSize(16, true)).toBe(MIN_TOUCH_TARGET); + expect(touchHitSize(24, true)).toBe(44); + expect(touchHitSize(60, true)).toBe(60); + }); + it('leaves the visual size untouched on a mouse pointer', () => { + expect(touchHitSize(16, false)).toBe(16); + }); +}); + +describe('distance', () => { + it('is euclidean', () => { + expect(distance(0, 0, 3, 4)).toBe(5); + expect(distance(1, 1, 1, 1)).toBe(0); + }); +}); + +describe('isCoarsePointer', () => { + it('does not throw without a browser matchMedia', () => { + expect(typeof isCoarsePointer()).toBe('boolean'); + }); +}); diff --git a/packages/web/src/lib/touchInteraction.ts b/packages/web/src/lib/touchInteraction.ts new file mode 100644 index 00000000..4467caa8 --- /dev/null +++ b/packages/web/src/lib/touchInteraction.ts @@ -0,0 +1,51 @@ +/** + * Pure touch-interaction logic for the graph (#29 mobile touch overhaul). The D3 + * layer feeds raw touch timing/movement here and applies the verdicts; keeping + * the thresholds + classification here makes them unit-testable without a DOM. + * + * Gestures on a node: + * - tap → select / open (short, didn't move) + * - long-press → context menu (held, didn't move) + * - drag/pan → move the node or pan the canvas (moved past the slop) + */ +export const MIN_TOUCH_TARGET = 44; // px — WCAG 2.5.5 / Apple HIG minimum +export const TAP_MAX_MS = 500; // held longer (without moving) = long-press +export const TAP_MAX_MOVE_PX = 10; // moved farther = drag/pan, not a tap + +export function distance(ax: number, ay: number, bx: number, by: number): number { + return Math.hypot(ax - bx, ay - by); +} + +/** A quick touch that didn't move = a tap (select/open). */ +export function isTap( + durationMs: number, + movePx: number, + { maxMs = TAP_MAX_MS, maxMove = TAP_MAX_MOVE_PX } = {}, +): boolean { + return durationMs >= 0 && durationMs < maxMs && movePx <= maxMove; +} + +/** A held touch that didn't move = a long-press (context menu). */ +export function isLongPress( + durationMs: number, + movePx: number, + { minMs = TAP_MAX_MS, maxMove = TAP_MAX_MOVE_PX } = {}, +): boolean { + return durationMs >= minMs && movePx <= maxMove; +} + +/** + * The hit-area side length for an on-canvas control. On touch we guarantee at + * least MIN_TOUCH_TARGET regardless of how small the icon is drawn; on a mouse + * pointer the visual size is fine. + */ +export function touchHitSize(visualPx: number, isTouch: boolean, min = MIN_TOUCH_TARGET): number { + return isTouch ? Math.max(min, visualPx) : visualPx; +} + +/** Coarse-pointer (touch) detection; safe in non-browser/test contexts. */ +export function isCoarsePointer(): boolean { + return typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia('(pointer: coarse)').matches; +} diff --git a/tests/e2e/mobile/touch-graph.spec.ts b/tests/e2e/mobile/touch-graph.spec.ts new file mode 100644 index 00000000..e26fda11 --- /dev/null +++ b/tests/e2e/mobile/touch-graph.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_USERS } from '../../lib/auth'; + +/** + * Mobile touch overhaul (#29). On a phone the graph defaults to LOCKED (touch + * pans the canvas), but a single TAP on a node must still open it — the primary + * way to inspect/edit on touch. Verifies tap-to-open works under a real touch + * context (not a synthetic mouse click). + */ +test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + +test.describe('mobile graph touch @mobile @touch', () => { + test('single tap on a node opens the inspector (even while locked)', async ({ page }) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + // Land deterministically on the graph view. + await page.evaluate(() => localStorage.setItem('graphdone:viewMode', 'graph')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.node-bg', { timeout: 30000 }); + await page.waitForTimeout(4000); // let physics settle + + // The graph starts locked on a phone (pan-by-touch is the default nav). + const lock = page.getByTestId('graph-lock-toggle'); + await expect(lock).toBeVisible(); + + // Inspector is not open yet. + await expect(page.getByTestId('node-inspector')).toHaveCount(0); + + // A real touch TAP on a node card opens the inspector. + await page.locator('.node-bg').first().tap(); + await expect(page.getByTestId('node-inspector')).toBeVisible({ timeout: 8000 }); + }); +});