seed-map rim placement rides the pointer pair, not click — fixes Uranus never placing on touch screens — TDD

- 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 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-10 11:39:24 -04:00
parent 14afb108c0
commit 080d44e10c
4 changed files with 124 additions and 20 deletions

View File

@@ -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) {
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})`)

View File

@@ -286,10 +286,16 @@ class SeedMapClockTest(FunctionalTest):
))
self.assertEqual(self._uranus_count(), 0)
# Clicking the Aquarius sign wedge places Uranus there. SVG <g> 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 <g> 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']"),
)

View File

@@ -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();

View File

@@ -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();