diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 77bb5bfa..e2995288 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -107,11 +107,17 @@ interface InteractiveGraphVisualizationProps { /** Notifies the host (Workspace) which node is selected, so a docked * inspector can show its contents/diagram. Fires null on deselect. */ onNodeSelected?: (node: WorkItem | null) => void; + /** When true (phone "locked" mode), node + edge-label dragging is disabled so + * a touch gesture pans the canvas instead of accidentally moving things. */ + interactionLocked?: boolean; } -export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: InteractiveGraphVisualizationProps = {}) { +export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, interactionLocked }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); + // Live-readable lock flag for d3 drag filters (avoids re-binding handlers on toggle). + const interactionLockedRef = useRef(!!interactionLocked); + useEffect(() => { interactionLockedRef.current = !!interactionLocked; }, [interactionLocked]); const { currentGraph, availableGraphs, descendInto } = useGraph(); // descendInto from context isn't memoized; hold the latest in a ref so the // D3-bound node click handler can call it without re-binding every render. @@ -2282,8 +2288,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: .attr('class', (d: WorkItem) => `node node-type-${d.type.toLowerCase()}`) .style('cursor', 'pointer') .call(d3.drag() + // Locked (phone) → reject the drag so the gesture pans the canvas instead. + .filter((event: any) => !interactionLockedRef.current && !event.ctrlKey && !event.button) .on('start', (event, d: any) => { - + // Check if this is an edge creation attempt (Alt/Option key held) if (event.sourceEvent.altKey) { mousedownNodeRef.current = d; @@ -3686,6 +3694,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: edgeLabelGroups .style('cursor', 'grab') .call(d3.drag() + // Locked (phone) → reject so the gesture pans instead of sliding the label. + .filter((event: any) => !interactionLockedRef.current && !event.ctrlKey && !event.button) .on('start', (event) => { event.sourceEvent?.stopPropagation(); }) diff --git a/packages/web/src/components/SafeGraphVisualization.tsx b/packages/web/src/components/SafeGraphVisualization.tsx index 3b91e3ae..a25579d6 100644 --- a/packages/web/src/components/SafeGraphVisualization.tsx +++ b/packages/web/src/components/SafeGraphVisualization.tsx @@ -9,16 +9,17 @@ import type { WorkItem } from '../types/graph'; */ interface SafeGraphVisualizationProps { onNodeSelected?: (node: WorkItem | null) => void; + interactionLocked?: boolean; } -export function SafeGraphVisualization({ onNodeSelected }: SafeGraphVisualizationProps = {}) { +export function SafeGraphVisualization({ onNodeSelected, interactionLocked }: SafeGraphVisualizationProps = {}) { return ( { // Error logged by boundary for debugging }} > - + ); } diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index b06f5bf4..3684a146 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight, MoreHorizontal, Lock, Unlock } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; @@ -35,6 +35,12 @@ export function Workspace() { const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches ); + const [showMoreSheet, setShowMoreSheet] = useState(false); + // Graph touch-interaction lock — on a phone, default to locked so panning the + // canvas never accidentally drags a node or an edge label; unlock to edit. + const [graphLocked, setGraphLocked] = useState(() => + typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches + ); const [showMiniMap, setShowMiniMap] = useState(true); const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); const breadcrumb = getBreadcrumb(); @@ -124,9 +130,9 @@ export function Workspace() { - {/* Center Section: View Mode Buttons — horizontally scrollable on mobile so - all eight stay reachable without clipping on a phone-width screen. */} -
+ {/* Center Section: View Mode Buttons — desktop/tablet only. Phones use the + bottom tab bar (List / Graph / More) instead of this strip. */} +
)} - +
+ {/* Lock toggle (phones): when locked, touch pans the canvas and never + drags a node or edge label; unlock to rearrange/edit. */} + {inspectorNode && (
setInspectorNode(null)} /> @@ -438,6 +459,93 @@ export function Workspace() { )}
+ {/* Mobile bottom navigation — the primary way to move between views on a phone. + List first, then Graph; the rest live in the More sheet. */} + {currentGraph && ( + + )} + + {/* Create FAB (phones) — primary action; hidden for read-only guests and in + graph view (where the on-canvas "+" grow flow creates nodes). */} + {currentGraph && currentUser?.role !== 'GUEST' && viewMode !== 'graph' && ( + + )} + + {/* "More" views sheet (phones) */} + {showMoreSheet && createPortal( +
setShowMoreSheet(false)} + > +
+
e.stopPropagation()} + > +
+

More views

+
+ {([ + { mode: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard }, + { mode: 'table', label: 'Table', Icon: Table }, + { mode: 'kanban', label: 'Board', Icon: Columns }, + { mode: 'gantt', label: 'Gantt', Icon: GanttChartSquare }, + { mode: 'calendar', label: 'Calendar', Icon: CalendarDays }, + { mode: 'activity', label: 'Activity', Icon: Activity }, + ] as const).map(({ mode, label, Icon }) => ( + + ))} +
+
+
, + document.body + )} + {/* Mini-Map Navigation - Bottom Right Corner (hidden on mobile: it covers too much of a phone screen, and panning the graph by touch is the primary nav there) */} {viewMode === 'graph' && currentGraph && showMiniMap && !isMobile && createPortal( diff --git a/tests/e2e/mobile-experience.spec.ts b/tests/e2e/mobile-experience.spec.ts index b4311ce7..3754cb7b 100644 --- a/tests/e2e/mobile-experience.spec.ts +++ b/tests/e2e/mobile-experience.spec.ts @@ -37,29 +37,37 @@ test.describe('mobile experience @mobile', () => { expect(overflow, 'no horizontal page overflow on a phone').toBeLessThanOrEqual(1); }); - test('all eight view tabs stay reachable and the graph view is mobile-clean', async ({ page }) => { + test('bottom nav (List/Graph/More) drives the views and the graph is mobile-clean', async ({ page }) => { await login(page, TEST_USERS.ADMIN); await page.waitForTimeout(1500); - // Every view tab must exist and be clickable (the strip scrolls, never clips). - const tabTitles = [ - 'Graph View', 'Dashboard View', 'Table View', 'Card View', - 'Kanban View', 'Gantt Chart', 'Calendar View', 'Activity Feed', - ]; - for (const title of tabTitles) { - const tab = page.locator(`button[title="${title}"]`); - await expect(tab, `${title} tab present`).toHaveCount(1); + // The phone uses a bottom tab bar, not the desktop tab strip. + const nav = page.getByTestId('mobile-bottom-nav'); + await expect(nav, 'mobile bottom nav present').toBeVisible(); + await expect(nav.getByText('List', { exact: true })).toBeVisible(); + await expect(nav.getByText('Graph', { exact: true })).toBeVisible(); + await expect(nav.getByText('More', { exact: true })).toBeVisible(); + + // More opens a sheet with the secondary views (kanban shows as "Board"). + await nav.getByText('More', { exact: true }).click(); + const sheet = page.getByTestId('mobile-more-sheet'); + for (const label of ['Dashboard', 'Table', 'Board', 'Gantt', 'Calendar', 'Activity']) { + await expect(sheet.getByText(label, { exact: true }), `${label} in More sheet`).toBeVisible(); } + await page.keyboard.press('Escape').catch(() => {}); + await page.mouse.click(10, 200); // dismiss the sheet - // Switch to the graph view and verify the mobile-clean treatment. - const graphTab = page.locator('button[title="Graph View"]'); - await graphTab.scrollIntoViewIfNeeded(); - await graphTab.click(); + // Switch to the graph via the bottom nav and verify the mobile-clean treatment. + await nav.getByText('Graph', { exact: true }).click(); await page.waitForTimeout(3000); - // Minimap is hidden on mobile (it covered ~18% of the screen). + // The lock toggle defaults to "Locked" so panning never drags a node/edge. + const lock = page.getByTestId('graph-lock-toggle'); + await expect(lock, 'graph lock toggle present on mobile').toBeVisible(); + await expect(lock).toContainText('Locked'); + + // Minimap is hidden on mobile; no connection banner while GraphQL is healthy. await expect(page.getByText('Mini-Map', { exact: true })).toHaveCount(0); - // No connection banner while the GraphQL data layer is healthy. await expect(page.getByText('Connection Lost', { exact: true })).toHaveCount(0); }); }); diff --git a/tests/e2e/mobile-views.spec.ts b/tests/e2e/mobile-views.spec.ts index 9b3a8525..9c47203b 100644 --- a/tests/e2e/mobile-views.spec.ts +++ b/tests/e2e/mobile-views.spec.ts @@ -10,31 +10,33 @@ import { login, TEST_USERS } from '../helpers/auth'; */ test.use({ viewport: { width: 390, height: 844 } }); -const VIEWS = [ - { name: 'Dashboard', tab: 'Dashboard View' }, - { name: 'Table', tab: 'Table View' }, - { name: 'Card', tab: 'Card View' }, - { name: 'Kanban', tab: 'Kanban View' }, - { name: 'Gantt', tab: 'Gantt Chart' }, - { name: 'Calendar', tab: 'Calendar View' }, - { name: 'Activity', tab: 'Activity Feed' }, -]; +const VIEWS = ['Dashboard', 'Table', 'Card', 'Kanban', 'Gantt', 'Calendar', 'Activity']; -async function openView(page: Page, tab: string) { - const t = page.locator(`button[title="${tab}"]`); - await t.scrollIntoViewIfNeeded(); - await t.click(); +// Navigate via the mobile bottom nav (List/Graph) + More sheet — the top tab +// strip is desktop-only now. +async function openView(page: Page, name: string) { + const nav = page.getByTestId('mobile-bottom-nav'); + if (name === 'Card') { + await nav.getByText('List', { exact: true }).click(); + } else if (name === 'Graph') { + await nav.getByText('Graph', { exact: true }).click(); + } else { + await nav.getByText('More', { exact: true }).click(); + const sheet = page.getByTestId('mobile-more-sheet'); + const label = name === 'Kanban' ? 'Board' : name; + await sheet.getByText(label, { exact: true }).click(); + } await page.waitForTimeout(2000); } test.describe('mobile views are explorable by scrolling down, not sideways @mobile', () => { - for (const v of VIEWS) { - test(`${v.name} view needs no horizontal scrolling on a phone`, async ({ page }) => { + for (const name of VIEWS) { + test(`${name} view needs no horizontal scrolling on a phone`, async ({ page }) => { const pageErrors: string[] = []; page.on('pageerror', (e) => pageErrors.push(e.message)); await login(page, TEST_USERS.ADMIN); - await openView(page, v.tab); + await openView(page, name); const probe = await page.evaluate(() => { const el = document.querySelector('[data-testid="view-content"]'); @@ -69,15 +71,15 @@ test.describe('mobile views are explorable by scrolling down, not sideways @mobi expect(probe.docOverflow, 'page itself must not overflow sideways').toBeLessThanOrEqual(1); expect( probe.offenders, - `${v.name}: these elements force horizontal scrolling on a phone` + `${name}: these elements force horizontal scrolling on a phone` ).toEqual([]); - expect(pageErrors, `${v.name}: no uncaught JS errors`).toEqual([]); + expect(pageErrors, `${name}: no uncaught JS errors`).toEqual([]); }); } test('Calendar defaults to the agenda list on a phone (not the cramped month grid)', async ({ page }) => { await login(page, TEST_USERS.ADMIN); - await openView(page, 'Calendar View'); + await openView(page, 'Calendar'); const agendaActive = await page.evaluate(() => { const btn = [...document.querySelectorAll('button')].find( (b) => (b.textContent || '').trim() === 'Agenda'