Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions scripts/record-demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 0 additions & 1 deletion skills/leaf/assets/runtime/conversation/acknowledgments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
12 changes: 10 additions & 2 deletions skills/leaf/assets/runtime/living-margin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions skills/leaf/assets/runtime/outbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions tests/render_cases_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions tests/test_render_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 5 additions & 6 deletions tests/test_render_margin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
70 changes: 70 additions & 0 deletions tests/test_render_outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
resized,
round_trip,
stamp_page,
ticked,
told,
undo,
unfolded_button,
Expand Down Expand Up @@ -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."""
Expand Down
Loading