Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 9 additions & 0 deletions skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,15 @@ dialog is open again and gives that reopening its state back rather than taking
have cleared is what the surface is read by, and losing it is silent — the sheet still
stands, still says its name, and the next press inside it means something else.

The same task boundary decides who puts focus back. A surface's own close route returns
the reader to the control that opened it, which is right for a press on that control and
wrong for a keyboard entry: the dispatcher captured the reader's exact place before the
command ran and restores it synchronously, so a return route delivered a task later
overwrites the restore and leaves them holding a door they never touched. A close the
dispatcher is unwinding therefore says so — `leavePageMap` and the flag its `close`
handler reads — and the frame's restore stands. Every other close still runs the
surface's own route.
Comment thread
leaf-agent marked this conversation as resolved.
Resolved

A handle lives inside the region it draws, so a drawn region must not be its own
scroll container: a scroller clips a handle straddling its border and carries it
away with the content. A tray is a shell holding a `.lf-tray-list`, and every
Expand Down
10 changes: 9 additions & 1 deletion skills/leaf/assets/runtime/chrome-style.js
Original file line number Diff line number Diff line change
Expand Up @@ -1502,13 +1502,21 @@ ${MARK_RULES}
max-height: min(720px, calc(100vh - 24px)); margin: auto; padding: 14px;
border: 1px solid var(--border-2); border-radius: 12px; background: var(--paper);
color: var(--ink); box-shadow: 0 18px 54px var(--shade); }
/* The open sheet is a column so its one scroller takes whatever room the head and the
search box leave, rather than a height computed against the window: the list ran to
calc(100vh - 150px) while the box it sits in stops at min(720px, 100vh - 24px), so
on a short window the list was taller than the sheet, hung out of its rounded corner
and off the bottom edge, and its own focus ring was drawn outside the card. Said on
[open] because the author sheet would otherwise beat the UA's display:none for a
dialog that is shut. */
.lf-page-map-sheet[open] { display: flex; flex-direction: column; }
.lf-page-map-sheet::backdrop { background: color-mix(in srgb, var(--ink) 26%, transparent); }
.lf-page-map-search { width: 100%; box-sizing: border-box; margin-top: 10px;
padding: 7px 9px; border: 1px solid var(--border-2); border-radius: var(--r);
background: var(--paper); color: var(--ink); font: inherit; }
.lf-page-map-search:focus-visible { outline: var(--here-ring);
--lf-here-ring: page-map-search; outline-offset: 2px; }
.lf-page-map-list { margin-top: 6px; overflow: auto; max-height: calc(100vh - 150px); }
.lf-page-map-list { margin-top: 6px; overflow: auto; min-height: 0; }
.lf-page-map-empty { margin: 18px 8px 8px; color: var(--muted); text-align: center; }
.lf-page-map-group { padding: 10px 0; border-top: 1px solid var(--rule); }
.lf-page-map-group:first-child { border-top: 0; }
Expand Down
14 changes: 12 additions & 2 deletions skills/leaf/assets/runtime/living-margin.js
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export function createLivingMargin(dependencies) {
let highlighted = null;
let rovingFrame = 0;
let sheetActivation = false;
let sheetReturning = false;
let sheetFrom = null;
let sheetTarget = null;
// The cascade owns available room: panels and trays change the body's named
Expand Down Expand Up @@ -1325,8 +1326,15 @@ export function createLivingMargin(dependencies) {
}

const pageMapIsActive = () => sheet.open || availableRows().includes(focused());
// The dispatcher's own way out of the `g m` frame, and the one close that owes the
// reader nothing: it captured where they stood before the press and restores it in the
// same press. That restore is synchronous while `close` arrives in a task of its own,
// so the door's return route below would run a frame later and put the reader on the
// Map control instead of the ask row or the reading place they asked to come back to.
function leavePageMap() {
if (sheet.open) sheet.close();
if (!sheet.open) return;
sheetReturning = true;
sheet.close();
}

function focusMapControl(entry = null) {
Expand Down Expand Up @@ -2593,7 +2601,9 @@ export function createLivingMargin(dependencies) {
sheet.addEventListener("close", () => {
const from = sheetFrom;
const activated = sheetActivation;
const returning = sheetReturning;
sheetActivation = false;
sheetReturning = false;
// A dialog delivers `close` in a task of its own, so a reader who reopens the sheet
// in the same breath — Esc off the overflow route and straight back onto the Button
// that named it — is standing in the next opening by the time this arrives. That
Expand All @@ -2605,7 +2615,7 @@ export function createLivingMargin(dependencies) {
sheetFrom = null;
sheetTarget = null;
paintKeys();
if (activated) return;
if (activated || returning) return;
if (from?.isConnected && from.checkVisibility())
from.focus({ preventScroll: true });
else focusMapControl();
Expand Down
9 changes: 8 additions & 1 deletion tests/test_render_anchors.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
stamp_version_file,
ticked,
told,
wait_for_pending_mark,
wait_for_revision,
)

Expand Down Expand Up @@ -235,7 +236,11 @@ class of bug here is text that looks selectable and isn't — a synthetic Range
select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y))

# Focusing the immediate field collapses the browser Selection; the durable pending
# paint proves the drag selected the words the widget actually says.
# paint proves the drag selected the words the widget actually says. The release
# queues the capture rather than performing it, so the paint is what says the gesture
# has been read — reading the highlight straight off the mouseup reads the frame
# before it and reports every drag as a drag that selected nothing.
wait_for_pending_mark(page)
assert pending_text(page).strip() == "In flight", (
"a drag across the heading selected nothing — it is painted, not said"
)
Expand Down Expand Up @@ -422,6 +427,7 @@ def test_a_widgets_label_takes_a_comment_inside_the_control_it_labels(browser, s
y = box["y"] + box["height"] / 2
select(page, (box["x"] + 6, y), (box["x"] + box["width"] - 6, y))

wait_for_pending_mark(page)
assert pending_text(page).strip() == "Heated bird bath", (
"a drag across the tab's name selected nothing"
)
Expand Down Expand Up @@ -473,6 +479,7 @@ def test_a_selection_around_a_targets_buttons_does_not_deaden_them(browser, serv
(end["x"] + end["width"] - 6, end["y"] + end["height"] - 6),
steps=16,
)
wait_for_pending_mark(page)
assert "Refill" in pending_text(page)
expect(page.locator(".lf-fab-input")).not_to_be_focused()

Expand Down
60 changes: 41 additions & 19 deletions tests/test_render_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2345,9 +2345,15 @@ def test_a_closed_leaf_clears_itself_off_the_tray(browser, serve, other_leaf):
expect(page.locator(".lf-others-panel")).to_be_focused()
expect(page.locator(".lf-keyline")).not_to_contain_text("walk the leaves")
assert page.locator(".lf-others-panel").get_attribute("aria-keyshortcuts") is None
# Nothing live left to open: the button stands while the panel does and stands
# down with it, which is the count's other half.
# Two presses in, two Escapes out. The second `g L` entered a tray that was already
# standing, so its own Escape gives that press back and leaves the workspace it
# found; the tray the first press stood up closes on the one after. Nothing live left
# to open: the button stands while the panel does and stands down with it, which is
# the count's other half.
page.keyboard.press("Escape")
expect(page.locator(".lf-others-panel")).to_be_visible()
page.keyboard.press("Escape")
expect(page.locator(".lf-others-panel")).not_to_be_visible()
told(page)
expect(btn).not_to_be_visible()
assert errors == []
Expand All @@ -2357,8 +2363,9 @@ def test_a_closed_leaf_clears_itself_off_the_tray(browser, serve, other_leaf):
def test_the_leaves_tray_takes_the_keyboard(browser, serve, live_leaf):
"""The tray is a list, and a reader walks it without reaching for the mouse: g L
opens it and lands on the first neighbour, up and down step between them and clamp
at the ends, Enter opens the focused one in its own tab, and Esc hands focus back
to the button that opened it. The go-to menu names the panel, and the key line names
at the ends, Enter opens the focused one in its own tab, and Esc gives that press
back — the reader is returned to the reading place they pressed `g L` from, not left
holding the button that names the tray. The go-to menu names the panel, and the key line names
the tray's own keys once focus is inside it — the promise and the press being one
scene — and the "?" reference carries the same rows."""
live_leaf("second", "A second leaf")
Expand Down Expand Up @@ -2399,9 +2406,11 @@ def test_the_leaves_tray_takes_the_keyboard(browser, serve, live_leaf):
expect(tab).to_have_url(destination)
page.keyboard.press("Escape")
expect(page.locator(".lf-others-panel")).not_to_be_visible()
# Closing while focus is inside would drop the reader on the body; it lands on
# the one control that reopens what just closed.
expect(btn).to_be_focused()
# One press in, one Escape out, and what the Escape gives back is exactly what the
# press took: the reading place `g L` was pressed from. Landing on the tray's own
# button instead would leave a reader who never touched it holding a control, one
# press from reopening what they had just put down.
assert page.evaluate("() => document.activeElement === document.body")
page.keyboard.press("?")
page.keyboard.press("?")
help_el = page.locator(".lf-help")
Expand Down Expand Up @@ -3882,15 +3891,18 @@ def offered(page, selector):
# enters the page. Twelve stops instead of thirty-three, with every ring the page's own
# widgets draw unread and the walk reporting itself complete.
RING_WALK_START = "() => document.body.focus()"
# A new stop, read on a rendered frame. Held by identity, since two buttons in a row can
# say the same words at the same scroll and are still two stops.
# What the walk is standing on, read on a rendered frame: a stop it has not stood on, one
# it has, or nothing at all. Held by identity, since two buttons in a row can say the same
# words at the same scroll and are still two stops. The three answers are one reading
# rather than two, because only the middle one ends a walk and a boolean spelt the other
# two the same way.
RING_NEW_STOP = f"""async () => {{
await ({RENDERED})();
const e = ({DEEP_FOCUS})();
if (!e || e === document.body || e === document.documentElement) return false;
if (window.__lfSeen.has(e)) return false;
if (!e || e === document.body || e === document.documentElement) return "empty";
if (window.__lfSeen.has(e)) return "seen";
window.__lfSeen.add(e);
return true;
return "new";
}}"""


Expand Down Expand Up @@ -4203,17 +4215,27 @@ def test_every_ring_the_layer_draws_is_shown_whole_somewhere_in_the_corpus(
for _ in range(400):
if walked or empty:
page.keyboard.press("Tab")
if not page.evaluate(RING_NEW_STOP):
# The key that opened the scope may have landed focus on nothing, so
# the first read is allowed to come back empty; a repeat after the
# walk has started is the order having come round. Two, not the cap:
# a walk that never starts otherwise spends four hundred frames
# saying so and reads as a slow test rather than a broken one.
stop = page.evaluate(RING_NEW_STOP)
if stop == "seen":
came_round = True
break
if stop == "empty":
# Nothing to stand on, which is two different things and neither of
# them the end of the walk. The key that opened the scope may have
# landed focus on nothing; and the tab order runs off the end of the
# document and comes back in through it, so a scope the walk joins
# part-way down its own order — the Page-map sheet, which it enters at
# the list — keeps the stops above its starting point on the far side
# of that crossing. Walking through it is how they are reached at all;
# the order still ends where it comes round to a stop already stood
# on. Two in a row is the cap, not four hundred: a walk that never
# starts should say so rather than read as a slow test.
empty += 1
if walked or empty > 2:
if empty > 2:
came_round = True
break
continue
empty = 0
walked, stops = walked + 1, stops + 1
if (lost := page.evaluate(SEEN_STOP)) is not None:
unseen.add(f"{where}: {lost}")
Expand Down
11 changes: 10 additions & 1 deletion tests/test_render_margin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2846,7 +2846,16 @@ def test_the_small_screen_map_is_a_complete_accessible_sheet(browser, serve, ope

page.keyboard.press("Escape")
expect(sheet).to_be_hidden()
expect(toggle).to_be_focused()
# Each route gives back exactly what it took. A press on the door is the reader
# holding that control, so the modal hands it back to them. `g m` was pressed from
# the page, and the dispatcher captured that place before the sheet stood up, so its
# one Escape returns them there rather than leaving them on a button they never
# touched (skills/leaf/CLAUDE.md, "The keyboard is a stack"). The door's own return
# route stands down for that press alone; every other close still runs it.
if opener == "keyboard":
assert page.evaluate("() => document.activeElement === document.body")
else:
expect(toggle).to_be_focused()
assert page.evaluate("() => document.scrollingElement.scrollTop") == before
assert errors == []
page.close()
Expand Down
10 changes: 7 additions & 3 deletions tests/test_render_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1783,11 +1783,15 @@ def disclosure_state():
expect(ask).to_be_focused()

# Page map has its own close step, but that does not waive the same workspace
# contract: leaving it restores the Asks row it displaced.
# contract: leaving it restores the Asks row it stood over. Its door is the one that
# can forget, because a dialog delivers `close` in a task of its own — a frame after
# the dispatcher has already put the reader back — so the door's own return route has
# to stand down for the press that is unwinding it.
page.keyboard.press("g")
page.keyboard.press("Shift+m")
expect(page.locator(".lf-margin-marker:focus")).to_have_count(1)
page.keyboard.press("m")
expect(page.get_by_role("dialog", name="Page map", exact=True)).to_be_visible()
page.keyboard.press("Escape")
expect(page.get_by_role("dialog", name="Page map", exact=True)).not_to_be_visible()
expect(page.locator(".lf-decisions-panel")).to_be_visible()
expect(ask).to_be_focused()

Expand Down
10 changes: 6 additions & 4 deletions tests/test_render_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1307,15 +1307,17 @@ def test_escape_lets_go_of_the_ask_the_reader_is_standing_on(browser, serve):
"letting go left the reader holding the control on a page that fits the window"
)

# The direct Page-map address is navigation too: it lands on the target's primary
# Button without unfolding the peer controls into an extra Escape rung.
# The Page map is navigation too, and one destination rather than the nine-item
# numbered prefix it replaced: it opens the complete map without unfolding the walked
# item's peers into an extra Escape rung, and its one Escape hands the reader back the
# reading place they pressed from rather than the Map control the door opened from.
page.keyboard.press("g")
page.keyboard.press("m")
page.keyboard.press("2")
expect(page.locator("#sug-refill[data-lf-decision]")).to_have_count(1)
expect(page.get_by_role("dialog", name="Page map", exact=True)).to_be_visible()
expect(walked_item.locator(":scope > .lf-margin-more")).to_be_visible()
expect(walked_item.locator(":scope > .lf-margin-options")).to_be_hidden()
page.keyboard.press("Escape")
expect(page.get_by_role("dialog", name="Page map", exact=True)).not_to_be_visible()
assert page.evaluate("() => document.activeElement === document.body")
assert errors == []
page.close()
Expand Down
Loading