Skip to content

Refine canvas trackpad navigation - #64

Merged
donovan-yohan merged 2 commits into
mainfrom
codex/canvas-trackpad-gestures
Aug 10, 2026
Merged

donovan-yohan merged 2 commits into
mainfrom
codex/canvas-trackpad-gestures

Conversation

@donovan-yohan

@donovan-yohan donovan-yohan commented Aug 10, 2026 •

Copy link
Copy Markdown
Owner

What changed

  • make ordinary two-finger trackpad scrolling pan the shared canvas camera
  • reserve zoom for pinch input, with bounded sensitivity and cursor anchoring
  • prevent cancellable browser page zoom on canvas gestures, including a Safari gesture fallback
  • suppress the full-canvas focus outline during Space panning
  • separate roving keyboard focus from selection so blank-pane clicks visibly deselect nodes
  • add deterministic gesture math tests and trusted Playwright coverage across React Flow and generic Mermaid

Root cause

The canvas treated every wheel event as a fixed 10% zoom step. Its roving keyboard focus target was also painted like selection, so clearing selected IDs still left a default node looking active.

Impact

MacBook trackpad scrolling now behaves like a canvas pan, pinch zoom is controlled, blank clicks clear selection, and keyboard focus remains accessible without the panning outline artifact.

Exact-head evidence

Head: bf3cd96

  • pnpm --filter @arielcharts/shared build
  • pnpm lint
  • pnpm typecheck
  • pnpm test — 302 tests passed
  • pnpm build
  • npx tsx e2e-workspace-ux-validate.ts
  • npx tsx e2e-validate.ts
  • pnpm test:e2e-sequence
  • git diff --check
  • adversarial review plus focused re-review: no remaining P0/P1/P2

The workspace UX gate uses trusted page.mouse.wheel input at verified React Flow and generic Mermaid canvas targets, checks defaultPrevented, pan-only scroll, gentle cursor-anchored pinch zoom, browser viewport-scale stability, blank-pane deselection, and focus styling.

Residual platform note

macOS Accessibility Zoom may intercept gestures before the browser. The app suppresses cancellable browser-level gesture defaults; a physical Mac/Safari pass remains the final platform-specific confirmation.

Summary by CodeRabbit

  • New Features

    • Added smooth canvas panning with mouse-wheel gestures.
    • Added cursor-centered zooming with Ctrl/trackpad gestures.
    • Improved Safari pinch-gesture support.
    • Added clearer focus indicators for keyboard navigation and panning.
  • Bug Fixes

    • Blank-canvas clicks now reliably clear selections.
    • Prevented unintended browser zooming during canvas interactions.
    • Improved focus styling consistency across diagram types.

@vercel

vercel Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
arielcharts Ready Ready Preview Aug 10, 2026 4:10pm

@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@donovan-yohan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c76ed36-3eb4-4ed9-b90c-ff2804106aa5

📥 Commits

Reviewing files that changed from the base of the PR and between bf3cd96 and 9879664.

📒 Files selected for processing (3)
  • apps/web/src/components/diagram-canvas.test.ts
  • apps/web/src/components/diagram-canvas.tsx
  • e2e-workspace-ux-validate.ts
📝 Walkthrough

Walkthrough

The PR adds normalized wheel and Safari pinch gesture handling with bounded, pointer-centered zoom. It also updates blank-canvas selection clearing and focus-visible styling for canvas and diagram nodes.

Changes

Canvas gesture handling

Layer / File(s) Summary
Wheel gesture conversion and camera updates
apps/web/src/lib/canvas-wheel-gesture.ts, apps/web/src/lib/canvas-wheel-gesture.test.ts
Wheel input produces pan or bounded pointer-centered zoom gestures. Tests cover delta normalization, camera updates, zoom bounds, and cursor anchoring.
Canvas wheel and Safari gesture wiring
apps/web/src/components/diagram-canvas.tsx, apps/web/src/components/diagram-canvas.test.ts, e2e-workspace-ux-validate.ts
The canvas uses native wheel and Safari gesture listeners, excludes editable targets, prevents browser defaults, and exposes panning state. Unit and E2E tests validate the behavior.
Selection and focus behavior
apps/web/src/app/globals.css, apps/web/src/components/diagram-canvas.tsx, apps/web/src/components/diagram-canvas.test.ts, e2e-workspace-ux-validate.ts
Pane clicks clear app-owned selection. Canvas and node outlines use focus-visible behavior. React Flow styling no longer uses focused-but-unselected node state. Tests cover blank clicks and focus styling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant DiagramCanvas
  participant CanvasWheelGesture
  participant CanvasCamera
  Browser->>DiagramCanvas: wheel or Safari gesture event
  DiagramCanvas->>CanvasWheelGesture: convert eligible input
  CanvasWheelGesture->>CanvasCamera: apply pan or pointer-centered zoom
  DiagramCanvas-->>Browser: prevent default browser behavior
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: refining canvas trackpad navigation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/canvas-trackpad-gestures

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/web/src/lib/canvas-wheel-gesture.test.ts (1)

45-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Compare the anchored point with a tolerance.

The anchored coordinates come from floating-point multiplication and subtraction (camera.zoom * 1.05 is not exact). toEqual requires bit-exact equality, so this assertion can break after a small change to the zoom scale or camera values, even when the anchoring logic stays correct. Use toBeCloseTo for each axis.

♻️ Proposed refactor
-    expect({
-      x: origin.left + next.panX + (anchoredCanvasPoint.x * next.zoom),
-      y: origin.top + next.panY + (anchoredCanvasPoint.y * next.zoom),
-    }).toEqual(client);
+    expect(origin.left + next.panX + (anchoredCanvasPoint.x * next.zoom)).toBeCloseTo(client.x);
+    expect(origin.top + next.panY + (anchoredCanvasPoint.y * next.zoom)).toBeCloseTo(client.y);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/canvas-wheel-gesture.test.ts` around lines 45 - 48, Update
the anchored-point assertion in the canvas wheel gesture test to compare the
computed x and y coordinates independently with toBeCloseTo rather than using
exact toEqual object equality. Preserve the existing expected coordinate
formulas and client values while allowing normal floating-point rounding
differences.
apps/web/src/components/diagram-canvas.test.ts (1)

44-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider behavioral assertions instead of source-text regexes.

These assertions match the component source text and the stylesheet text. They break on formatting changes, such as a different argument order in addEventListener options or a line break inside the callback. They also pass when the behavior is broken, for example when the effect never attaches the listener. The e2e suite covers the trusted-gesture path, so a small DOM-level test would close the remaining gap: render the canvas, dispatch a wheel event with cancelable: true, and assert defaultPrevented and the resulting transform. Keep the regex checks only for facts a DOM test cannot observe, such as passive: false.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/diagram-canvas.test.ts` around lines 44 - 60, Replace
the behavioral source-text regexes in the “canvas wheel ownership” test with a
DOM-level test that renders the canvas, dispatches a cancelable wheel event, and
asserts that the event is defaultPrevented and the canvas transform changes.
Retain only the regex assertion needed to verify the native listener uses
passive: false, and remove regex checks for callback structure or listener
attachment behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/components/diagram-canvas.tsx`:
- Around line 1572-1605: Update handleSafariGestureStart and
handleSafariGestureChange to reject gesture.scale values that are not finite or
are less than or equal to zero. Apply the positive-scale validation in both
handlers before storing the scale or calculating the scale ratio, while
preserving the existing guard behavior for invalid gestures.

In `@e2e-workspace-ux-validate.ts`:
- Around line 495-533: Add camera restoration to the end of
expectWheelGestureCameraControls: click the exact “Fit diagram” button using the
existing verifiedClick helper, then call waitForStableCanvasTransform with the
label. Keep this cleanup after all wheel assertions so subsequent checks start
from the fitted, stable camera state.

---

Nitpick comments:
In `@apps/web/src/components/diagram-canvas.test.ts`:
- Around line 44-60: Replace the behavioral source-text regexes in the “canvas
wheel ownership” test with a DOM-level test that renders the canvas, dispatches
a cancelable wheel event, and asserts that the event is defaultPrevented and the
canvas transform changes. Retain only the regex assertion needed to verify the
native listener uses passive: false, and remove regex checks for callback
structure or listener attachment behavior.

In `@apps/web/src/lib/canvas-wheel-gesture.test.ts`:
- Around line 45-48: Update the anchored-point assertion in the canvas wheel
gesture test to compare the computed x and y coordinates independently with
toBeCloseTo rather than using exact toEqual object equality. Preserve the
existing expected coordinate formulas and client values while allowing normal
floating-point rounding differences.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d58904d0-e8f0-4281-8009-fd88331ddf84

📥 Commits

Reviewing files that changed from the base of the PR and between 9aa844c and bf3cd96.

📒 Files selected for processing (6)
  • apps/web/src/app/globals.css
  • apps/web/src/components/diagram-canvas.test.ts
  • apps/web/src/components/diagram-canvas.tsx
  • apps/web/src/lib/canvas-wheel-gesture.test.ts
  • apps/web/src/lib/canvas-wheel-gesture.ts
  • e2e-workspace-ux-validate.ts

Comment on lines +1572 to +1605
const handleSafariGestureStart = useCallback((event: Event) => {
const container = containerRef.current;
const gesture = event as SafariGestureEvent;
if (!container || !canHandleCanvasWheel(event.target, container) || !Number.isFinite(gesture.scale)) {
return;
}

event.preventDefault();
safariPinchScaleRef.current = gesture.scale;
}, []);

const handleSafariGestureChange = useCallback((event: Event) => {
const container = containerRef.current;
const gesture = event as SafariGestureEvent;
const previousScale = safariPinchScaleRef.current;
if (!container || previousScale === null || !canHandleCanvasWheel(event.target, container) || !Number.isFinite(gesture.scale)) {
return;
}

event.preventDefault();
safariPinchScaleRef.current = gesture.scale;
const rect = container.getBoundingClientRect();
const client = {
x: Number.isFinite(gesture.clientX) ? gesture.clientX : rect.left + (rect.width / 2),
y: Number.isFinite(gesture.clientY) ? gesture.clientY : rect.top + (rect.height / 2),
};

setAnimateTransform(false);
setViewport((current) => applyCanvasWheelGesture(current, {
client,
kind: 'zoom',
scale: Math.pow(gesture.scale / previousScale, 0.4),
}, rect, MIN_ZOOM, MAX_ZOOM));
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject a non-positive Safari pinch scale.

handleSafariGestureStart accepts any finite gesture.scale, including 0. If previousScale is 0, then gesture.scale / previousScale is 0/0 = NaN or Infinity, and Math.pow(NaN, 0.4) is NaN. applyCanvasWheelGesture then produces NaN for zoom, panX, and panY, because clamp propagates NaN. The camera stays broken until a full remount, because every later gesture divides by the stored NaN scale. Require a positive scale in both handlers.

🛡️ Proposed fix
   const handleSafariGestureStart = useCallback((event: Event) => {
     const container = containerRef.current;
     const gesture = event as SafariGestureEvent;
-    if (!container || !canHandleCanvasWheel(event.target, container) || !Number.isFinite(gesture.scale)) {
+    if (!container || !canHandleCanvasWheel(event.target, container) || !(gesture.scale > 0) || !Number.isFinite(gesture.scale)) {
       return;
     }
@@
     const previousScale = safariPinchScaleRef.current;
-    if (!container || previousScale === null || !canHandleCanvasWheel(event.target, container) || !Number.isFinite(gesture.scale)) {
+    if (!container || previousScale === null || previousScale <= 0 || !canHandleCanvasWheel(event.target, container) || !(gesture.scale > 0) || !Number.isFinite(gesture.scale)) {
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSafariGestureStart = useCallback((event: Event) => {
const container = containerRef.current;
const gesture = event as SafariGestureEvent;
if (!container || !canHandleCanvasWheel(event.target, container) || !Number.isFinite(gesture.scale)) {
return;
}
event.preventDefault();
safariPinchScaleRef.current = gesture.scale;
}, []);
const handleSafariGestureChange = useCallback((event: Event) => {
const container = containerRef.current;
const gesture = event as SafariGestureEvent;
const previousScale = safariPinchScaleRef.current;
if (!container || previousScale === null || !canHandleCanvasWheel(event.target, container) || !Number.isFinite(gesture.scale)) {
return;
}
event.preventDefault();
safariPinchScaleRef.current = gesture.scale;
const rect = container.getBoundingClientRect();
const client = {
x: Number.isFinite(gesture.clientX) ? gesture.clientX : rect.left + (rect.width / 2),
y: Number.isFinite(gesture.clientY) ? gesture.clientY : rect.top + (rect.height / 2),
};
setAnimateTransform(false);
setViewport((current) => applyCanvasWheelGesture(current, {
client,
kind: 'zoom',
scale: Math.pow(gesture.scale / previousScale, 0.4),
}, rect, MIN_ZOOM, MAX_ZOOM));
}, []);
const handleSafariGestureStart = useCallback((event: Event) => {
const container = containerRef.current;
const gesture = event as SafariGestureEvent;
if (!container || !canHandleCanvasWheel(event.target, container) || !(gesture.scale > 0) || !Number.isFinite(gesture.scale)) {
return;
}
event.preventDefault();
safariPinchScaleRef.current = gesture.scale;
}, []);
const handleSafariGestureChange = useCallback((event: Event) => {
const container = containerRef.current;
const gesture = event as SafariGestureEvent;
const previousScale = safariPinchScaleRef.current;
if (!container || previousScale === null || previousScale <= 0 || !canHandleCanvasWheel(event.target, container) || !(gesture.scale > 0) || !Number.isFinite(gesture.scale)) {
return;
}
event.preventDefault();
safariPinchScaleRef.current = gesture.scale;
const rect = container.getBoundingClientRect();
const client = {
x: Number.isFinite(gesture.clientX) ? gesture.clientX : rect.left + (rect.width / 2),
y: Number.isFinite(gesture.clientY) ? gesture.clientY : rect.top + (rect.height / 2),
};
setAnimateTransform(false);
setViewport((current) => applyCanvasWheelGesture(current, {
client,
kind: 'zoom',
scale: Math.pow(gesture.scale / previousScale, 0.4),
}, rect, MIN_ZOOM, MAX_ZOOM));
}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/diagram-canvas.tsx` around lines 1572 - 1605, Update
handleSafariGestureStart and handleSafariGestureChange to reject gesture.scale
values that are not finite or are less than or equal to zero. Apply the
positive-scale validation in both handlers before storing the scale or
calculating the scale ratio, while preserving the existing guard behavior for
invalid gestures.

Comment on lines +495 to +533
async function expectWheelGestureCameraControls(page: Page, label: string, renderer: 'flowchart' | 'generic'): Promise<void> {
const canvas = page.getByTestId('diagram-canvas');
await canvas.waitFor({ state: 'visible', timeout: 15_000 });
await verifiedClick(page, page.getByRole('button', { name: 'Zoom out', exact: true }), `${label} wheel headroom`);
await waitForStableCanvasTransform(page, `${label} wheel headroom`);
const beforePan = await readCanvasCameraSnapshot(page, `${label} wheel-pan baseline`);
const normalWheel = await dispatchTrustedCanvasWheel(page, label, renderer, { ctrlKey: false, deltaX: 18, deltaY: 32 });
const afterPan = await readCanvasCameraSnapshot(page, `${label} wheel-pan result`);
assert(normalWheel.isTrusted && normalWheel.defaultPrevented, `${label} normal trusted wheel was not owned by the canvas.`);
assert(afterPan.zoom === beforePan.zoom && (afterPan.panX !== beforePan.panX || afterPan.panY !== beforePan.panY),
`${label} ordinary two-finger scroll did not pan without zooming: ${JSON.stringify({ beforePan, afterPan })}.`);

const beforeZoom = afterPan;
const browserBeforeZoom = await page.evaluate(() => ({
browserScale: window.visualViewport?.scale ?? 1,
devicePixelRatio: window.devicePixelRatio,
}));
const pinch = await dispatchTrustedCanvasWheel(page, label, renderer, { ctrlKey: true, deltaX: 0, deltaY: -20 });
const afterZoom = await readCanvasCameraSnapshot(page, `${label} ctrl-wheel result`);
const canvasBounds = await canvas.boundingBox();
assert(canvasBounds, `${label} lost its canvas bounds during ctrl-wheel zoom.`);
const anchoredCanvasPoint = {
x: (pinch.point.x - canvasBounds.x - beforeZoom.panX) / beforeZoom.zoom,
y: (pinch.point.y - canvasBounds.y - beforeZoom.panY) / beforeZoom.zoom,
};
const anchoredScreenPoint = {
x: afterZoom.panX + (anchoredCanvasPoint.x * afterZoom.zoom),
y: afterZoom.panY + (anchoredCanvasPoint.y * afterZoom.zoom),
};
assert(pinch.isTrusted && pinch.defaultPrevented, `${label} trusted ctrl-wheel pinch was not cancelled before browser zoom.`);
assert(afterZoom.zoom > beforeZoom.zoom && afterZoom.zoom < beforeZoom.zoom * 1.1,
`${label} ctrl-wheel zoom was missing or too sensitive: ${JSON.stringify({ beforeZoom, afterZoom })}.`);
assert(pinch.browserScale === 1 && browserBeforeZoom.browserScale === 1
&& pinch.devicePixelRatio === browserBeforeZoom.devicePixelRatio,
`${label} ctrl-wheel changed the browser viewport scale: ${JSON.stringify({ browserBeforeZoom, pinch })}.`);
assert(Math.abs(anchoredScreenPoint.x - (pinch.point.x - canvasBounds.x)) < 0.1
&& Math.abs(anchoredScreenPoint.y - (pinch.point.y - canvasBounds.y)) < 0.1,
`${label} ctrl-wheel zoom did not keep its blank-pane cursor point anchored: ${JSON.stringify({ afterZoom, anchoredScreenPoint, pinch })}.`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore the camera before the next check.

expectWheelGestureCameraControls clicks "Zoom out", then pans and zooms with wheel input. It does not restore the camera. The nearby helper expectCanvasCameraControlHitTargets ends with a "Fit diagram" click for this reason (Line 392). Later checks then run against a panned and scaled camera. For example, expectBlankCanvasClickClearsSelection clicks .mermaid-flow-node first, which can sit outside the visible canvas after the pan, and allowedCanvasGesturePoints samples ratio-based points that may no longer be blank. Add a "Fit diagram" click plus waitForStableCanvasTransform at the end of the helper.

🛡️ Proposed fix
   assert(Math.abs(anchoredScreenPoint.x - (pinch.point.x - canvasBounds.x)) < 0.1
     && Math.abs(anchoredScreenPoint.y - (pinch.point.y - canvasBounds.y)) < 0.1,
   `${label} ctrl-wheel zoom did not keep its blank-pane cursor point anchored: ${JSON.stringify({ afterZoom, anchoredScreenPoint, pinch })}.`);
+  await verifiedClick(page, page.getByRole('button', { name: 'Fit diagram', exact: true }), `${label} wheel camera reset`);
+  await waitForStableCanvasTransform(page, `${label} wheel camera reset`);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function expectWheelGestureCameraControls(page: Page, label: string, renderer: 'flowchart' | 'generic'): Promise<void> {
const canvas = page.getByTestId('diagram-canvas');
await canvas.waitFor({ state: 'visible', timeout: 15_000 });
await verifiedClick(page, page.getByRole('button', { name: 'Zoom out', exact: true }), `${label} wheel headroom`);
await waitForStableCanvasTransform(page, `${label} wheel headroom`);
const beforePan = await readCanvasCameraSnapshot(page, `${label} wheel-pan baseline`);
const normalWheel = await dispatchTrustedCanvasWheel(page, label, renderer, { ctrlKey: false, deltaX: 18, deltaY: 32 });
const afterPan = await readCanvasCameraSnapshot(page, `${label} wheel-pan result`);
assert(normalWheel.isTrusted && normalWheel.defaultPrevented, `${label} normal trusted wheel was not owned by the canvas.`);
assert(afterPan.zoom === beforePan.zoom && (afterPan.panX !== beforePan.panX || afterPan.panY !== beforePan.panY),
`${label} ordinary two-finger scroll did not pan without zooming: ${JSON.stringify({ beforePan, afterPan })}.`);
const beforeZoom = afterPan;
const browserBeforeZoom = await page.evaluate(() => ({
browserScale: window.visualViewport?.scale ?? 1,
devicePixelRatio: window.devicePixelRatio,
}));
const pinch = await dispatchTrustedCanvasWheel(page, label, renderer, { ctrlKey: true, deltaX: 0, deltaY: -20 });
const afterZoom = await readCanvasCameraSnapshot(page, `${label} ctrl-wheel result`);
const canvasBounds = await canvas.boundingBox();
assert(canvasBounds, `${label} lost its canvas bounds during ctrl-wheel zoom.`);
const anchoredCanvasPoint = {
x: (pinch.point.x - canvasBounds.x - beforeZoom.panX) / beforeZoom.zoom,
y: (pinch.point.y - canvasBounds.y - beforeZoom.panY) / beforeZoom.zoom,
};
const anchoredScreenPoint = {
x: afterZoom.panX + (anchoredCanvasPoint.x * afterZoom.zoom),
y: afterZoom.panY + (anchoredCanvasPoint.y * afterZoom.zoom),
};
assert(pinch.isTrusted && pinch.defaultPrevented, `${label} trusted ctrl-wheel pinch was not cancelled before browser zoom.`);
assert(afterZoom.zoom > beforeZoom.zoom && afterZoom.zoom < beforeZoom.zoom * 1.1,
`${label} ctrl-wheel zoom was missing or too sensitive: ${JSON.stringify({ beforeZoom, afterZoom })}.`);
assert(pinch.browserScale === 1 && browserBeforeZoom.browserScale === 1
&& pinch.devicePixelRatio === browserBeforeZoom.devicePixelRatio,
`${label} ctrl-wheel changed the browser viewport scale: ${JSON.stringify({ browserBeforeZoom, pinch })}.`);
assert(Math.abs(anchoredScreenPoint.x - (pinch.point.x - canvasBounds.x)) < 0.1
&& Math.abs(anchoredScreenPoint.y - (pinch.point.y - canvasBounds.y)) < 0.1,
`${label} ctrl-wheel zoom did not keep its blank-pane cursor point anchored: ${JSON.stringify({ afterZoom, anchoredScreenPoint, pinch })}.`);
}
async function expectWheelGestureCameraControls(page: Page, label: string, renderer: 'flowchart' | 'generic'): Promise<void> {
const canvas = page.getByTestId('diagram-canvas');
await canvas.waitFor({ state: 'visible', timeout: 15_000 });
await verifiedClick(page, page.getByRole('button', { name: 'Zoom out', exact: true }), `${label} wheel headroom`);
await waitForStableCanvasTransform(page, `${label} wheel headroom`);
const beforePan = await readCanvasCameraSnapshot(page, `${label} wheel-pan baseline`);
const normalWheel = await dispatchTrustedCanvasWheel(page, label, renderer, { ctrlKey: false, deltaX: 18, deltaY: 32 });
const afterPan = await readCanvasCameraSnapshot(page, `${label} wheel-pan result`);
assert(normalWheel.isTrusted && normalWheel.defaultPrevented, `${label} normal trusted wheel was not owned by the canvas.`);
assert(afterPan.zoom === beforePan.zoom && (afterPan.panX !== beforePan.panX || afterPan.panY !== beforePan.panY),
`${label} ordinary two-finger scroll did not pan without zooming: ${JSON.stringify({ beforePan, afterPan })}.`);
const beforeZoom = afterPan;
const browserBeforeZoom = await page.evaluate(() => ({
browserScale: window.visualViewport?.scale ?? 1,
devicePixelRatio: window.devicePixelRatio,
}));
const pinch = await dispatchTrustedCanvasWheel(page, label, renderer, { ctrlKey: true, deltaX: 0, deltaY: -20 });
const afterZoom = await readCanvasCameraSnapshot(page, `${label} ctrl-wheel result`);
const canvasBounds = await canvas.boundingBox();
assert(canvasBounds, `${label} lost its canvas bounds during ctrl-wheel zoom.`);
const anchoredCanvasPoint = {
x: (pinch.point.x - canvasBounds.x - beforeZoom.panX) / beforeZoom.zoom,
y: (pinch.point.y - canvasBounds.y - beforeZoom.panY) / beforeZoom.zoom,
};
const anchoredScreenPoint = {
x: afterZoom.panX + (anchoredCanvasPoint.x * afterZoom.zoom),
y: afterZoom.panY + (anchoredCanvasPoint.y * afterZoom.zoom),
};
assert(pinch.isTrusted && pinch.defaultPrevented, `${label} trusted ctrl-wheel pinch was not cancelled before browser zoom.`);
assert(afterZoom.zoom > beforeZoom.zoom && afterZoom.zoom < beforeZoom.zoom * 1.1,
`${label} ctrl-wheel zoom was missing or too sensitive: ${JSON.stringify({ beforeZoom, afterZoom })}.`);
assert(pinch.browserScale === 1 && browserBeforeZoom.browserScale === 1
&& pinch.devicePixelRatio === browserBeforeZoom.devicePixelRatio,
`${label} ctrl-wheel changed the browser viewport scale: ${JSON.stringify({ browserBeforeZoom, pinch })}.`);
assert(Math.abs(anchoredScreenPoint.x - (pinch.point.x - canvasBounds.x)) < 0.1
&& Math.abs(anchoredScreenPoint.y - (pinch.point.y - canvasBounds.y)) < 0.1,
`${label} ctrl-wheel zoom did not keep its blank-pane cursor point anchored: ${JSON.stringify({ afterZoom, anchoredScreenPoint, pinch })}.`);
await verifiedClick(page, page.getByRole('button', { name: 'Fit diagram', exact: true }), `${label} wheel camera reset`);
await waitForStableCanvasTransform(page, `${label} wheel camera reset`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-workspace-ux-validate.ts` around lines 495 - 533, Add camera restoration
to the end of expectWheelGestureCameraControls: click the exact “Fit diagram”
button using the existing verifiedClick helper, then call
waitForStableCanvasTransform with the label. Keep this cleanup after all wheel
assertions so subsequent checks start from the fitted, stable camera state.

@donovan-yohan
donovan-yohan force-pushed the codex/canvas-trackpad-gestures branch from bf3cd96 to 9879664 Compare August 10, 2026 16:09
@donovan-yohan
donovan-yohan merged commit 38dc287 into main Aug 10, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — 9879664e Deployed Aug 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant