diff --git a/plugins/leaf/skills/leaf/assets/leaf.js b/plugins/leaf/skills/leaf/assets/leaf.js index e9bc629a6..1774e0b39 100644 --- a/plugins/leaf/skills/leaf/assets/leaf.js +++ b/plugins/leaf/skills/leaf/assets/leaf.js @@ -10016,9 +10016,26 @@ function renderVersions(state) { showNews(latestChip, behind); if (behind) latestChip.textContent = `New version available → open v${latestVersion}`; } +/** The reader's hand on a widget, in the layer's own word: a drag the log has not taken + * yet. The class is half of `unaccountedGesture` below, so taking it up or putting it + * down moves core's `z` row — a row no widget declares, and therefore the one no widget + * would think to repaint. Both edges of a pointer drag went unpainted for exactly that + * reason, and on a quiet board the line went on offering `undo` for as long as the + * reader held the card, over a press the dispatcher was already refusing. So the paint + * is owed here, where the class is written, rather than by whoever remembers. + * + * Coalesced to a frame like every paint, which is what lets it stand for everything else + * the same gesture moved: the widget's own rows where the grab is a press on an + * already-focused grip and no focus event fires, and a send the drop states after this + * returns — so a drop that sends still reads as a gesture the log has not taken. + */ +export const dragging = (el, on) => { + el.classList.toggle("lf-dragging", on); + paintKeys(); +}; // A gesture of the reader's that the page has not accounted for in a log read, asked of // the layer's own signals rather than of any widget by name: a drag wears .lf-dragging -// (the module sets it), every unresolved browser event is in the outbox, and an undo +// (dragging, above), every unresolved browser event is in the outbox, and an undo // in flight is its own — it is tracked separately because the walk itself cannot be // offered again while its event is being answered. // Two questions want the answer, diff --git a/plugins/leaf/skills/leaf/bundled/widgets/lf-board.js b/plugins/leaf/skills/leaf/bundled/widgets/lf-board.js index d5fb04883..ce4b057fe 100644 --- a/plugins/leaf/skills/leaf/bundled/widgets/lf-board.js +++ b/plugins/leaf/skills/leaf/bundled/widgets/lf-board.js @@ -33,7 +33,7 @@ import { keys, labelOf, saying, - paintKeys, + dragging, motion, pageScroller, PRESS, @@ -237,9 +237,8 @@ customElements.define( const cards = this.#cards(from); const index = cards.indexOf(card); this.#grabbed = { card, grip, from, index }; - this.classList.add("lf-dragging"); + dragging(this, true); card.classList.add("lf-lift"); - paintKeys(); // a grab is a press on an already-focused grip, so no focus event fires // Where the card starts, in the idiom every arrow step announces — a reader about to // move it needs the position the moves count from. announce( @@ -296,8 +295,7 @@ customElements.define( #release() { this.#grabbed.card.classList.remove("lf-lift"); this.#grabbed = null; - this.classList.remove("lf-dragging"); - paintKeys(); + dragging(this, false); } // The one writer of "card X sits at index i among column C's cards": arrow steps, @@ -375,10 +373,13 @@ customElements.define( this.#release(); } else this.#cancel(); } - this.classList.add("lf-dragging"); + dragging(this, true); }, onEnd: (evt) => { - this.classList.remove("lf-dragging"); + // Ahead of the branches below, because the one that returns early sends + // nothing: a card dropped where it was picked up puts the hand down with + // nothing following it to say so. + dragging(this, false); const sup = this.#superseded; this.#superseded = null; // The *draggable* indexes, which count cards; Sortable's plain diff --git a/tests/test_render.py b/tests/test_render.py index 72e5cae1e..9c5f8848b 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -625,6 +625,22 @@ def _traffic(page): return page.lf_traffic +def _painted_line(page): + """The key line as the gesture just made left it. + + `paintHere` coalesces to an animation frame, so a read taken straight after the + state moves reads the frame before the paint. Consuming the frame is what makes + the read once rather than a poll: the line repaints on its own often enough — a + version poll, a focus move, news arriving — that an assertion re-asking through + `expect` reports whichever later paint the page happened to make, and passes on a + gesture that painted nothing at all. + """ + page.evaluate( + "() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))" + ) + return page.locator(".lf-keyline").inner_text() + + def _until(page, fact, wanted): """Block until `fact` holds of the page's traffic. @@ -15305,6 +15321,62 @@ def hold(route): page.close() +def test_a_pointer_drag_stops_the_line_offering_the_press_it_refuses(browser, serve): + """`.lf-dragging` is half of the `z` liveness the runtime declares, and a pointer + drag is a whole gesture rather than a frame: the focus paint lands on the + mousedown, `fallbackTolerance` fires the drag's start after it, and on a quiet + board nothing repaints between the pick-up and the drop. So unpainted, the line + goes on offering `undo` for as long as the reader holds the card, over a press the + dispatcher is already refusing. The drop is the same gap read backwards: a card + put down where it was picked up takes the class off and returns before #send, so + there is no send downstream to paint in its place.""" + page, errors = open_page(browser, serve(BOARD_PAGE)) + grip = page.locator("#card-heater .lf-grip") + grip.focus() + for key in ["Enter", "ArrowRight", "Enter"]: + page.keyboard.press(key) + round_trip(page) + # The move carries a FLIP, and a box measured across it is a box the card has + # already left: the press lands on the grip at that instant and the pointer is + # somewhere else by the mousemove after it, so the drag never starts and the + # failure arrives as the assertion below timing out on a page nobody dragged. + page.wait_for_function( + "() => document.getElementById('card-heater').getAnimations().length === 0" + ) + expect(page.locator(".lf-keyline")).to_contain_text("undo") + sent = _traffic(page).sends + + # The card the keyboard move left the grip focused on, so the mousedown lands on + # an already-focused control and fires no focusin — the paint under test is the + # only one that could clear the offer. Held inside the column it now has to + # itself, so the drop reorders nothing and onEnd returns before #send. + box = grip.bounding_box() + start = ( + math.floor(box["x"] + box["width"] / 2), + math.floor(box["y"] + box["height"] / 2), + ) + page.mouse.move(*start) + page.mouse.down() + page.mouse.move(start[0], start[1] + 24, steps=8) # past fallbackTolerance + page.wait_for_selector("lf-board.lf-dragging") # the gesture is live in the page + # Read once, on the frame the paint coalesces to, rather than through `expect`: + # a poll two seconds out repaints the line whatever this drag did, so an + # assertion that re-asks passes on the poll and says nothing about the edge. + assert "undo" not in _painted_line(page), ( + "the line offered a press the dispatcher refuses for the length of a drag" + ) + + page.mouse.up() + assert page.locator("lf-board.lf-dragging").count() == 0 + assert "undo" in _painted_line(page), ( + "the drop that sent nothing left the line refusing a press that is live" + ) + assert _traffic(page).sends == sent, "the drop that moved nothing sent a move" + expect(page.locator("#col-done #card-heater")).to_have_count(1) + assert errors == [] + page.close() + + def test_an_action_response_accounts_for_its_gesture_without_a_follow_up_poll( browser, serve ):