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
45 changes: 26 additions & 19 deletions skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1153,21 +1153,23 @@ into the words as a `<mark>` (BAKE), the highlight registry being script state
no file can hold.

The bar a selection or keyboard-selected item raises is `.lf-fab-bar`: the durable,
compact `.lf-fab-input` followed by one response ellipsis. Naming a target opens and
focuses that field immediately; it grows in place and never transfers text into a
second composer card. Enter sends and Shift-Enter inserts a newline. Tab changes the
same bar into Comment, Suggest when the anchor is a quote, and the layer's reaction
tokens. `.lf-response-control` keeps the field and every choice on one baseline with
one type, border, and elevation; the bar keeps its DOM owner and accessible name while
its contents change. Comment restores the field and Suggest restores it in
replacement-text mode.

`showFab` places the bar; `openComposer` binds its field to the durable draft and focuses
it. `selectResponseTarget` does both for a keyboard item hint, and the ⌥ press uses
`focusTargetComment` on the same captured anchor. Automatic passage selection opens that
passage's own durable draft; these explicit Comment gestures carry unsent words onto the
new anchor. Submitted words still in flight remain owned by their original anchor, while
a later target starts clean and keeps focus. For a page
compact `.lf-fab-input` followed by one response ellipsis. An explicit item target opens
and focuses that field immediately. Selecting a passage opens the field without taking
focus or collapsing the browser selection; the reader can still copy the selection or use
its native context menu, then enter the field with Comment. The field grows in place and
never transfers text into a second composer card. Enter sends and Shift-Enter inserts a
newline. Tab changes the same bar into Comment, Suggest when the anchor is a quote, and
the layer's reaction tokens. `.lf-response-control` keeps the field and every choice on
one baseline with one type, border, and elevation; the bar keeps its DOM owner and
accessible name while its contents change. Comment restores the field and Suggest
restores it in replacement-text mode.

`showFab` places the bar; `openComposer` binds its field to the durable draft and takes the
focus decision. `selectResponseTarget` focuses it for a keyboard item hint, and the ⌥ press
uses `focusTargetComment` on the same captured anchor. Automatic passage selection opens
that passage's own durable draft without moving focus; explicit Comment gestures carry
unsent words onto the new anchor. Submitted words still in flight remain owned by their
original anchor, while a later target starts clean and keeps focus. For a page
target, `r` contributes Comment, Suggest where available, and the reaction Buttons to
that target's existing Button options. Those temporary Buttons borrow the cluster's room
and dock with it when necessary; they do not claim permanent rail width. A thread-local
Expand Down Expand Up @@ -1923,6 +1925,10 @@ it owns the press, and stops. A `native` row runs and stops the scope walk but
leaves that default intact. A focused widget may shadow a page key without either
scope naming the other.

Leaf must not block standard platform or browser shortcuts. A handler prevents a default
only after a Leaf command owns the complete modified press; secondary clicks and the
native context menu remain the browser's too.

`claims` lists platform keys a scope consumes even when no registered row answers
them. A text entry scope uses `takesLetters` and claims character keys plus the keys
that edit that specific control: Enter, deletion, caret movement, Home/End, and page
Expand Down Expand Up @@ -2562,12 +2568,13 @@ surface. Ordinary boxes send with `Mod+Enter`; the compact anchored composer pas
textarea growth through `field-sizing: content`. Script does not measure or set textarea
height.

The selection composer keeps its passage painted after focus moves into the textarea.
The selection composer keeps its passage painted after an explicit Comment gesture moves
focus into the textarea. Automatic passage selection leaves the native selection in place.
Its `.lf-composer` wrapper contributes state and draft machinery through
`display: contents`; only `.lf-fab-input` draws. `showComposer` states the whole visible
and focus outcome from `composerOpen`, `pendingAnchor`, and `fabAnchor`. Outside clicks
and Escape hide without discarding words. A successful send or an explicit draft close
discards the local record.
outcome from `composerOpen`, `pendingAnchor`, and `fabAnchor`; `openComposer`'s `focus`
option decides focus independently. Outside clicks and Escape hide without discarding
words. A successful send or an explicit draft close discards the local record.

An accepted anchored comment opens its inline thread. When the reserved margin is too
narrow, that thread may cover the page in its bounded card; it does not substitute the
Expand Down
5 changes: 1 addition & 4 deletions skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -2140,10 +2140,7 @@ const COMPOSER = {
does: "Close the composer, keeping the draft",
line: "close — draft kept",
promoteEscape: false,
run: () => {
hideComposer();
showFab(null);
},
run: dismissFab,
},
],
};
Expand Down
14 changes: 10 additions & 4 deletions skills/leaf/assets/runtime/composing/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ export function createSelectionComposer(runtime, dependencies) {
// control inside the response bar; the other composer controls stay hidden there.
composer.style.display = open ? "contents" : "none";
composer.toggleAttribute("data-lf-open", open);
// The reader's own selection is gone by now — focusing a textarea drops it — so this
// mark is the only thing left pointing at the passage being quoted.
// An explicit Comment gesture focuses the textarea and drops the native selection, so
// this mark then becomes the durable pointer to the quoted passage. Automatic passage
// selection leaves both readings standing until the reader enters the field.
paintAnchors();
paintHere();
}
Expand All @@ -198,7 +199,12 @@ export function createSelectionComposer(runtime, dependencies) {
function openComposer(
anchor,
text,
{ suggest = false, about = designIsOn() ? "layer" : null, carry = false } = {},
{
suggest = false,
about = designIsOn() ? "layer" : null,
carry = false,
focus = true,
} = {},
) {
closeReactions();
if (composerInput.value === seededQuote) composerInput.value = "";
Expand Down Expand Up @@ -234,7 +240,7 @@ export function createSelectionComposer(runtime, dependencies) {
showComposer(true);
showFab(anchor);
syncComposer();
composerInput.focus();
if (focus) composerInput.focus();
watchComposer();
// Programmatic carrying fires no input event, so persist that one move explicitly.
// An automatically opened empty field has no draft to save; its first edit does.
Expand Down
12 changes: 8 additions & 4 deletions skills/leaf/assets/runtime/composing/surface.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,11 @@ export function createSelectionSurface({
const sel = pageSelection();
const anchor = sel ? selectionAnchor(sel) : null;
if (anchor?.quote.length >= MIN_QUOTE) {
openComment(anchor, "");
// Selecting words is still the browser's gesture. Open Leaf's response field beside
// them without moving focus into it, so the live Selection remains available to Copy
// and the native context menu. An explicit Comment press uses the same field and
// focuses it through focusFabComment below.
openComment(anchor, "", { focus: false });
} else if (fabAnchor?.quote && !fabHoldsCapturedPassage()) showFab(null);
}
// Where the pointer stopped is not the question; where the selection is, is. The guard
Expand Down Expand Up @@ -363,12 +367,12 @@ export function createSelectionSurface({
let actionPress = false;
let targetActivation = false;
let fabInputTakingFocus = false;
function openComment(...args) {
function openComment(anchor, text, options = {}) {
// Chromium may collapse the native page Selection before dispatching the textarea's
// focus event. Mark the handoff first so that intermediate selectionchange cannot
// dismiss the durable anchor the composer is opening on.
fabInputTakingFocus = true;
return openComposer(...args);
fabInputTakingFocus = options.focus !== false;
return openComposer(anchor, text, options);
}
function fabHoldsCapturedPassage() {
return fabInputTakingFocus || fabBar.contains(document.activeElement);
Expand Down
3 changes: 3 additions & 0 deletions tests/render_cases_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,9 @@ def compose(page, passage, text=None):
key is built from is the same in both tabs."""
page.locator(passage).scroll_into_view_if_needed()
page.locator(passage).click(click_count=3)
expect(page.locator(".lf-fab-bar")).to_be_visible()
expect(page.locator(".lf-fab-input")).not_to_be_focused()
Comment thread
leaf-agent marked this conversation as resolved.
page.keyboard.press("c")
expect(page.locator(".lf-fab-input")).to_be_focused()
if text is not None:
page.locator(".lf-fab-input").fill(text)
Expand Down
27 changes: 15 additions & 12 deletions tests/test_render_anchors.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,10 +440,10 @@ def test_a_widgets_label_takes_a_comment_inside_the_control_it_labels(browser, s


def test_a_selection_around_a_targets_buttons_does_not_deaden_them(browser, serve):
"""A drag around a target opens the immediate field without deadening its Buttons.
"""A drag around a target offers Comment without deadening its Buttons.

Focus collapses the browser's native selection, so the captured passage must remain
durable while the pointer path through `…` and a direct keyboard action both work."""
The browser's native selection remains available while the pointer path through `…`
and a direct keyboard action both work."""
page, errors = open_page(browser, serve(SUGGESTION_PAGE))
# Across the two paragraphs, so the row deciding the first is inside the selection.
start = page.locator("#replace").bounding_box()
Expand All @@ -455,7 +455,7 @@ def test_a_selection_around_a_targets_buttons_does_not_deaden_them(browser, serv
steps=16,
)
assert "Refill" in pending_text(page)
expect(page.locator(".lf-fab-input")).to_be_focused()
expect(page.locator(".lf-fab-input")).not_to_be_focused()

item = page.locator("[data-lf-for='sug-refill']").locator("xpath=..")
item.locator(":scope > .lf-margin-more").click()
Expand Down Expand Up @@ -659,8 +659,7 @@ def test_a_drag_released_mid_word_hugs_words_and_sentences(browser, serve):
drag must stay in one rendered block; matching endpoints around a nested paragraph
do not make the intervening blocks one sentence.

The reads use Leaf's pending highlight because focus moves into the immediate
comment field and therefore collapses the browser's native selection."""
The reads use Leaf's pending highlight, the durable form of the native selection."""
page, errors = open_page(
browser,
serve(
Expand Down Expand Up @@ -696,7 +695,7 @@ def spot(root, word, into):
return page.evaluate(mid, {"root": root, "word": word, "into": into})

def captured():
expect(page.locator(".lf-fab-input")).to_be_focused()
expect(page.locator(".lf-fab-input")).to_be_visible()
quoted = composer_quote(page)["text"]
return quoted[1:-1]

Expand Down Expand Up @@ -755,6 +754,7 @@ def captured():
}""")
page.keyboard.press("Shift")
assert captured() == "ragra"
page.locator("#t").click()

page.evaluate("""() => {
const n = document.querySelector('#p').firstChild;
Expand Down Expand Up @@ -2241,6 +2241,7 @@ def test_an_ambiguous_revised_passage_detaches_instead_of_guessing(browser, serv
const fab = document.querySelector('.lf-fab-input');
if (fab.style.display !== 'block') return 'no button';
await new Promise(r => setTimeout(r, 40));
fab.focus();
document.querySelector('.lf-composer textarea').value = 'is this idempotent?';
document.querySelector('.lf-composer textarea')
.dispatchEvent(new Event('input', {bubbles: true}));
Expand Down Expand Up @@ -2436,7 +2437,7 @@ def test_a_passage_longer_than_the_pattern_is_anchored_whole(browser, serve):
steps=12,
)
expect(page.locator(".lf-fab-input")).to_be_visible()
expect(page.locator(".lf-fab-input")).to_be_focused()
expect(page.locator(".lf-fab-input")).not_to_be_focused()
expect(page.locator(".lf-composer")).to_be_visible()

# The mark under the open composer is the selection, both ends of it — and on the
Expand Down Expand Up @@ -2495,7 +2496,7 @@ def test_a_selection_of_the_whole_page_still_finds_its_passage(browser, serve):

page.keyboard.press("ControlOrMeta+a")
expect(page.locator(".lf-fab-input")).to_be_visible()
expect(page.locator(".lf-fab-input")).to_be_focused()
expect(page.locator(".lf-fab-input")).not_to_be_focused()
expect(page.locator(".lf-composer")).to_be_visible()
painted = page.evaluate(
"() => [...(CSS.highlights.get('lf-pending') ?? [])]"
Expand Down Expand Up @@ -2538,6 +2539,7 @@ def test_one_neighbour_is_not_enough_to_identify_a_revised_comment(browser, serv
const fab = document.querySelector('.lf-fab-input');
if (fab.style.display !== 'block') return 'no button';
await new Promise(r => setTimeout(r, 40));
fab.focus();
const box = document.querySelector('.lf-composer textarea');
box.value = 'does this hold?';
box.dispatchEvent(new Event('input', {bubbles: true}));
Expand Down Expand Up @@ -3343,7 +3345,7 @@ def route(request):
added.click(modifiers=["Alt"])
expect(page.locator(".lf-fab-bar")).to_be_visible()
expect(page.locator(".lf-fab-input")).to_be_focused()
page.locator(".lf-composer textarea").fill("Review the whole added line.")
page.locator(".lf-fab-input").fill("Review the whole added line.")
page.keyboard.press("Enter")
round_trip(page)
page.get_by_role("button", name=re.compile("^Threads")).click()
Expand Down Expand Up @@ -3398,8 +3400,9 @@ def route(request):
"endsAtTokenStart": True,
}, selected
expect(page.locator(".lf-fab-bar")).to_be_visible()
expect(page.locator(".lf-fab-input")).to_be_focused()
page.locator(".lf-composer textarea").fill("Review this expression.")
expect(page.locator("#lf-composer-quote")).to_contain_text("“request.token.id”")
expect(page.locator(".lf-fab-input")).not_to_be_focused()
page.locator(".lf-fab-input").fill("Review this expression.")
page.keyboard.press("Enter")
round_trip(page)
expect(page.locator(".lf-thread .lf-quote").nth(1)).to_have_text(
Expand Down
26 changes: 13 additions & 13 deletions tests/test_render_drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def test_page_round_trip(browser, serve):
page.evaluate("window.__leafJourneyDocument = 'held'")

# Select the passage from the keyboard's path: a real Range, then the keyup
# the runtime watches for keyboard selections. The immediate field takes focus.
# the runtime watches for keyboard selections. Comment explicitly enters its field.
page.evaluate("""() => {
const r = document.createRange();
r.selectNodeContents(document.getElementById('intro'));
Expand All @@ -88,6 +88,8 @@ def test_page_round_trip(browser, serve):
page.wait_for_selector(
".lf-fab-input", state="visible"
) # the selection raised the button
expect(page.locator(".lf-fab-input")).not_to_be_focused()
page.keyboard.press("c")
expect(page.locator(".lf-fab-input")).to_be_focused()
page.wait_for_selector(".lf-composer", state="visible")
page.locator(".lf-composer textarea").fill("Is 0041 idempotent?")
Expand Down Expand Up @@ -916,13 +918,10 @@ def test_an_untouched_inline_reply_follows_but_an_emptied_draft_holds(browser, s


def test_a_held_comment_send_leaves_the_passage_picked_out_behind_it(browser, serve):
"""The same reading of a later gesture, for the other gesture a reader can have
standing. A comment's send ends by handing typing to the thread it became, and a
round trip is how long that step takes to arrive; focusing a box collapses whatever
the page had selected. So a reader who picked out their next passage while the send
was in the wire had it taken back — silently, because nothing re-decides the 💬
until they gesture again, and the words in front of them simply stop being
something to comment on.
"""A comment's send must not take a newer passage selection with its focus handoff.

The newer selection remains native and keeps its response field available while the
earlier send becomes a thread behind it.

Held rather than raced: the window is one request's flight, and a machine quick
enough closes it before the next gesture. A loaded CI runner is not, and it said so
Expand All @@ -941,8 +940,8 @@ def test_a_held_comment_send_leaves_the_passage_picked_out_behind_it(browser, se
# The reader picks out their next passage while the first send is still in the wire.
page.locator("#p2").click(click_count=3)
expect(page.locator(".lf-fab-input")).to_be_visible()
expect(page.locator(".lf-fab-input")).to_be_focused()
expect(page.locator(".lf-fab-input")).to_have_value("")
expect(page.locator(".lf-fab-input")).not_to_be_focused()

held[0].continue_()
page.unroute("**/api/event")
Expand All @@ -957,7 +956,7 @@ def test_a_held_comment_send_leaves_the_passage_picked_out_behind_it(browser, se
assert pending_text(page) == "A short second passage.", (
"the send's landing lost the passage the reader had picked out"
)
expect(page.locator(".lf-fab-input")).to_be_focused()
expect(page.locator(".lf-fab-input")).not_to_be_focused()
expect(page.locator(".lf-composer")).to_be_visible()
assert composer_quote(page)["text"].strip("“”") == "A short second passage."
assert errors == []
Expand All @@ -976,12 +975,13 @@ def test_an_unsent_comment_stays_with_its_passage_when_another_is_selected(
original = "These words belong to the first passage."

page.locator("#p1").click(click_count=3)
expect(field).to_be_focused()
expect(field).to_be_visible()
expect(field).not_to_be_focused()
field.fill(original)

page.locator("#p2").click(click_count=3)
expect(field).to_be_focused()
expect(field).to_have_value("")
expect(field).not_to_be_focused()
assert (
page.evaluate(
"""() => Object.keys(localStorage)
Expand All @@ -991,8 +991,8 @@ def test_an_unsent_comment_stays_with_its_passage_when_another_is_selected(
)

page.locator("#p1").click(click_count=3)
expect(field).to_be_focused()
expect(field).to_have_value(original)
expect(field).not_to_be_focused()
assert errors == []
page.close()

Expand Down
Loading
Loading