diff --git a/scripts/record-demo.py b/scripts/record-demo.py index 2ad3a375b..271e56080 100755 --- a/scripts/record-demo.py +++ b/scripts/record-demo.py @@ -273,16 +273,20 @@ def shot(duration: int) -> None: shot(1600) select_text(page, "#p2", "Backfill history") - page.locator(".lf-fab").click() - page.locator(".lf-composer textarea").fill("Can the backfill stay online?") + # The selection raises the response bar with its field already open and focused, + # so the demo types into it directly and sends with Enter. The Comment button the + # bar used to show is now one Tab away, and the composer around the field draws + # nothing of its own. + field = page.locator(".lf-fab-input") + field.fill("Can the backfill stay online?") page.wait_for_function( - """() => document.querySelector('.lf-composer').style.display === 'block' + """() => document.querySelector('.lf-composer').style.display === 'contents' && (CSS.highlights.get('lf-pending')?.size ?? 0) > 0 && document.getElementById('lf-composer-quote').classList.contains('lf-unseen')""" ) shot(2300) - page.locator(".lf-composer").get_by_role("button", name="Comment").click() + field.press("Enter") page.wait_for_selector(".lf-margin-thread") shot(1500) page.locator(".lf-threads-toggle").click() diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index 0f1d8c902..f10dd443e 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -650,8 +650,13 @@ connected before the first state and one constructed by a later thread reconcile `lf-actions` fires after a complete state has reconciled, including a read whose event list did not grow and the heartbeat's re-application of the state the page -already holds. This lets a module refresh elapsed time and retry a render -deferred by live input without owning a timer or a second event cursor. +already holds. The outbox fires it too, for the reconciliation it performs on an +answer of its own — a refused action, or a read event — which withdraws or +settles a winner without applying a state. Every pass that reconciles is +therefore heard through this one event, which is what keeps a surface reading the +projection rather than the DOM current with a withdrawal. It also lets a module +refresh elapsed time and retry a render deferred by live input without owning a +timer or a second event cursor. Callbacks must render from the sequence they receive and return their cleanup function from `watchActions` or `watchUpdates` when their element disconnects. diff --git a/skills/leaf/assets/runtime/conversation/acknowledgments.js b/skills/leaf/assets/runtime/conversation/acknowledgments.js index 509736ac6..e1ccdc6ad 100644 --- a/skills/leaf/assets/runtime/conversation/acknowledgments.js +++ b/skills/leaf/assets/runtime/conversation/acknowledgments.js @@ -120,7 +120,6 @@ export function createAcknowledgments(dependencies) { } for (const line of pageQueryAll(".lf-receipt")) if (!wanted.has(line)) line.remove(); - document.dispatchEvent(new CustomEvent("lf-acknowledgments")); } return { paintAcknowledgments }; diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js index 53ab27ff7..ea1c9b886 100644 --- a/skills/leaf/assets/runtime/living-margin.js +++ b/skills/leaf/assets/runtime/living-margin.js @@ -1738,12 +1738,20 @@ export function createLivingMargin(dependencies) { syncThreadRelation(reading, reading.lfChoice?.kind === "comment"); paintKeys(); }); - document.addEventListener("lf-actions", render); // TODO(2026-08-31): Reconcile this provisional acknowledgment-to-Button adapter // with the in-flight Target Button implementation before their combined changes // land. The canonical acknowledgment projection and page-edge placement remain the // contract; only this presentation seam should follow the final Button API. - document.addEventListener("lf-acknowledgments", render); + // + // The row's acknowledgment face is read out of the state projection, so it follows the + // applied log on `lf-actions` rather than the receipt paint: every path that reconciles + // a complete state dispatches that once it has reconciled, and both of the paths that + // paint receipts sit inside one. A repaint driven from the paint instead ran inside the + // panel render the application performs *before* reconciliation, which is early enough + // to read a candidate the same read is about to reject — and it ran inside a dispatch, + // where the fault that candidate throws is reported as an uncaught page error rather + // than rejecting the read. + document.addEventListener("lf-actions", render); document.addEventListener("lf-answered", render); document.addEventListener("lf-comparison", render); offerListeners.add(render); diff --git a/skills/leaf/assets/runtime/outbox.js b/skills/leaf/assets/runtime/outbox.js index 5a8010482..eea4524dc 100644 --- a/skills/leaf/assets/runtime/outbox.js +++ b/skills/leaf/assets/runtime/outbox.js @@ -234,6 +234,12 @@ export function createOutbox(runtime, dependencies) { // contains their attempt, never merely by a response whose rendering failed. if (entry.rejected || entry.readEvent) { if (reconcileKnownState()) releaseProjectedOutbox(); + // Sequence consumers hear a reconciliation performed here the way they hear + // every other one. This one withdraws a refused winner from the projection, and + // a surface reading the projection rather than the DOM — the margin's row for + // that winner, acknowledgment face and all — would otherwise stand on the + // withdrawn state until the heartbeat's next tick. + document.dispatchEvent(new Event("lf-actions")); } // The list is an input to the key line and no focus/mouse event accompanies // either edge. Repaint before resolving the caller, whose own settlement may diff --git a/tests/render_cases_navigation.py b/tests/render_cases_navigation.py index 80b922c38..6602d6e5e 100644 --- a/tests/render_cases_navigation.py +++ b/tests/render_cases_navigation.py @@ -337,9 +337,15 @@ def wait_hovered(page, text): def card_body(page, says): """A point low on a comment's card, below the quote — where a reader's hand rests - while they read the comment, and where nothing presses.""" + while they read the comment, and where nothing presses. + + Low on the card *as the reader sees it*: the list scrolls, so the last card's own + bottom can sit below the scroller and behind the panel's foot. A point read off the + card's rect alone lands on the general box there, which hovers no card at all.""" box = page.locator(".lf-thread").filter(has_text=says).first.bounding_box() - return box["x"] + box["width"] / 2, box["y"] + box["height"] - 8 + seen = page.locator(".lf-threads").bounding_box() + bottom = min(box["y"] + box["height"], seen["y"] + seen["height"]) + return box["x"] + box["width"] / 2, bottom - 8 # Addressable links that start within one digit's width of each other: a run of footnote diff --git a/tests/test_render_conversations.py b/tests/test_render_conversations.py index 8cfd16f40..03d216f77 100644 --- a/tests/test_render_conversations.py +++ b/tests/test_render_conversations.py @@ -1750,12 +1750,18 @@ def test_a_coined_class_cannot_reach_the_chromes_rules(browser, serve): # Every one of these is worn by something the runtime puts inside the page rather than # inside its own container — or, for lf-address, on both sides of that line at once, # which is the same reason: a scoped rule cannot reach the copy in the page. Except the - # last, which is worn by nothing and is here for the other half of the sentence. lf-copy is the medium `version export` marks on the root, and the runtime - # names it under a negation to withhold the live page's scroller from a file that has - # no panel to scroll beside; a rule that dresses no element can leak onto none, and - # what the pin is for is the day one of these stops being either kind. + # first two, which document level names only to hold a rule off them and which are here + # for the other half of the sentence. lf-copy is the medium `version export` marks on + # the root, and the runtime names it under a negation to withhold the live page's + # scroller from a file that has no panel to scroll beside; a rule that dresses no + # element can leak onto none, and what the pin is for is the day one of these stops + # being either kind. assert {c for c in surface["global"] if c.startswith("lf-")} == { "lf-copy", + # The compact response field, named the same way: the general text box's rule + # excludes it at document level because the field takes its whole geometry from + # the response controls it shares a baseline with, inside the chrome's own scope. + "lf-fab-input", "lf-ui", # A native label can pass through an intermediate focus target. These project # the held control's focus until activation settles. diff --git a/tests/test_render_margin.py b/tests/test_render_margin.py index 4cc01726a..b41db13ca 100644 --- a/tests/test_render_margin.py +++ b/tests/test_render_margin.py @@ -706,13 +706,12 @@ def test_reaction_choices_and_their_receipt_share_an_unided_selected_block( bar = page.locator(".lf-fab-bar") expect(bar).to_be_visible() bar.locator(".lf-react-trigger").click() - item = page.locator(".lf-margin-item").filter( - has=page.locator('.lf-margin-more[aria-expanded="true"]') - ) - expect(item).to_have_count(1) - assert abs(item.bounding_box()["y"] - paragraph.bounding_box()["y"]) <= 6 + # The choices are the bar's own, raised on the selection where the reader is + # pointing: an anchored response opens in place rather than docking a row of + # options into the margin. What the margin holds for this block is the receipt. + expect(bar).to_have_class(re.compile(r"\blf-react-open\b")) - item.locator('.lf-margin-options .lf-react[data-token="ok"]').click() + bar.locator('.lf-react[data-token="ok"]').click() round_trip(page) sent = events_model.read_events(serve.page_dir)[-1] assert sent["anchor"]["section"] == "s-how" and sent["anchor"]["quote"] diff --git a/tests/test_render_outbox.py b/tests/test_render_outbox.py index df1c71d97..da11c3c31 100644 --- a/tests/test_render_outbox.py +++ b/tests/test_render_outbox.py @@ -36,6 +36,7 @@ resized, round_trip, stamp_page, + ticked, told, undo, unfolded_button, @@ -1427,6 +1428,75 @@ def test_a_refused_action_waits_for_a_live_gesture_before_reconciling(browser, s page.close() +# Sampled rather than waited for, because the tick repairs a stale margin two seconds +# later and an `expect` would take that for the pass under test. Both facts are read on +# every frame and every task from before the refusal is answered: the reconciliation and +# the render it tells consumers about are one turn, so no sample can fall between them, +# and a margin left behind is sampled hundreds of times before the heartbeat hides it. +CARD_AND_MARGIN_SAMPLES = """() => { + window.__lfMarginSamples = []; + const sample = () => { + window.__lfMarginSamples.push([ + Boolean(document.querySelector("#col-done #card-heater")), + Boolean(document.querySelector('.lf-margin-marker[data-lf-kinds~="outcome"]')), + ]); + requestAnimationFrame(sample); + }; + sample(); + window.__lfMarginInterval = setInterval(sample, 0); +}""" + + +def test_a_refused_action_withdraws_its_outcome_from_the_margin_in_one_pass( + browser, serve +): + """The margin reads the projection, so the outcome an optimistic winner raised + leaves with the winner. Reconciling a refusal is not a state application, and + `lf-actions` is the one thing every pass that reconciles tells its consumers + through; without it the page kept a ✓ Outcome for a move the log never took until + the heartbeat two seconds later. A widget that announces its own answer + (`lf-options` dispatches `lf-answered` for a rewound pick) covers this for its own + margin entry; a board move announces nothing.""" + page, errors = open_page(browser, serve(BOARD_PAGE)) + resized(page, 1440, 900) + held = [] + page.route("**/api/event", lambda route: held.append(route)) + heater = page.locator("#card-heater .lf-grip") + with page.expect_request("**/api/event"): + heater.focus() + for key in ["Enter", "ArrowRight", "Enter"]: + page.keyboard.press(key) + ticked(page) + outcome = page.locator('.lf-margin-marker[data-lf-kinds~="outcome"]') + expect(outcome).to_have_count(1) + + page.evaluate(CARD_AND_MARGIN_SAMPLES) + attempt = held[0].request.post_data_json["attempt"] + with page.expect_response(lambda response: "/api/event" in response.url): + held[0].fulfill( + status=400, + json={ + "ok": False, + "attempt": attempt, + "error": "refused before append", + "final": True, + }, + ) + page.wait_for_function('() => !document.querySelector("#col-done #card-heater")') + samples = page.evaluate("() => window.__lfMarginSamples") + page.evaluate("() => clearInterval(window.__lfMarginInterval)") + + assert [True, True] in samples, "the pending move never reached the margin" + assert [False, True] not in samples, ( + "the margin kept the outcome of a refused move after the board had taken it " + f"back: {samples}" + ) + expect(outcome).to_have_count(0) + assert actions(serve.page_dir) == [] + assert errors and all("400" in error for error in errors) + page.close() + + def test_a_lost_accepted_response_keeps_later_gestures_in_order(browser, serve): """The outbox retries an accepted gesture whose response was lost before it sends the next gesture. Both arrive once, in the order the reader made them."""