diff --git a/README.md b/README.md
index e2d3641..5399c65 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,11 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief
and Eraser (`7`/`E`, click a shape or connection to delete it). Escape goes back to Select.
- **New diagram and samples.** Start empty, or from a web app architecture or support flow
sample. Undo brings back whatever was there.
+- **Phones and tablets.** Below 768px the tool bar docks at the bottom and panels and dialogs rise
+ as sheets. Pinch to zoom and pan with two fingers anywhere, even over shapes; a long press (or a
+ right click) opens a shape's or connection's menu. A stylus draws with its pressure, and a palm
+ resting on the screen draws nothing. A link opened on a phone shows the diagram fitted and
+ read-only, with Copy for AI and Edit.
- **Shape library.** The Shapes tool opens it over the canvas. Drag a shape onto the canvas to drop
it there, or click it (or press Enter) to add it in a clear spot near the middle.
- **Resize.** Select a shape and drag its handles; hold Shift to keep its proportions.
diff --git a/backlog/tasks/fl-81 - Phones-and-tablets.md b/backlog/tasks/fl-81 - Phones-and-tablets.md
index b60579a..9667e58 100644
--- a/backlog/tasks/fl-81 - Phones-and-tablets.md
+++ b/backlog/tasks/fl-81 - Phones-and-tablets.md
@@ -1,9 +1,11 @@
---
id: FL-81
title: Phones and tablets
-status: To Do
-assignee: []
+status: Done
+assignee:
+ - '@raj-khan'
created_date: '2026-09-25 17:39'
+updated_date: '2026-09-27 13:08'
labels:
- frontend
- responsive
@@ -26,10 +28,44 @@ There are no responsive styles in src/ and no touch gestures. Anyone opening a s
-- [ ] #1 Below 768px the tool bar docks at the bottom, the menu collapses to one button, panels and dialogs become bottom sheets, and nothing scrolls sideways at 360px
-- [ ] #2 Pinch to zoom, two-finger pan, long press for the context menu, touch targets at least 44px
-- [ ] #3 The pen supports stylus pressure and palm rejection
-- [ ] #4 A shared or hosted link on a phone opens a fitted read-only view with Copy for AI and Edit
-- [ ] #5 The e2e suite also runs at a phone and a tablet viewport
+- [x] #1 Below 768px the tool bar docks at the bottom, the menu collapses to one button, panels and dialogs become bottom sheets, and nothing scrolls sideways at 360px
+- [x] #2 Pinch to zoom, two-finger pan, long press for the context menu, touch targets at least 44px
+- [x] #3 The pen supports stylus pressure and palm rejection
+- [x] #4 A shared or hosted link on a phone opens a fitted read-only view with Copy for AI and Edit
+- [x] #5 The e2e suite also runs at a phone and a tablet viewport
+
+## Implementation Plan
+
+
+
+1. Responsive shell below 768px (Tailwind max-md): tool bar docks bottom centre; the menu island is one button; undo/redo move to the top bar; view controls shrink to fit and zoom level; minimap hidden; library, text pane, details drawer and dialogs become bottom sheets; toasts sit above the tool bar; no sideways scroll at 360px.
+2. Touch: touch-action none on the canvas (Vue Flow already pinch-zooms and pans with d3-zoom); 44px targets under pointer: coarse; a canvas context menu (Open, Rename, Duplicate, Delete; Remove for a connection) on right click and on a 500ms long press.
+3. Pen: record pointer pressure per point as x,y,p; draw pressure strokes as a variable-width outline (perfect-freehand) in the app and the SVG renderer; once a stylus is seen, touch pointers do not draw (palm rejection).
+4. Viewer: a diagram opened from a link on a phone-sized screen opens read-only and fitted, with Copy for AI and Edit.
+5. Playwright phone (Pixel 7) and tablet (Galaxy Tab S4) projects running e2e/mobile/*.spec.js.
+
+
+
+## Implementation Notes
+
+
+
+Vue Flow cannot pinch when a finger lands on a shape: d3-drag on the node stops the touch propagating to d3-zoom. useTwoFingers handles two-finger pinch and pan in the capture phase, keeping the point between the fingers fixed, so it works over shapes and leaves no Move in history.
+
+Long press is a 500ms timer on touch/pen pointers (iOS sends no contextmenu); the ending tap is swallowed. The same menu opens on right click: Open details, Rename, Duplicate, Delete; Edit label and Remove for a connection.
+
+Pressure is stored as x,y,p per point only when a stylus drew; perfect-freehand turns it into a filled outline in the app and renderSvg. Plain strokes are unchanged. Palm rejection: once a stylus has drawn, touch never draws; a second pointer never joins a stroke.
+
+On a phone, undo/redo join the top bar and the view controls shrink to fit and zoom; the minimap hides. The viewer refits after the shared diagram is measured, because Vue Flow was already mounted with the previous one.
+
+Verified: e2e/mobile/touch.spec.js on phone (Pixel 7) and tablet (Galaxy Tab S4 landscape), 12 runs: no sideways scroll at 360px, tool bar position, 44px targets, CDP two-finger pinch and pan, long press menu and undo, sheets, pen pressure and palm, viewer fitted with Copy for AI and Edit; right-click menu in tools.spec.js; full e2e 119, vitest 247, lint and typecheck; screenshots at 360px, Pixel 7 and tablet.
+
+
+## Final Summary
+
+
+
+Phones and tablets: a thumb-first layout below 768px with bottom sheets, 44px targets, pinch and two-finger pan anywhere, a long-press and right-click context menu, stylus pressure with palm rejection, and a fitted read-only viewer for links opened on a phone. Verified by phone and tablet Playwright projects and screenshots.
+
diff --git a/e2e/mobile/touch.spec.js b/e2e/mobile/touch.spec.js
new file mode 100644
index 0000000..0f3d529
--- /dev/null
+++ b/e2e/mobile/touch.spec.js
@@ -0,0 +1,216 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * Touch, on a phone (Pixel 7) and a tablet (Galaxy Tab S4, landscape). The
+ * phone gets the phone layout; the tablet keeps the desktop one, by touch.
+ */
+const shapes = (page) => page.locator('.vue-flow__node')
+const node = (page, id) => page.locator(`.vue-flow__node[data-id="${id}"]`)
+const tools = (page) => page.getByRole('toolbar', { name: 'Tools' })
+const isPhone = (page) => page.viewportSize().width < 768
+const transform = (page) =>
+ page.locator('.vue-flow__transformationpane').evaluate((element) => element.style.transform)
+const zoomOf = async (page) => Number((await transform(page)).match(/scale\(([\d.]+)\)/)[1])
+
+/** Two fingers, from one pair of points to another, in steps. */
+async function twoFingers(page, from, to) {
+ const cdp = await page.context().newCDPSession(page)
+ const points = (pair) => pair.map(([x, y], id) => ({ x, y, id }))
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(from) })
+ for (let step = 1; step <= 10; step += 1) {
+ const at = from.map(([x, y], index) => [
+ x + ((to[index][0] - x) * step) / 10,
+ y + ((to[index][1] - y) * step) / 10,
+ ])
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(at) })
+ }
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
+}
+
+/** A finger resting on a point for longer than a long press. */
+async function longPress(page, locator) {
+ const box = await locator.boundingBox()
+ const cdp = await page.context().newCDPSession(page)
+ const point = { x: box.x + box.width / 2, y: box.y + box.height / 2, id: 0 }
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] })
+ await page.waitForTimeout(700)
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/new')
+ await expect(shapes(page)).toHaveCount(5)
+})
+
+test('fits the screen: nothing scrolls sideways, and the tool bar sits where the thumb is', async ({
+ page,
+}) => {
+ if (isPhone(page)) await page.setViewportSize({ width: 360, height: 780 })
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(
+ page.viewportSize().width,
+ )
+
+ const bar = await tools(page).boundingBox()
+ const height = page.viewportSize().height
+ if (isPhone(page)) expect(bar.y + bar.height).toBeGreaterThan(height - 24)
+ else expect(bar.y).toBeLessThan(24)
+
+ for (const button of await page.locator('.island button:visible').all()) {
+ const box = await button.boundingBox()
+ expect(
+ Math.min(box.width, box.height),
+ await button.getAttribute('aria-label'),
+ ).toBeGreaterThanOrEqual(44)
+ }
+})
+
+test('pinching zooms, and two fingers pan', async ({ page }) => {
+ const { width, height } = page.viewportSize()
+ const [cx, cy] = [width / 2, height / 2]
+ const before = await zoomOf(page)
+
+ await twoFingers(
+ page,
+ [
+ [cx - 30, cy],
+ [cx + 30, cy],
+ ],
+ [
+ [cx - 120, cy],
+ [cx + 120, cy],
+ ],
+ )
+ await expect.poll(() => zoomOf(page)).toBeGreaterThan(before)
+
+ const panned = await transform(page)
+ await twoFingers(
+ page,
+ [
+ [cx - 40, cy],
+ [cx + 40, cy],
+ ],
+ [
+ [cx - 40, cy + 120],
+ [cx + 40, cy + 120],
+ ],
+ )
+ await expect.poll(() => transform(page)).not.toBe(panned)
+
+ // A gesture over shapes moves the view, never a shape.
+ await expect(
+ page.getByRole('toolbar', { name: 'History' }).getByRole('button', { name: 'Undo' }),
+ ).toBeDisabled()
+})
+
+test('a long press opens the context menu, and its Delete can be undone', async ({ page }) => {
+ await longPress(page, node(page, 'e879e4'))
+ const menu = page.getByRole('menu', { name: 'Shape' })
+ await expect(menu).toBeVisible()
+ await expect(menu.getByRole('menuitem')).toHaveText([
+ 'Open details',
+ 'Rename',
+ 'Duplicate',
+ 'Delete',
+ ])
+
+ await menu.getByRole('menuitem', { name: 'Delete' }).tap()
+ await expect(shapes(page)).toHaveCount(4)
+ await page.getByRole('toolbar', { name: 'History' }).getByRole('button', { name: 'Undo' }).tap()
+ await expect(shapes(page)).toHaveCount(5)
+})
+
+test('panels rise as sheets on a phone, and float on a tablet', async ({ page }) => {
+ await tools(page).getByRole('button', { name: 'Shapes' }).tap()
+ const library = await page.getByRole('complementary', { name: 'Shapes' }).boundingBox()
+ const { width, height } = page.viewportSize()
+ if (isPhone(page)) {
+ expect(library.width).toBeCloseTo(width, 0)
+ expect(library.y + library.height).toBeCloseTo(height, 0)
+ } else {
+ expect(library.width).toBeLessThan(width / 2)
+ }
+})
+
+test('the pen takes a stylus’s pressure, and ignores a resting palm', async ({ page }) => {
+ await tools(page).getByRole('button', { name: 'Pen' }).tap()
+ const layer = page.getByTestId('pen-layer')
+ const box = await layer.boundingBox()
+ const at = (dx, dy) => ({ clientX: box.x + 100 + dx, clientY: box.y + 200 + dy })
+ const send = (type, init) =>
+ layer.dispatchEvent(type, { bubbles: true, isPrimary: true, button: 0, ...init })
+
+ await send('pointerdown', { pointerId: 7, pointerType: 'pen', pressure: 0.2, ...at(0, 0) })
+ for (let step = 1; step <= 8; step += 1) {
+ await send('pointermove', {
+ pointerId: 7,
+ pointerType: 'pen',
+ pressure: 0.2 + step * 0.08,
+ ...at(step * 15, (step % 2) * 20),
+ })
+ }
+ await send('pointerup', { pointerId: 7, pointerType: 'pen', ...at(120, 0) })
+ await expect(page.getByTestId('ink-stroke')).toHaveCount(1)
+
+ const ink = await page.evaluate(() =>
+ JSON.parse(localStorage.getItem('flow:document')).nodes.find((n) => n.type === 'ink'),
+ )
+ expect(ink.data.points.split(' ').every((point) => point.split(',').length === 3)).toBe(true)
+
+ // A palm, after the stylus: it draws nothing.
+ await send('pointerdown', { pointerId: 9, pointerType: 'touch', ...at(0, 60) })
+ await send('pointermove', { pointerId: 9, pointerType: 'touch', ...at(80, 90) })
+ await send('pointerup', { pointerId: 9, pointerType: 'touch', ...at(80, 90) })
+ await expect(page.getByTestId('ink-stroke')).toHaveCount(1)
+})
+
+test('a shared link opens read-only and fitted on a phone, with Copy for AI and Edit', async ({
+ page,
+ context,
+}) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+ const hash =
+ '#flow=t' +
+ Buffer.from(
+ // Fanned out wider than a phone, so it has to be fitted to be seen.
+ [
+ 'title: Checkout',
+ 'pay = process "Pay"',
+ ...['ship', 'mail', 'bill', 'log'].map((id) => `${id} = process "${id}"\npay -> ${id}`),
+ ].join('\n'),
+ 'utf8',
+ ).toString('base64url')
+ await page.goto(`/new${hash}`)
+ await expect(shapes(page)).toHaveCount(5)
+
+ const viewer = page.getByRole('toolbar', { name: 'Shared diagram' })
+ if (!isPhone(page)) {
+ await expect(viewer).toHaveCount(0)
+ await expect(tools(page)).toBeVisible()
+ return
+ }
+
+ await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible()
+ // Fitted: every shape is on screen.
+ const { width, height } = page.viewportSize()
+ await expect
+ .poll(async () => {
+ const boxes = await Promise.all(
+ (await shapes(page).all()).map((shape) => shape.boundingBox()),
+ )
+ return boxes.every(
+ (box) => box.x >= 0 && box.x + box.width <= width && box.y + box.height <= height,
+ )
+ })
+ .toBe(true)
+ await expect(tools(page)).toHaveCount(0)
+ await node(page, 'pay').tap()
+ await expect(page).toHaveURL(/\/new$/)
+
+ await viewer.getByRole('button', { name: 'Copy for AI' }).tap()
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('Pay')
+
+ await viewer.getByRole('button', { name: 'Edit' }).tap()
+ await expect(tools(page)).toBeVisible()
+ await node(page, 'pay').tap()
+ await expect(page).toHaveURL(/\/new\/node\/pay$/)
+})
diff --git a/e2e/tools.spec.js b/e2e/tools.spec.js
index 576cbdb..c0a7897 100644
--- a/e2e/tools.spec.js
+++ b/e2e/tools.spec.js
@@ -97,3 +97,20 @@ test('the text pane and the details drawer float and close', async ({ page }) =>
await page.getByRole('button', { name: 'Close details' }).click()
await expect(page).toHaveURL(/\/new$/)
})
+
+test('a right click opens the context menu for a shape or a connection', async ({ page }) => {
+ await node(page, 'e879e4').click({ button: 'right' })
+ const menu = page.getByRole('menu', { name: 'Shape' })
+ await expect(menu.getByRole('menuitem').first()).toBeFocused()
+ await menu.getByRole('menuitem', { name: 'Duplicate' }).click()
+ await expect(shapes(page)).toHaveCount(6)
+
+ await page.keyboard.press('Escape')
+ // A step line's box has no line at its centre, so the menu is asked for on the line itself.
+ await page
+ .locator('.vue-flow__edge[data-id="e-d09c08-b6a0c1"] path.cursor-pointer')
+ .dispatchEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })
+ await expect(page.getByRole('menu', { name: 'Connection' })).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('menu')).toHaveCount(0)
+})
diff --git a/package-lock.json b/package-lock.json
index 1ff0634..2b5addc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1",
"fflate": "^0.8.3",
+ "perfect-freehand": "^1.2.3",
"pinia": "^4.0.3",
"roughjs": "^4.6.6",
"vue": "^3.5.42",
@@ -3616,6 +3617,12 @@
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
+ "node_modules/perfect-freehand": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
+ "integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/package.json b/package.json
index 43279e9..60ae198 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1",
"fflate": "^0.8.3",
+ "perfect-freehand": "^1.2.3",
"pinia": "^4.0.3",
"roughjs": "^4.6.6",
"vue": "^3.5.42",
diff --git a/playwright.config.js b/playwright.config.js
index 267f5ba..769955a 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -16,7 +16,16 @@ export default defineConfig({
trace: 'on-first-retry',
},
- projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ // Desktop runs everything but the touch specs; a phone and a tablet run those.
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, testIgnore: /mobile\// },
+ { name: 'phone', use: { ...devices['Pixel 7'] }, testMatch: /mobile\/.*\.spec\.js/ },
+ {
+ name: 'tablet',
+ use: { ...devices['Galaxy Tab S4 landscape'] },
+ testMatch: /mobile\/.*\.spec\.js/,
+ },
+ ],
// Against the production build, so E2E exercises what actually ships.
webServer: {
diff --git a/src/components/canvas/CanvasContextMenu.vue b/src/components/canvas/CanvasContextMenu.vue
new file mode 100644
index 0000000..4356933
--- /dev/null
+++ b/src/components/canvas/CanvasContextMenu.vue
@@ -0,0 +1,113 @@
+
+
+
+
+