From 3fd85a2bba42882a60205ed1b095d87fd1a1f832 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:31:18 +0000 Subject: [PATCH 1/2] Return the Page map's Escape to the reader's own place, fit its list inside the sheet, and read the nightly fixtures as the layer now draws them --- skills/leaf/CLAUDE.md | 9 ++++ skills/leaf/assets/runtime/chrome-style.js | 10 +++- skills/leaf/assets/runtime/living-margin.js | 14 ++++- tests/test_render_anchors.py | 9 +++- tests/test_render_controls.py | 60 ++++++++++++++------- tests/test_render_margin.py | 11 +++- tests/test_render_navigation.py | 10 ++-- tests/test_render_projection.py | 10 ++-- tests/test_site.py | 15 ++++-- 9 files changed, 113 insertions(+), 35 deletions(-) diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index c5ee142908..29b2577f27 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -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. + 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 diff --git a/skills/leaf/assets/runtime/chrome-style.js b/skills/leaf/assets/runtime/chrome-style.js index 4e1d71a788..32a04e7d23 100644 --- a/skills/leaf/assets/runtime/chrome-style.js +++ b/skills/leaf/assets/runtime/chrome-style.js @@ -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; } diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js index 5f88264fb5..2a586f7837 100644 --- a/skills/leaf/assets/runtime/living-margin.js +++ b/skills/leaf/assets/runtime/living-margin.js @@ -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 @@ -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) { @@ -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 @@ -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(); diff --git a/tests/test_render_anchors.py b/tests/test_render_anchors.py index a4ca473613..e864911b30 100644 --- a/tests/test_render_anchors.py +++ b/tests/test_render_anchors.py @@ -61,6 +61,7 @@ stamp_version_file, ticked, told, + wait_for_pending_mark, wait_for_revision, ) @@ -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" ) @@ -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" ) @@ -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() diff --git a/tests/test_render_controls.py b/tests/test_render_controls.py index de4e4ca9c3..052313e7e5 100644 --- a/tests/test_render_controls.py +++ b/tests/test_render_controls.py @@ -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 == [] @@ -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") @@ -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") @@ -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"; }}""" @@ -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}") diff --git a/tests/test_render_margin.py b/tests/test_render_margin.py index c754c6c3de..de96cf47d2 100644 --- a/tests/test_render_margin.py +++ b/tests/test_render_margin.py @@ -2807,7 +2807,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() diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index 4908856dee..47ed8c2121 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -1568,11 +1568,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() diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py index b7dfcf6601..b6b35dc446 100644 --- a/tests/test_render_projection.py +++ b/tests/test_render_projection.py @@ -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() diff --git a/tests/test_site.py b/tests/test_site.py index a0c46d2aa2..95ee56fb38 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -490,12 +490,19 @@ def test_the_label_is_chrome_rather_than_words_to_quote(site, hosted, browser): label = drag_across(page, "main > .sitenote p") assert "example of a leaf page" in label["text"] assert not label["fieldOffered"] - # The word `c` carries with nothing in hand — it goes to the threads rather - # than opening a box on anything, and "comment on the selection" does not - # contain it, so the two readings still tell each other apart. - assert "threads" in label["says"], ( + # The word `c` carries with nothing in hand — it names the page, the box a + # reader with nothing selected writes in, rather than the words the drag passed + # over. Both halves, because the reading only means anything against the other: + # the page's own lede says "the selection" from the same line, so a label that + # says "the page" is the layer declining to quote it rather than a line that + # never changes. + assert "comment on the page" in label["says"], ( "the site's own label was offered as a passage to quote" ) + assert "comment on the selection" in control["says"], ( + "the page's own words were not offered as a passage to quote, so the " + "label's reading above is evidence about nothing" + ) assert not errors, errors[:3] finally: page.close() From 3f3972792e3a295108a97f0e649033b36e942b2a Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:01:38 +0000 Subject: [PATCH 2/2] Name every close route that owns the sheet's focus restore The paragraph was written against this branch's two-flag design; main's single sheetCloseOwnsFocus is also raised by activate and by the map action's forwarding onclick, so the dispatcher is not its only holder. --- skills/leaf/CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index a2f455e382..d347c3946f 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -1429,10 +1429,12 @@ The same task boundary decides who puts focus back. A surface's own close route 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. +overwrites the restore and leaves them holding a door they never touched. A close that +places the reader itself therefore says so, by raising the flag the `close` handler +reads: `leavePageMap` unwinding the dispatcher's frame, so that frame's restore stands, +and the two activation routes that land the reader on the map control or on the control +the row forwards to. A close that raises nothing — the Close button, the platform's own +dismissal — still runs the surface's own route. 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