Repository navigation
Description
On a touch screen, dragging a finger does not move the Breakout Task paddle. The paddle responds only to mousemove and the arrow keys. A finger dragged across a touch screen produces touch and pointer events but no mousemove: browsers send compatibility mouse events only for a tap, after the finger lifts. Tablets and phones have no arrow keys either. So the paddle stays put while the ball falls, and the only way to move it is to tap where it should go, one jump per tap. The canvas also has no touch-action: none, so the browser treats a drag over it as a pan gesture. A subject who does a remote assignment on a tablet or phone through the gateway will usually lose all three lives and be recorded as win: false. That outcome measures the input device, not the subject.
Where
packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:94-99 and :214-216:
const mouseMoveHandler = (e: MouseEvent) => {
const relativeX = e.clientX - canvas.offsetLeft;
if (relativeX > 0 && relativeX < width) {
paddleX = relativeX - paddleWidth / 2;
}
};
// ...
document.addEventListener('keydown', keyDownHandler, false);
document.addEventListener('keyup', keyUpHandler, false);
document.addEventListener('mousemove', mouseMoveHandler, false);The canvas rule in packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/styles.css:6-10 sets no touch-action.
Reproduce
- Open the Breakout Task on a tablet or phone and press Start.
- Put a finger on the paddle and drag it left or right.
Actual: the paddle does not move during the drag. The ball falls past it and a life is lost.
Expected: the paddle follows the finger, the same way it follows the mouse.
Tests
packages/instrument-library/src/__tests__/DNP_BREAKOUT_TASK.test.ts: it('should center the paddle on a touch pointer inside the canvas, so the task is playable on a tablet'). Dispatch new PointerEvent('pointermove', { clientX: 100, pointerType: 'touch' }), step one frame, and expect lastPaddleX() to be 62.5, matching the existing mouse case.
There is no e2e test for this. The paddle exists only as pixels on a canvas, so Playwright has no element whose position it can assert. The unit test covers the input handling.
Suggested fix
Listen for pointermove instead of mousemove. Pointer events cover mouse, pen and touch, with the same clientX. Add touch-action: none to the canvas rule in styles.css, so the browser hands the drag to the page instead of treating it as a pan.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing setstouch-action, neither the task'sstyles.cssnorpackages/runtime-internal/src/interactive/iframe.html.Changes to the suggested fix
touch-action: noneon#root, not oncanvas. The browser intersects thetouch-actionof the touched element and its ancestors (MDN), so a rule on#rootcovers the canvas as well. A rule oncanvasalone is not enough. The paddle is drawn flush with the bottom edge of the canvas (index.ts:133), so a finger aimed at it can land below the canvas, on#root. A subject who keeps the finger under the paddle so it stays visible is always there. That drag is still taken as a pan, the browser sendspointercancel, andpointermovestops. The listener is ondocumentand reads onlyclientX, so it would otherwise steer fine from below the canvas.pointerdownas well. Today a tap places the paddle only through the compatibilitymousemove. A finger that does not move fires nopointermove, so withpointermovealone a tap stops working.isPrimaryis false. Only the primary pointer produces compatibility mouse events (Pointer Events), so today a second finger cannot move the paddle. Apointermovelistener ondocumentwould let it. The mouse pointer is primary, so mouse play does not change.Tests
moveMouse(DNP_BREAKOUT_TASK.test.ts:87-89) dispatchesMouseEvent('mousemove'), which the new listener never sees. Two game tests also use it throughfollowBallWithMouse. Change it to dispatchnew PointerEvent('pointermove', { clientX, isPrimary: true, pointerType: 'mouse' }).isPrimarydefaults tofalsein the spec and in happy-dom 20.11.15, so the touch case in the issue needsisPrimary: true.pointerdowncase. Add a non-primarypointermovecase that leaves the paddle at202.5; the new branch needs it for 100% coverage.An e2e test is possible, and it is the only test that can see the CSS: the unit tests load
styles.cssas an empty module. The demo seed creates the Breakout Task (apps/api/src/demo/demo.service.ts:72). Intesting/src/specs/instrument-completion.spec.ts, undertest.use({ hasTouch: true }):iframe[name="interactive-instrument"].#rootin that frame hastouch-action: none(toHaveCSS).page.context().newCDPSession(page)andInput.dispatchTouchEvent:touchStarton#rootjust below the canvas, a fewtouchMoves, thentouchEnd. Take the coordinates from the canvas'sboundingBox(). End away from the center but well inside the canvas, so that Breakout Task paddle can move past the canvas walls #1640's clamp does not apply. In Playwright 1.63Touchscreenhas onlytap(), and its Chromium implementation sends this same CDP call. CDP is Chromium-only, so do not tag the test@smoke.getImageData, scaled bycanvas.width / canvas.clientWidth. Assert that the run of#0095DDpixels is centered under the finger. The ball is first served fromy = height - 30moving up, so it is nowhere near that row in the first second.Elsewhere
Interactive-With-Vanillaexample has the same handler (index.ts:53,:69-74) and the samecanvasrule. It appends the canvas directly tobody, so itstouch-action: nonegoes onhtml, body.getScale(index.ts:16-26) never returns less than 1, so the canvas is at least 480×320 CSS px. On a phone in portrait it is wider than the frame, andjustify-content: centeron#rootputs its left edge out of reach. That needs its own issue.pointermoveandpointerdowninstead ofmousemove.Triage note written with Claude Code. Code references point at
main@d86740e.