From 080d44e10c0943d84896c1324def3bc294476638 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 10 Jun 2026 11:39:24 -0400 Subject: [PATCH] =?UTF-8?q?seed-map=20rim=20placement=20rides=20the=20poin?= =?UTF-8?q?ter=20pair,=20not=20click=20=E2=80=94=20fixes=20Uranus=20never?= =?UTF-8?q?=20placing=20on=20touch=20screens=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS withholds a tap's synthesized click whenever a document-level mousemove/mouseover handler (the room page runs several tooltip machines) mutates the DOM — the wedge lit its :hover feedback but _onPickSign never fired on mobile staging - drawRim placement mode now binds pointerdown→pointerup w. a 10px slop radius (drag/scroll intent fires pointercancel or drifts past it); click stays only as the no-PointerEvent fallback — no double- pick on desktop - SkyWheelSpec R7 retold as the pure pointer tap (no click), + R7b compat-click no-double-pick + R7c drag-cancel; R9 tap-ified - seed map FT dispatches the pointer pair on the wedge [[project-voronoi-spec]] Co-Authored-By: Claude Fable 5 --- .../static/apps/gameboard/sky-wheel.js | 36 ++++++++++++-- .../test_game_room_seed_map.py | 12 +++-- src/static/tests/SkyWheelSpec.js | 48 ++++++++++++++++--- src/static_src/tests/SkyWheelSpec.js | 48 ++++++++++++++++--- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js index dc62131..bb7ca8a 100644 --- a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js @@ -1503,12 +1503,38 @@ const SkyWheel = (() => { .attr('class', 'nw-sign-group') .attr('data-sign-name', sign.name); // Placement mode (Set the Game Clock): the wedge is a placement target. + // Placement rides the POINTER timeline (down→up tap with a slop radius), + // NOT the click event: on iOS a tap's synthesized click is withheld + // whenever a document-level mousemove/mouseover handler (the room page + // runs several tooltip machines) mutates the DOM — the wedge lit its + // :hover feedback but never placed on touch screens. Pointer events fire + // on the raw input timeline, immune to that compat-mouse suppression; a + // scroll/drag intent either fires pointercancel (no up) or drifts past + // the slop radius. Mice fire the same pair, so desktop needs no click + // handler (binding one would double-pick). if (opts.placeable) { - slice.classed('nw-sign--placeable', true).style('cursor', 'pointer') - .on('click', function (event) { - event.stopPropagation(); - if (opts.onPickSign) opts.onPickSign(sign.name); - }); + slice.classed('nw-sign--placeable', true).style('cursor', 'pointer'); + const pick = function (event) { + event.stopPropagation(); + if (opts.onPickSign) opts.onPickSign(sign.name); + }; + if (window.PointerEvent) { + let downX = 0, downY = 0, downId = null; + slice + .on('pointerdown', function (event) { + downId = event.pointerId; + downX = event.clientX; + downY = event.clientY; + }) + .on('pointerup', function (event) { + if (downId === null || event.pointerId !== downId) return; + downId = null; + if (Math.hypot(event.clientX - downX, event.clientY - downY) > 10) return; + pick(event); + }); + } else { + slice.on('click', pick); + } } slice.append('path') .attr('transform', `translate(${cx},${cy})`) diff --git a/src/functional_tests/test_game_room_seed_map.py b/src/functional_tests/test_game_room_seed_map.py index a2c6494..cb62e28 100644 --- a/src/functional_tests/test_game_room_seed_map.py +++ b/src/functional_tests/test_game_room_seed_map.py @@ -286,10 +286,16 @@ class SeedMapClockTest(FunctionalTest): )) self.assertEqual(self._uranus_count(), 0) - # Clicking the Aquarius sign wedge places Uranus there. SVG elements - # don't expose .click() in Firefox — dispatch the event (TDD skill). + # Tapping the Aquarius sign wedge places Uranus there. Placement rides + # the pointerdown→pointerup pair, not click (iOS withholds the tap- + # synthesized click — see SkyWheelSpec R7). SVG elements don't + # expose .click() in Firefox anyway — dispatch the events (TDD skill). self.browser.execute_script( - "arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))", + "var el = arguments[0];" + "['pointerdown', 'pointerup'].forEach(function (t) {" + " el.dispatchEvent(new PointerEvent(t," + " {bubbles: true, pointerId: 1, clientX: 10, clientY: 10}));" + "})", self.browser.find_element( By.CSS_SELECTOR, "#id_seed_wheel_svg [data-sign-name='Aquarius']"), ) diff --git a/src/static/tests/SkyWheelSpec.js b/src/static/tests/SkyWheelSpec.js index 3d269b8..4dbcff4 100644 --- a/src/static/tests/SkyWheelSpec.js +++ b/src/static/tests/SkyWheelSpec.js @@ -1148,26 +1148,63 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { // ── Placement mode (Set the Game Clock ritual) ────────────────────────── // drawRim(svg, data, {placeable, onPickSign}) turns the sign wedges into - // placement targets: the gamer whose turn it is clicks a sign to place the + // placement targets: the gamer whose turn it is taps a sign to place the // active planet there. Still singleton-pure — no SkyWheel module writes. + // + // Placement rides the POINTER timeline (pointerdown→pointerup tap), never + // the click event: on iOS a tap's synthesized click is WITHHELD whenever a + // document-level mousemove/mouseover handler (the room page runs several + // tooltip machines) mutates the DOM — the wedge lit its :hover feedback + // but Uranus never placed on touch screens. - it("R7: placement mode makes the sign wedges clickable and reports the picked sign", () => { + // A tap: down + up on the wedge, no wander — and crucially NO click event, + // exactly what a suppressed-click mobile tap delivers. + function tap(el, opts) { + opts = opts || {}; + const downX = opts.downX === undefined ? 100 : opts.downX; + const upX = opts.upX === undefined ? downX : opts.upX; + el.dispatchEvent(new PointerEvent("pointerdown", + { pointerId: 7, clientX: downX, clientY: 100, bubbles: true })); + el.dispatchEvent(new PointerEvent("pointerup", + { pointerId: 7, clientX: upX, clientY: 100, bubbles: true })); + } + + it("R7: placement mode reports the tapped sign from the pointer pair alone (no click)", () => { let picked = null; SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } }); const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']"); expect(aqua.classList.contains("nw-sign--placeable")).toBe(true); - aqua.dispatchEvent(new MouseEvent("click", { bubbles: true })); + tap(aqua); expect(picked).toBe("Aquarius"); }); + it("R7b: a desktop click's compat event after the tap does not double-pick", () => { + let picks = 0; + SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => { picks++; } }); + + const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']"); + tap(aqua); + // A real mouse click follows its pointer pair with a click event. + aqua.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(picks).toBe(1); + }); + + it("R7c: a drag across the wedge (down→up beyond the slop radius) does not place", () => { + let picked = null; + SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } }); + + tap(rimSvg.querySelector("[data-sign-name='Aquarius']"), { downX: 100, upX: 140 }); + expect(picked).toBeNull(); + }); + it("R8: without placement opts the sign wedges stay inert (no placeable class)", () => { SkyWheel.drawRim(rimSvg, ROOM_SKY); const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']"); expect(aqua.classList.contains("nw-sign--placeable")).toBe(false); }); - it("R9: a placement click never touches the interactive singleton", () => { + it("R9: a placement tap never touches the interactive singleton", () => { skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); skySvg.setAttribute("id", "id_sky_svg"); skySvg.style.width = "400px"; @@ -1180,8 +1217,7 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { SkyWheel.draw(skySvg, CONJUNCTION_CHART); SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => {} }); - rimSvg.querySelector("[data-sign-name='Aries']") - .dispatchEvent(new MouseEvent("click", { bubbles: true })); + tap(rimSvg.querySelector("[data-sign-name='Aries']")); // The interactive wheel's tooltip controls survive; no sign locked active. expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull(); diff --git a/src/static_src/tests/SkyWheelSpec.js b/src/static_src/tests/SkyWheelSpec.js index 3d269b8..4dbcff4 100644 --- a/src/static_src/tests/SkyWheelSpec.js +++ b/src/static_src/tests/SkyWheelSpec.js @@ -1148,26 +1148,63 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { // ── Placement mode (Set the Game Clock ritual) ────────────────────────── // drawRim(svg, data, {placeable, onPickSign}) turns the sign wedges into - // placement targets: the gamer whose turn it is clicks a sign to place the + // placement targets: the gamer whose turn it is taps a sign to place the // active planet there. Still singleton-pure — no SkyWheel module writes. + // + // Placement rides the POINTER timeline (pointerdown→pointerup tap), never + // the click event: on iOS a tap's synthesized click is WITHHELD whenever a + // document-level mousemove/mouseover handler (the room page runs several + // tooltip machines) mutates the DOM — the wedge lit its :hover feedback + // but Uranus never placed on touch screens. - it("R7: placement mode makes the sign wedges clickable and reports the picked sign", () => { + // A tap: down + up on the wedge, no wander — and crucially NO click event, + // exactly what a suppressed-click mobile tap delivers. + function tap(el, opts) { + opts = opts || {}; + const downX = opts.downX === undefined ? 100 : opts.downX; + const upX = opts.upX === undefined ? downX : opts.upX; + el.dispatchEvent(new PointerEvent("pointerdown", + { pointerId: 7, clientX: downX, clientY: 100, bubbles: true })); + el.dispatchEvent(new PointerEvent("pointerup", + { pointerId: 7, clientX: upX, clientY: 100, bubbles: true })); + } + + it("R7: placement mode reports the tapped sign from the pointer pair alone (no click)", () => { let picked = null; SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } }); const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']"); expect(aqua.classList.contains("nw-sign--placeable")).toBe(true); - aqua.dispatchEvent(new MouseEvent("click", { bubbles: true })); + tap(aqua); expect(picked).toBe("Aquarius"); }); + it("R7b: a desktop click's compat event after the tap does not double-pick", () => { + let picks = 0; + SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => { picks++; } }); + + const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']"); + tap(aqua); + // A real mouse click follows its pointer pair with a click event. + aqua.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(picks).toBe(1); + }); + + it("R7c: a drag across the wedge (down→up beyond the slop radius) does not place", () => { + let picked = null; + SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } }); + + tap(rimSvg.querySelector("[data-sign-name='Aquarius']"), { downX: 100, upX: 140 }); + expect(picked).toBeNull(); + }); + it("R8: without placement opts the sign wedges stay inert (no placeable class)", () => { SkyWheel.drawRim(rimSvg, ROOM_SKY); const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']"); expect(aqua.classList.contains("nw-sign--placeable")).toBe(false); }); - it("R9: a placement click never touches the interactive singleton", () => { + it("R9: a placement tap never touches the interactive singleton", () => { skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); skySvg.setAttribute("id", "id_sky_svg"); skySvg.style.width = "400px"; @@ -1180,8 +1217,7 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { SkyWheel.draw(skySvg, CONJUNCTION_CHART); SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => {} }); - rimSvg.querySelector("[data-sign-name='Aries']") - .dispatchEvent(new MouseEvent("click", { bubbles: true })); + tap(rimSvg.querySelector("[data-sign-name='Aries']")); // The interactive wheel's tooltip controls survive; no sign locked active. expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();