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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 43 additions & 7 deletions backlog/tasks/fl-81 - Phones-and-tablets.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,10 +28,44 @@ There are no responsive styles in src/ and no touch gestures. Anyone opening a s

<!-- AC:BEGIN -->

- [ ] #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

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

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.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

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.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

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.
<!-- SECTION:FINAL_SUMMARY:END -->
216 changes: 216 additions & 0 deletions e2e/mobile/touch.spec.js
Original file line number Diff line number Diff line change
@@ -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$/)
})
17 changes: 17 additions & 0 deletions e2e/tools.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading