diff --git a/examples/corpus.html b/examples/corpus.html
index 9b37b0e61..23defac5b 100644
--- a/examples/corpus.html
+++ b/examples/corpus.html
@@ -1042,7 +1042,8 @@
What would you report on the final revision?
Choose the review the agent should enter on the forge. Each option states
the disposition and the exact work, so the agent can submit it without
- translating a generic label.
+ translating a generic label. With an option focused, Enter writes
+ another answer; c comments on that option instead.
diff --git a/examples/pr-walkthrough.html b/examples/pr-walkthrough.html
index 0100e1b88..ef02cb78b 100644
--- a/examples/pr-walkthrough.html
+++ b/examples/pr-walkthrough.html
@@ -559,7 +559,8 @@ What would you report on the final revision?
Choose the review the agent should enter on the forge. Each option states
the disposition and the exact work, so the agent can submit it without
- translating a generic label.
+ translating a generic label. With an option focused, Enter writes
+ another answer; c comments on that option instead.
diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md
index 774f2f14b..2c1f0dc1c 100644
--- a/skills/leaf/CLAUDE.md
+++ b/skills/leaf/CLAUDE.md
@@ -1828,6 +1828,13 @@ of rows applies and which platform keys that context claims. The dispatcher,
key line, `?` reference, control tooltips, and announcements are projections of
those objects.
+Treat that register as a product grammar, not a collection of locally convenient
+shortcuts. Before adding or changing a binding, survey the complete register for
+meaning, scope, native overlap, entry and exit symmetry, and focus restoration.
+Document every inconsistency the survey exposes in the task handoff. If the rules
+here do not settle one, escalate it to the user before choosing locally; the
+absence of a dispatch conflict does not make a binding precise.
+
Binding spelling is canonical: modifiers are ordered `Mod`, `Alt`, `Shift`, and
single-letter keys are lowercase. A produced punctuation glyph carries no Shift
prefix because the keyboard layout owns that modifier. Validate that form when a
@@ -1841,21 +1848,37 @@ is therefore as deep as the way in, and the reader walks it back without having
counted: three presses in, three Escapes out, each giving up the press that
earned it.
-A press that opens a surface and then steps into something inside it pushes two
-layers at once, and Escape can only hand one of them back. The reader reads that
-as Escape not undoing what the key did, and no surface can tell them otherwise,
-because what the key line promises is one press. Where a press looks like it
-wants two layers, the second layer earns a key of its own — usually the same
-letter again, from the scope the first press stood up. That press is the
-reader's own next step rather than a toll, and the layer it leaves between is
-where the surface's own keys become reachable at all. `c` into the thread panel
-and then its box is that shape, and the paragraphs above own the detail.
+A command that enters a temporary surface declares one `returnFrame`. The dispatcher
+captures the reader's exact focus or reading block before it runs, and pushes the frame
+only after the declared layer is active. Escape closes that frame and restores the
+captured place. The command may reveal containing chrome and focus its destination in
+one transaction: `c` from the page opens and focuses the page-comment box, and its one
+Escape closes that whole entry because the panel is the box's container, not a second
+destination the reader requested.
+
+A frame is active only while its owning surface still stands and the reader remains in
+the layer it entered. A latent filter value or mode flag is not enough: closing a panel or
+leaving a widget must retire its frame so core Escape cannot advertise or mutate hidden
+state elsewhere on the page.
+
+Two independently requested entries remain two frames. `g T` enters the Threads list;
+`c` from that list enters its page-comment box. Two Escapes return first to the list and
+then to the exact place and workspace `g T` displaced. A filter or other state entered
+inside a surface gets its own frame or its control's own nearer Escape step. Never infer
+the inverse of a keyboard entry from whatever panels happen to be open afterwards.
+
+A bounded mode may instead own its complete entry, nesting, cancellation, and origin
+machine inside the one scope that claims the keyboard while it stands. Help, the `g`
+address window, item selection, page search, and reactions use that form. Such a mode does
+not also push a command frame. What is forbidden is the middle state: opening with an
+ordinary `run`, then asking a shared scene inspection or unrelated outer scope to guess
+what Escape should restore.
Landing focus in what a press opened is arrival, not a second layer: a tray on
-its first row, the versions menu on a version, the panel on its list. The second
-layer is a box the surface does not shadow — the reference's search box is inside
-a surface too, and what keeps it one layer is `HELP` standing nearer with a claim
-over the whole keyboard, so the box's letters were never the page's to take back.
+its first row, the versions menu on a version, the panel on its list, or the comment
+box `c` named. A later command into a different mode is another layer. The reference's
+search box is part of its one complete mode because `HELP` owns the whole keyboard while
+it stands; its letters were never the page's to take back.
The rule holds for a sequence as much as for a surface, where the stack it is
about is the reader's rather than the dispatcher's. The address chord arms on
@@ -1916,12 +1939,12 @@ browser Selection from the active match. Escape returns to the surface that open
the page after a direct `/`, or the visible hints after `s` then `/`. The mode keeps `?`
available and claims the rest of the page's keyboard while it stands.
-`rung()` has a single `panelOpen` branch, and that is the rule rather than a
-looseness in it: a surface and where the reader stands in it are one layer. The
-panel's list and the thread `t` walks to are the same rung, which is why `c` from
-either of them is the box — the box being the layer below. So the click that
-opened the panel is the press one Escape gives back, whichever of its contents
-the reader walked to first.
+The return stack records entry history; `rung()` is only the fallback for state reached
+without a registered entry, such as a pointer-opened panel or focus the reader moved by
+ordinary traversal. A keyboard command with `returnFrame` never asks `rung()` to guess
+its inverse. Moving within an entered surface—`t` walking from the Threads list to a
+thread, for example—does not push another frame, so Escape still returns through the
+entry that opened the surface.
The register owns capabilities, not controls. Every capability the chrome offers
has a row, and each control that reaches one names its key through `also`; a
@@ -1953,10 +1976,16 @@ native Enter or Space, while the Ask-local list gives it a contextual number. In
a conditional chord mnemonic must not share its final key
with a page action, or a dead destination can fall through into a different operation.
+`c` is reserved for commenting. Enter keeps native activation or the focused control's
+local continuation. On an option mark, Enter means “write another option”: it extends the
+answer currently being edited and returns to that same mark with Escape. Using `c` there
+would conflate changing the option set with opening a conversation about it; the existing
+page `c` remains the latter.
+
A row whose press turns a mode on and off states the mode rather than the toggle.
`does` and `line` are functions of whether it stands, so the sentence says which
-way this press will go, and Escape takes the mode off through the rung ladder
-rather than through a second binding of its own.
+way this press will go. When turning it on is an entry, its `returnFrame` states
+Escape's inverse rather than a second row guessing from the resulting scene.
Which scope a row belongs to follows from what its press acts on. The page holds
the presses whose subject is the page: `/` searches its text, `s` names its visible
@@ -1969,49 +1998,36 @@ on: `w` narrows the thread panel's list and `/` searches it, and both live in
`PANEL`. The page's alphabet is small and every letter spent there is spent on
every page, so a letter earns page scope only by acting on the page.
-A surface may also hold the next step of a page key, which is the third row in
-`PANEL` and the one exception the rule has: the page's `c` lands the reader on
-the comment list and the panel's `c` puts them in its box. The letter is the
-same because the intent is, one scope in — as `g` names a document list and then
-a member of it — and the inner row stands down wherever the page's own key has a
-nearer
-answer, so the two never offer the reader a choice about which one runs.
-
-A scope's rows act on contents the reader is looking at rather than standing in,
-which is why they can be sorted by surface at all. One press is not like that:
-`c` follows the reader, and what it means is whatever they are standing in.
-
-That it reaches into the panel is not an exception. Page scope already crosses
-there: `t`/`T` can land on cards in Threads, and `a`/`A` can land on an ask an
-agent sent inside a thread. A page key that takes the reader somewhere owes them
-an answer once they are standing there.
-Rescoping `c` per surface would not even buy the tidiness it looks like — the
-reader stands in one place at a time, so it is several rows spelling one key,
-each live exactly where the others are not.
-
-Its destination is the anchor the 💬 carries, then the open thread the reader is
+A surface may also hold the contextual form of a page intent. `c` always means
+comment; its destination follows what the reader is standing on. From the Threads
+list the panel row enters the page-comment box. Everywhere the page has a nearer
+answer—a selection, item, or conversation—the page row enters that box instead.
+The rows are mutually exclusive, so the register never asks the reader to choose
+between two meanings for `c`.
+
+That the page row reaches into Threads is not an exception. Page scope already crosses
+there: `t`/`T` can land on cards in Threads, and `a`/`A` can land on an ask an agent
+sent inside a thread. A page key that takes the reader somewhere owes them an answer
+once they are standing there. The destination, label, command, and return frame all
+come from `commentDestination`, so the same contextual reading governs every projection.
+
+The destination is the anchor the 💬 carries, then the open thread the reader is
in or the single inline thread held by a pressed Page-map marker, then the item they are
-standing in, and, when none of those is in hand, the conversation itself.
+standing in, and, when none of those is in hand, the page-comment box.
`commentDestination` decides it once and states the
-sentence, the key line and the press together, so the reference, the line and
-what happens cannot come to spell it differently. The pointer's answers outrank
+sentence, return frame, key line and press together, so the reference, the line,
+what happens, and the way back cannot come to spell it differently. The pointer's answers outrank
the standing: a selection or a raised 💬 is the more recent thing the reader
said. `standingItem` and `standingConversation` are what "standing" means here,
and **Standing somewhere** below owns that reading.
-The last of the four names the room rather than a box in it, and is the one place
-a surface holds a `c` of its own. It is not a second reading of the page's key
-but the same intent one scope further in, the way `g` names a list and then a
-member of it: the page's `c` opens the panel and stands the reader on its list,
-and the panel's `c` puts them in the general box. Landing straight in that box is
-what it replaced, and that box is the one place in the panel where the panel's own
-letters are all shadowed — the typing scope claims a letter first — so the press
-that promised the comments left `w` and `/` unreachable until the reader pressed
-Escape. The panel's row is not the several-rows-one-key shape either, because it
-stands down wherever the page's key has the nearer answer: a live 💬, or the
-conversation the reader is standing in, whose own box `Enter` already reaches.
-A resolved thread offers no box, so the row answers there and the general
-box is the honest destination.
+The page-comment box lives in the Threads panel, but entering it does not mean “open
+Threads”: `g T` owns that destination and lands on the list where `w` and `/` remain
+reachable. `c` opens the panel only as the implementation container its requested box
+needs, focuses the cursor immediately, and records the prior workspace in one frame.
+Escape therefore returns directly to the exact prior control or reading place. From an
+already-entered Threads list, `c` adds one nested frame and Escape returns to that list.
+A resolved thread has no reply box, so the general box is the honest contextual answer.
The item's box is the composer, on the item, and not a widget's own conversation
seat even where it has one. `openOnItem` writes the anchor `renderConversations`
@@ -2046,6 +2062,14 @@ A row has these meanings:
can act at the reader's current position.
- `run` performs one result. A run-less row names a press it does not make: the
platform's own on a link, or one another scope's row already runs.
+- `returnFrame`, when the result enters a temporary layer, returns its `active`, `close`,
+ `does`, and `line` contract. The dispatcher captures the origin before `run`, validates
+ the descriptor, and pushes it only if the layer is active afterwards. Do not call the
+ return stack from a command or restore focus in the command's close path; declaring the
+ frame is what makes keyboard invocation and reference invocation obey the same stack.
+ A command surface that already displaced the reader, such as the modal reference, passes
+ its saved origin into dispatcher invocation instead of letting a closing implementation
+ control become the origin.
- `native: true` performs `run` without preventing the platform default. Use it
when Leaf must change state before the browser completes the same press, not
to leave an otherwise owned press half-handled. It still follows the ordinary
@@ -2096,37 +2120,34 @@ newlines, or caret keys from it.
One box inside another scope states only what it does differently. The find box
registers its Escape and Enter on the exact input element, so those rows stand
-before `TYPING`; the general text-entry claim then stands before any ancestor
-widget, and ancestor scopes still stand before unrelated core modes. Escape lets
-the narrowing go, and the box on the press after that. One press is one rung there
-as everywhere else.
-
-A box hands the reader back to the conversation it is written in, which is the
-rung `c` came down. `backFromBox` climbs `SAYS_IN` from the box where
-`standingConversation` climbs it from where the reader stands, so the press in
-and the press out name one element and one word — "comment on the thread" going
-in, "back to thread" coming out. The panel's general box has no conversation and
-lands on the list. A page-owned first-message seat has no standing place of its
-own; a widget control that explicitly enters its box supplies both the return
-control and the caller-owned word for that route through `landInConversation`.
-A visit reached by Tab supplies neither and leaves the page's own "let go"
-standing. Asking whether the container can take focus is what keeps every other
-route a relation rather than a list of containers that happen to be focusable.
-
-A key may repeat across nesting scopes to mean the same intent one scope further
-in. `c` reads that way: from
-the page it goes to the comments and stands the reader on the list, and from
-inside the panel it opens the general box. A landing is chosen for the keys it
-leaves live — the general box shadows every letter the panel's own scope binds,
-so landing there would have made `w` and `/` cost an Escape first. Put the reader
-where the surface's keys answer and let a second press take them into the box.
-Where a box has a key that reaches it, the box says so itself through its
-placeholder `address`, which is what a screen reader hears.
+before the command return frame; that frame stands before `TYPING`, and the general
+text-entry claim stands before any ancestor widget. Escape therefore lets a live query
+go, then leaves the box through the `/` frame, then leaves the panel through its entry
+frame. A plain composer with no control-specific Escape goes directly through the
+command frame instead of paying a generic “leave the textarea” step the entry never made.
+
+A keyboard-entered box hands the reader back through its captured return frame.
+`boxReturnFrame` and `standingConversation` climb the same conversation relation, so
+“comment on the thread” going in and “back to thread” coming out name one element.
+The panel's general box returns to the Threads list when it was entered there, and to
+the prior page place and workspace when page `c` entered it directly. `backFromBox`
+remains the fallback for Tab or pointer arrival, where no keyboard entry exists to
+restore. A page-owned first-message seat has no standing place of its own; a widget
+control that explicitly enters its box supplies the caller-owned return target through
+`landInConversation`.
+
+A key may repeat across nesting scopes to mean the same intent in context. `c` reads
+that way: from the page it enters the nearest comment box; from the Threads list it
+enters the page-comment box one frame below that list. `g T`, not `c`, is what enters
+Threads as a navigable surface and leaves `w` and `/` live. Where a box has a key that
+reaches it, the box says so itself through its placeholder `address`, which is what a
+screen reader hears.
A true mode may own the keyboard. An armed address chord and the open reference
claim the relevant keys through their scope. A longer-lived menu keeps the
-reference available through `allButTheReference`. Closing an overlay restores
-focus to `helpFrom` so the reader returns to the control that opened it. A modal
+reference available through `allButTheReference`. Closing the reference restores
+the shared captured `helpOrigin`, so the reader returns to the control or reading
+place that opened it. A modal
dialog clears the top layer's auto popovers on its way in, so the reference notes
the ones it was opened over and stands them back up before that restore — the
overlay that says what a menu's keys are cannot be what takes the menu away. It
@@ -2134,11 +2155,12 @@ stands each one back up from that layer's own invoker — `lfInvoker`, the link
layer declares because the platform's own runs one way only — so the layer's way
out survives the round trip too.
-Escape is an ordinary binding in the register for Leaf-owned modes. The innermost scope that binds it
-owns one unwind step. A control-specific Escape, panel dismissal, decision release,
-and return to the page cannot cascade from one keypress. A scope does not need a
-private `keydown` listener or hand-written `preventDefault` to protect that
-contract.
+Escape is an ordinary binding in the register for Leaf-owned modes. A focused control's
+specific inner step stands first, the latest active command return frame next, then the
+generic text and containing scopes. The innermost live row owns exactly one unwind step.
+A query clear, box return, panel dismissal, decision release, and return to the page
+cannot cascade from one keypress. A scope does not need a private `keydown` listener or
+hand-written `preventDefault` to protect that contract.
Auto popovers and modal dialogs are the platform's modes. While one is the active top
layer, the page rung stands down and browser Escape closes it; Leaf updates from the
@@ -2337,13 +2359,15 @@ that names it. A control that itself stands under a fixed bar is not a finding
that is a fact about where it was put — and neither is a box too tall for the
region it is in.
-`rung` and `letGo` put focus on `body` when the reader leaves chrome or releases
-a decision. `body` has a tab stop because a short page may not become focusable from
-overflow alone. Focus rather than blur hands Space, PageDown, arrows, Home, and
-End back to the page's actual scroll box. `letGo` also runs synchronously during
-module evaluation so a fresh page accepts native scrolling before asynchronous
-upgrade, without stealing focus from a control the reader reaches during that
-upgrade.
+`restoreReturnPlace` restores the exact connected control a command displaced. When
+the reader had no control focused it restores the captured reading block without
+leaving that block as an artificial activation target; if neither survives, it focuses
+`body`. Pointer and ordinary-traversal fallbacks use `rung` and `letGo` for that last
+case. `body` has a tab stop because a short page may not become focusable from overflow
+alone. Focus rather than blur hands Space, PageDown, arrows, Home, and End back to the
+page's actual scroll box. `letGo` also runs synchronously during module evaluation so a
+fresh page accepts native scrolling before asynchronous upgrade, without stealing focus
+from a control the reader reaches during that upgrade.
### The key line and reference
@@ -2463,6 +2487,13 @@ document because its rows leave the page. The asks tray takes a strip because
its rows travel within the page and the reader must keep the target visible.
Both entry controls call the same tray setter.
+Keyboard destinations also capture the workspace they replace. `g T`, `g A`, and
+`g L` may exchange a standing panel or tray for another; their return frame restores
+that prior workspace and re-resolves its semantic row when reconciliation rebuilt it.
+`g M` uses the same frame for the Page-map rail or compact sheet. Direct destinations
+therefore restore both exact standing and workspace state rather than merely focusing
+the destination's banner control after closing it.
+
`restoreTray` runs after all declarations exist and after the first projection
can populate state-dependent rows. It calls its supplied `beforeOpen` policy to
retire Threads, then presents the remembered tray directly without replaying
@@ -2548,6 +2579,8 @@ place that card at the top or bottom of its list without moving the page. From a
beside-panel, `g p` returns focus to the page while keeping the panel and its narrowing.
An edge is one place, so the second key completes the route; because every page has a
top, the mode never arms empty and the page-level `g` row needs no capability gate.
+Completing a direct destination exchanges the transient chord for one return frame;
+Escape restores the exact standing and workspace captured before `g` armed.
`DIRECT_DESTINATIONS` is the direct-destination vocabulary. Each entry declares its
mnemonic, words, capability, and landing. `ADDRESSES` is the numbered page-list
vocabulary. Each entry declares:
diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js
index fd154dc3e..dd67a6b77 100644
--- a/skills/leaf/assets/leaf.js
+++ b/skills/leaf/assets/leaf.js
@@ -111,17 +111,12 @@
* The stack the rule is about is the reader's, and every key here answers to it: a press
* that takes the reader in pushes one layer, and Escape pops one. So the way out is as
* deep as the way in and can be walked back without being counted — three presses to get
- * somewhere, three to come back, each giving up the press that earned it. A key that
- * opened a surface and then also stepped into something inside it puts two layers on for
- * one press, and Escape can only ever hand one of them back; the reader reads that as
- * Escape not undoing what the key did. `c` is the case that named the rule, having opened
- * the thread panel and landed in its general box together.
- *
- * Landing focus in what a press opened is arrival, not a second layer — a tray on its
- * first row, the versions menu on a version, the panel on its list. A second layer is a
- * box the surface does not shadow, and it earns a press of its own: `c` again, from the
- * panel this time. The layer that leaves between is where the surface's own keys can be
- * reached at all.
+ * somewhere, three to come back, each giving up the press that earned it. An entry row
+ * declares one return frame. The dispatcher captures the exact focus or reading block
+ * before the command and pushes only after its layer stands; Escape closes that frame and
+ * restores the capture. Revealing containing chrome and focusing the requested destination
+ * are one entry: page `c` enters the page-comment box, while `g T` enters the Threads list.
+ * A later `c` from that list is a second entry, so two Escapes unwind both in reverse.
*
* Two page modes make a destination explicit before acting on it. `s` draws short,
* viewport-local hints on stable items and declared visual parts. `/` searches all page
@@ -139,9 +134,9 @@
* adjacent fixed keycaps. A list letter narrows those inline hints without moving the
* remaining routes. Any other key disarms the window and keeps its
* ordinary meaning, which the dispatcher spells as disarming and walking the stack again.
- * Escape is a binding like any other, and the rung is whichever scope in reach binds it
- * first, so backing out is one layer per press and the promise cannot drift from the
- * press.
+ * Escape is a binding like any other. A control-specific inner step precedes the latest
+ * active command frame, then generic focus and pointer fallbacks; the first live row owns
+ * exactly one step, so the promise cannot drift from the press.
*
* What a key would do right now is state the user can read, not recall. The quiet fixed key
* line starts with the first live row of the innermost scope, then a promotable Escape or
@@ -245,6 +240,7 @@ import { DISCLOSE, createDisclosure } from "./runtime/keyboard/disclosure.js";
import { createDispatch } from "./runtime/keyboard/dispatch.js";
import { createKeyline } from "./runtime/keyboard/keyline.js";
import { createReference } from "./runtime/keyboard/reference.js";
+import { createReturnStack } from "./runtime/keyboard/return-stack.js";
import { createScopes, keys, paintKeys, saying } from "./runtime/keyboard/scopes.js";
import { createLivingMargin, marginAction } from "./runtime/living-margin.js";
import { createNavigation, scrollerFor } from "./runtime/navigation.js";
@@ -921,14 +917,14 @@ const needsBtn = el("button", "lf-btn lf-needs", "Waiting on you");
needsBtn.setAttribute("aria-pressed", "false");
findRow.append(findInput, needsBtn);
const threadsBox = el("div", "lf-threads");
-// An Escape rung: backing out of the general box lands on the list (visible ring,
-// t/T walk on from it) rather than on nothing. -1 keeps it out of the Tab order.
+// A stable panel landing for g T, for c entered from the list, and for pointer/Tab
+// fallbacks that have no command frame. -1 keeps it out of the Tab order.
threadsBox.tabIndex = -1;
-// And a name, because `c` now lands a reader here rather than in the general box, whose
-// own label spoke for it. A page key's arrival has to say where it arrived — the two
-// landings this one matches are both named, a leaf row by its link text and a decision row by
-// the decision — or the press is silent to exactly the reader who cannot see the ring it
-// painted. The same reason the reference dialog carries a role and a label beside its -1.
+// And a name, because `g T` lands a reader here and the panel's visible heading alone does
+// not name a focusable container. A page key's arrival has to say where it arrived — the
+// other direct destinations are named by a leaf link, a decision row, or a Page-map marker
+// — or the press is silent to exactly the reader who cannot see the ring it painted. The
+// same reason the reference dialog carries a role and a label beside its -1.
// `group` rather than `list`: the box holds run headings as well as threads, so a list
// role fails `aria-required-children` outright and leaves a screen reader announcing a list
// with no items. The name is what the landing needed; the role is only there because a bare
@@ -1428,11 +1424,9 @@ const generalHint = () => (designOn ? "Comment on the layer" : "Comment on the p
// paint and the whole layer stops on the reference. The comment above already calls the
// two a pair; this is the pair being one thing rather than two that agree by hand.
const PANEL_SAY = {
- // The page's c brought the reader here; this one puts them in the box. Same letter
- // twice because it is the same intent one scope further in, which is how the rest
- // of the register reads too — g names a list and then a member of it. The box says
- // the same key from its own placeholder, so the second press is discoverable from
- // the panel without the reference open.
+ // From the Threads list this puts the reader in the page-comment box. Page c reaches
+ // the same box directly; this is the same contextual intent from a surface whose local
+ // w and / commands remain useful until the reader asks to write.
id: "comment.write",
keys: ["c"],
does: () => generalHint(),
@@ -1454,6 +1448,12 @@ const PANEL_SAY = {
// no box to be the nearer answer, and standingConversation reads the box rather than
// the class, so the press there is the general box's after all.
when: () => !fabAnchorAt() && !standingConversation(),
+ returnFrame: () => ({
+ active: () => generalRow.contains(documentFocused()),
+ close: () => generalInput.blur(),
+ does: "Return to the thread panel",
+ line: "back to threads",
+ }),
run: () => generalInput.focus({ preventScroll: true }),
};
@@ -1463,9 +1463,8 @@ const syncGeneral = wireInput(generalInput, {
// design mode knows their remark is about the layer as a whole.
hint: generalHint,
// The box's own address: unfocused, the placeholder reads "Comment on the page · c".
- // That c is the panel's own and the second press of the page's. One key rather than a
- // chord, because this box is the panel's own and the scope that offers it is the one
- // the reader is standing in.
+ // The same c reaches this box from the page or from the panel list. One key rather than
+ // a chord, because “comment” is the intent in both contexts.
//
// Read off the row that answers the press rather than spelled here, which is the rule
// the reference states about itself: a fact about a binding written somewhere the
@@ -1548,14 +1547,11 @@ approveBtn.onclick = async () => {
// whole keyboard under the reference overlay. Both walks read the one declaration, where
// two guards in two functions had drifted.
//
-// Escape is a binding like any other. It was a ladder of its own — a says/out pair per
-// branch of a scene() function, plus a hand-written sentence in the reference that listed
-// six of its eight rungs — and as a row the rung is whichever scope in reach binds it
-// first, said and run off one object. What that retires is a contract a widget used to
-// keep by hand: a control declaring its own Escape had to consume the press, or the
-// runtime's ladder ran behind it and closed the panel under a line that promised one
-// action. The dispatcher runs the innermost rung and no other, so the promise is
-// structural.
+// Escape is a binding like any other. A control's own inner step precedes the latest
+// command return frame; generic text and pointer/traversal fallbacks stand behind it.
+// The dispatcher runs the first live row and no other. It alone captures and pushes
+// command frames, and declaration validation refuses an incomplete return contract, so
+// an entry cannot restore at a different point on its key and reference routes.
const pageParts = (sel) =>
[...document.querySelectorAll(sel)].filter((el) => !inChrome(el));
@@ -1643,14 +1639,69 @@ const standingItem = () => {
// which is what is left when they are standing nowhere in it. An element anchor answers in
// its own word (a figure, a card), the way the panel names one.
//
-// Three of the four are a box to write in and say so in the same sentence, so the sentence
-// is written once here and the word is what varies. The fourth is not a comment at all but
-// the room the comments are in, and states its own words rather than being bent to the
-// pattern — naming it by word alone is what produced "comment on the comments".
+// Every destination is a box to write in and says so in the same sentence; the word is
+// what varies.
const commenting = (word) => ({
does: `Comment on the ${word}`,
line: `comment on the ${word}`,
});
+function workspaceControlRoute(control) {
+ if (!control || control === document.body) return () => null;
+ const decision = control?.closest?.(".lf-decisions-row[data-lf-at]");
+ if (decision) {
+ const target = decision.dataset.lfAt;
+ return () =>
+ [...decisionsPanel.querySelectorAll(".lf-decisions-row[data-lf-at]")].find(
+ (row) => row.dataset.lfAt === target,
+ ) ?? null;
+ }
+ const thread = control?.closest?.(".lf-thread[data-id]");
+ if (thread) {
+ const id = thread.dataset.id;
+ return () =>
+ [...panel.querySelectorAll(".lf-thread[data-id]")].find(
+ (row) => row.dataset.id === id,
+ ) ?? null;
+ }
+ const leaf = control?.closest?.(".lf-others-panel a[href]");
+ if (leaf) {
+ const href = leaf.href;
+ return () =>
+ [...othersPanel.querySelectorAll("a[href]")].find((link) => link.href === href) ??
+ null;
+ }
+ return () => (control?.isConnected ? control : null);
+}
+const workspaceState = () => ({
+ panel: panelIsOpen(),
+ tray: currentTray(),
+ control: workspaceControlRoute(documentFocused()),
+});
+function restoreWorkspace(state) {
+ const { panel: hadPanel, tray } = state;
+ if (tray) showTray(tray);
+ else if (hadPanel) {
+ showTray(null);
+ setPanel(true);
+ } else {
+ showTray(null);
+ setPanel(false);
+ }
+ return state.control();
+}
+const composerReturnFrame = () => ({
+ active: () => composerOpen,
+ close: dismissFab,
+ does: "Return to where you were",
+ line: "back",
+});
+const boxReturnFrame = (held, box, does = "Return to the thread") => ({
+ active: () =>
+ held?.isConnected && (containsAcross(held, focused()) || box === focused()),
+ close: () => box.blur(),
+ does,
+ line: "back to thread",
+});
const commentDestination = () => {
const anchor = fabAnchorAt();
if (anchor)
@@ -1659,31 +1710,39 @@ const commentDestination = () => {
anchor.quote ? "selection" : itemWord(elementById(anchor.section)) || "item",
),
go: focusFabComment,
+ returnFrame: composerReturnFrame,
};
const inline = livingMargin?.activeInlineThread();
const inlineBox = inline && conversationInput(inline);
const said =
standingConversation() ?? (inlineBox ? { held: inline, box: inlineBox } : null);
- if (said) return { ...commenting("thread"), go: () => landIn(said) };
+ if (said)
+ return {
+ ...commenting("thread"),
+ go: () => landIn(said),
+ returnFrame: () => boxReturnFrame(said.held, said.box),
+ };
const here = standingItem();
- if (here) return { ...commenting(itemWord(here)), go: () => commentOnItem(here) };
- // Standing nowhere the press can name, so it means "take me to the conversation" and
- // lands on the list rather than in a box: the ring is visible, t/T walk on from it, and
- // w and / are live, because the scope the reader is now standing in is the panel's
- // rather than a text box's. Landing in the general box put them in the one place in the
- // panel where the panel's own keys are all shadowed — TYPING claims a letter before
- // PANEL can — so the reader who pressed c to reach the threads had to press Escape
- // before they could use them. The box is one more c away (PANEL's own row), which is the
- // shape of every other way in: a scope names its keys, and typing is a scope you enter.
+ if (here)
+ return {
+ ...commenting(itemWord(here)),
+ go: () => commentOnItem(here),
+ returnFrame: composerReturnFrame,
+ };
return {
- does: "Go to the threads",
- // Not "threads": that is the t/T walk's word on the same line, and the two are
- // different capabilities — one goes to the room, the other steps through what is in
- // it. A line printing one word twice leaves the keycaps to say which is which.
- line: "go to threads",
+ ...commenting("page"),
go: () => {
setPanel(true);
- threadsBox.focus({ preventScroll: true });
+ generalInput.focus({ preventScroll: true });
+ },
+ returnFrame: () => {
+ const workspace = workspaceState();
+ return {
+ active: () => panelIsOpen() && generalRow.contains(documentFocused()),
+ close: () => restoreWorkspace(workspace),
+ does: "Return to where you were",
+ line: "back",
+ };
},
};
};
@@ -1691,11 +1750,10 @@ const hasCapturedTarget = () => Boolean(fabAnchorAt());
// c goes where commenting happens: a live selection gets the composer (what the floating
// button does), an element click's pending 💬 gets that, an open thread the reader is
// standing in gets its own reply box, the item they are standing in gets the box belonging
-// to it, and otherwise the conversation itself, the panel opening and the list taking the
-// focus — the general box being the panel's own c, one press further in. Never the panel's
-// collapse: c doubled as the toggle once, so with the panel standing open the one key that
-// promised "comment" answered "close", and no shortcut reached the box. Backing out is
-// Escape's, which already closes the panel rung by rung.
+// to it, and otherwise the page's general box. That box lives in Threads, but c names and
+// focuses the box directly; g T independently names the list. Never the panel's collapse:
+// c doubled as the toggle once, so with the panel standing open the key that promised
+// “comment” answered “close”. Backing out is the entry's return frame.
//
// Standing outranks the page and not the pointer: a reader who has just selected words or
// raised the 💬 on something has said what they mean more recently than the focus they left
@@ -1759,9 +1817,9 @@ const letGo = () => document.body.focus({ preventScroll: true });
// two-step shelf, a text box) still own their deliberate unwind step.
const browserDismissesTopLayer = () =>
Boolean(document.querySelector(":popover-open, dialog:modal"));
-// The Escape ladder, one definition for every scope that reaches past the focused control,
-// so the thread's, the list's and the page's cannot disagree. It unwinds from where the
-// reader is standing, not from what happens to be open.
+// The fallback Escape reading for state reached without a registered keyboard entry:
+// pointer-opened workspaces, captured targets, and ordinary focus traversal. Commanded
+// entries use the return stack and never infer their inverse from this resulting scene.
//
// So the first rung is theirs: out on the page, the innermost thing they are in is the decision
// they are standing on, and a panel behind them is a layer they are not in. Nothing said
@@ -1833,7 +1891,7 @@ const BACK_OUT = {
// thing the reader just chose. Keep both on the short line and leave this row in the full
// reference until the target is gone.
promoteEscape: () => !hasCapturedTarget() || reactionTokens().length === 0,
- when: () => !browserDismissesTopLayer() && Boolean(rung()),
+ when: () => !returnStack.current() && !browserDismissesTopLayer() && Boolean(rung()),
run: () => rung().out(),
};
@@ -2014,6 +2072,7 @@ const { GO, GOTO, isChordArmed, paintAddresses, setChord } = createAddress({
claimsEsc,
el,
enterPageMap: () => livingMargin?.enterPageMap(),
+ leavePageMap: () => livingMargin?.leavePageMap(),
focused,
focusedThread,
fragmentId,
@@ -2030,12 +2089,17 @@ const { GO, GOTO, isChordArmed, paintAddresses, setChord } = createAddress({
pageParts,
paintHere,
panelCovers,
+ panelIsOpen,
+ pageMapIsActive: () => livingMargin?.pageMapIsActive() ?? false,
placeThreadEdge,
resolveAnchor,
saying,
seenScroller,
setPanel,
showTray,
+ currentTray,
+ workspaceState,
+ restoreWorkspace,
startsAt,
scrollToElement,
threadsBox,
@@ -2256,12 +2320,11 @@ const inTheBox = () => panel.contains(documentFocused());
// climbs further and answers for a seat on the page too — the two readings stayed apart
// rather than one standing in for the other.
const focusedThreadOf = () => documentFocused()?.closest?.(".lf-thread");
-// Where a box hands the reader back to, which is the rung `c` came down. It asked
-// `.lf-thread` and the panel alone, so the two boxes outside the chrome — a conversation
-// seated on the page, and each thread on that seat — had no way out but the page's own
-// "let go": one press in from the thread and one press out to nothing at all, which is
-// the arithmetic the keyboard-is-a-stack rule exists to keep. The climb is
-// `heldConversation`'s, so this is the same element `c` named on the way in.
+// Where a box reached by Tab or pointer hands the reader back. Keyboard entry carries its
+// own captured return frame before this fallback is reached. This once asked only for
+// `.lf-thread` and the panel, so the two boxes outside the chrome — a conversation seated
+// on the page, and each thread on that seat — had no relation to return through. The climb
+// is `heldConversation`'s, the same relation contextual `c` uses when it names a thread.
//
// A seat holding no thread yet has no standing place of its own. A widget control that
// explicitly sends the reader into that box can supply its own return through
@@ -2356,14 +2419,12 @@ const TYPING = {
// letters spent on a panel that might be shut, promised by the key line over prose the
// presses said nothing about.
//
-// `c` is the one row here whose subject is not this list, and it is the rule read one
-// step further rather than the rule bending: the page's `c` follows the reader and is
-// what lands them here, and this is the same intent one scope in, the way `g` names a
-// list and then a member of it. The row's own comment carries where it stands down, so
-// the page's answer is the one that runs wherever the page has a nearer one.
+// `c` is the one row here whose subject is not this list: it carries the page's contextual
+// comment intent into the general box. The row's own guard carries where it stands down,
+// so the page's nearer selection, item, or conversation answer wins there.
//
// Standing in the panel is where its focus is, not merely that it is open: the Threads
-// button is the banner's, so opening by pointer leaves the reader outside, and `c`, `t`,
+// button is the banner's, so opening by pointer leaves the reader outside, and `g T`, `t`,
// Tab or a click on a thread is what puts them in. The same line `THREAD` draws one step
// further in, which is why that scope sits before this one and its rows shadow these.
// Whether the page has this scope at all is not a question the log answers: every page
@@ -2384,9 +2445,9 @@ const PANEL = {
// so the reader learns one idea and reaches it two ways rather than learning
// "needs you" beside it.
//
- // A narrowing is a mode, so the row states it as one: the sentence and the line
- // both turn on whether it stands, and Escape takes it off through the rung ladder
- // rather than through a second binding here. Dead while there is nothing waiting
+ // A narrowing is a mode, so the row states it as one: the sentence and line turn
+ // on whether it stands, and a successful keyboard activation pushes its return
+ // frame. The scene rung remains only for pointer activation. Dead while there is nothing waiting
// and nothing hidden, which is the same fact that greys the control — and dead
// before the log arrives, which is the one part of that the standing narrowing
// cannot say for itself: `needsYou` is a flag the reader set, and it outlives a
@@ -2403,6 +2464,12 @@ const PANEL = {
runtime.statePhase === "ready" &&
(conversationRuntime.needsYou ||
conversationRuntime.threadList.some(awaitsReader)),
+ returnFrame: () => ({
+ active: () => panelIsOpen() && conversationRuntime.needsYou,
+ close: () => needsBtn.click(),
+ does: "Show every thread again",
+ line: "show all",
+ }),
run: () => needsBtn.click(),
},
{
@@ -2414,17 +2481,25 @@ const PANEL = {
does: "Find in the threads",
line: "find",
also: findInput,
+ returnFrame: () => ({
+ active: () => panelIsOpen() && (findInput === documentFocused() || narrowed()),
+ close: () => {
+ if (widen()) return false;
+ findInput.blur();
+ },
+ does: () =>
+ narrowed() ? "Show every thread again" : "Leave the thread search",
+ line: () => (narrowed() ? "show all" : "back to threads"),
+ }),
run: () => {
findInput.focus();
findInput.select();
},
},
- // Last, because the first contextual chip is this scope's first live row. Standing
- // here, `w` and `/` are the only rows that can ever hold that slot — inside a thread
- // THREAD is nearer, inside a box TYPING claims the letters, outside the panel this scope
- // is not standing — so a `c` in front of them is the two keys this landing exists to
- // expose going unadvertised at the one place they work. The second press has a surface
- // of its own: the box says the key in its placeholder.
+ // Last, because `w` and `/` are the list's own operations while this is a contextual
+ // route through it. The latest return frame already owns the first key-line slot; the
+ // remaining one should say what the list can do. The page-comment box advertises `c`
+ // in its own placeholder, and the complete reference retains this row.
PANEL_SAY,
],
};
@@ -2457,6 +2532,11 @@ const THREAD = {
':scope > .lf-thread-actions > .lf-reopen:not(:disabled, [aria-disabled="true"])',
),
),
+ returnFrame: () => {
+ const thread = focusedThread();
+ const box = thread && conversationInput(thread);
+ return box ? boxReturnFrame(thread, box) : null;
+ },
// Find the thread's own compose row rather than the first textarea: a message may
// contain a widget with an editor of its own before the reply box in DOM order.
run: () => {
@@ -2668,13 +2748,9 @@ const PAGE = {
keys: ["c"],
// One key, four destinations, and the surfaces name the one in front of the reader:
// a live selection, the item a click raised the 💬 on, the box belonging to whatever
- // the reader is standing in, or — when none of those is in hand — the conversation
- // itself. "Comment" covered them all and so promised none of them.
- //
- // The last of the four used to be "comment on the page" and put the reader straight
- // into the general box; it now names the room, and the panel's own c names the box
- // in it. Both words are stated beside the press they belong to, so the sentence, the
- // key line and what the press does cannot come to disagree.
+ // the reader is standing in, or — when none of those is in hand — the page itself.
+ // "Comment" covered them all and so promised none of them. All four enter their
+ // actual box; the panel's contextual c reaches the same general box from its list.
does: () => commentDestination().does,
line: () => commentDestination().line,
// A selection made before the anchor pass has run can't be quoted yet, and
@@ -2682,6 +2758,10 @@ const PAGE = {
// waits, and the row's own liveness is where that is said rather than a refusal
// inside run that no surface can see.
when: () => anchoringReady || !pageSelection(),
+ returnFrame: () => {
+ updateFab();
+ return commentDestination().returnFrame?.() ?? null;
+ },
run: commentKey,
},
{
@@ -2859,10 +2939,12 @@ const PAGE = {
};
// The stack, innermost first, and the whole of what the runtime says about the order. The
-// element scopes splice in where ELEMENTS stands — between the two modes that suspend the page
-// and the page's own, because a widget's control shadows the page and nothing shadows an armed
-// chord or the reference — and every other reading is taken from here: the dispatcher and the
-// line walk it as it stands, the reference walks it backwards.
+// Element scopes splice in where ELEMENTS stands. RETURN follows that placeholder in this
+// canonical list; the dispatcher places it at the dynamic boundary after the exact control
+// and before generic typing and ancestor rows, so an input can clear its own query before
+// leaving while a plain composer returns in the one Escape its entry earned. Every reading
+// starts from this stack: the dispatcher and line walk it inward, and the reference walks it
+// backwards.
//
// Three lists said this, and the third was the reference's own, in its own order, holding the
// same eight scopes by hand. A mode left out of that one was a mode the reference never named
@@ -2870,6 +2952,8 @@ const PAGE = {
// not declared the way a widget's are. A list that must be edited in step with another is the
// same bug waiting on the next mode.
const ELEMENTS = Symbol("the scopes of the focused element");
+const returnStack = createReturnStack({ focused, paintHere, readingBlock });
+const { RETURN } = returnStack;
const SCOPES = [
HELP,
SHORTCUT_SHELF,
@@ -2878,6 +2962,7 @@ const SCOPES = [
REACT,
SELECT,
ELEMENTS,
+ RETURN,
VERSIONS,
COMPOSER,
TYPING,
@@ -2955,6 +3040,8 @@ const { availableCommands, executeCommand, readerIn, shadow, stack } = createDis
paintHere,
REACT,
recoveredLabelFocus,
+ RETURN,
+ returnStack,
SCOPES,
scopesFor,
setChord,
@@ -3691,13 +3778,21 @@ const savedComposer = selectionComposerRuntime.pendingComposer();
// failure cannot make either promise, so controls and top-layer UI remain unavailable.
function presentPage() {
if (document.body.hasAttribute(PAGE_PAINT_ATTRIBUTE.presented)) return;
- document.body.setAttribute(PAGE_PAINT_ATTRIBUTE.presented, "1");
// Anchors are durable coordinates, so their pass and every route that can mint one
// begin only after replay has reconciled the authored document. An early native text
// selection can then resolve against the standing DOM, while a retired passage cannot
// leave a composer carrying its authored words.
anchoringReady = true;
- paintAnchors();
+ try {
+ paintAnchors();
+ } catch (error) {
+ anchoringReady = false;
+ throw error;
+ }
+ // The stamp is the promise that every semantic prerequisite above succeeded, not merely
+ // that presentation was attempted. Keep it absent when a malformed widget makes the
+ // anchor reading fail, so durable controls remain withheld on that partial page.
+ document.body.setAttribute(PAGE_PAINT_ATTRIBUTE.presented, "1");
updateFab();
paintHere();
landArrival();
diff --git a/skills/leaf/assets/runtime/chrome-layout.js b/skills/leaf/assets/runtime/chrome-layout.js
index 06b4a52c4..2372bfbb1 100644
--- a/skills/leaf/assets/runtime/chrome-layout.js
+++ b/skills/leaf/assets/runtime/chrome-layout.js
@@ -104,12 +104,13 @@ export function createChromeLayout({
// so the invoker has to be given its focus back: raising the panel is not a request to
// leave where the reader was standing, and the toggle that lost it would otherwise hold
// aria-expanded with no ring on it and hand the reader's next Space to a button they
- // never chose. A reader who asked to go in says so with the press that takes them — `c`
- // focuses the list itself — and setPanel's own handoff is the other thing that moves them.
+ // never chose. A reader who asked to go in says so with the press that takes them —
+ // `g T` focuses the list and `c` focuses its requested box — and setPanel's own handoff
+ // is the other thing that moves them.
function showPanelLayer() {
- // `c` says "take me to the conversation" whether or not the panel is already up, so
- // this is asked again about a panel that is already showing. Nothing to redo, and the
- // focus below would otherwise fire against a reader already standing inside.
+ // Both a comment destination and Threads navigation may ask for a panel that is already
+ // showing. Nothing to redo, and the focus below would otherwise fire against a reader
+ // already standing inside.
if (panel.open) return;
const invoker = document.activeElement;
panel.show();
diff --git a/skills/leaf/assets/runtime/keyboard/address.js b/skills/leaf/assets/runtime/keyboard/address.js
index f6b427101..6820c95d2 100644
--- a/skills/leaf/assets/runtime/keyboard/address.js
+++ b/skills/leaf/assets/runtime/keyboard/address.js
@@ -15,6 +15,7 @@ export function createAddress({
claimsEsc,
el,
enterPageMap,
+ leavePageMap,
focused,
focusedThread,
fragmentId,
@@ -31,12 +32,17 @@ export function createAddress({
pageParts,
paintHere,
panelCovers,
+ panelIsOpen,
+ pageMapIsActive,
placeThreadEdge,
resolveAnchor,
saying,
seenScroller,
setPanel,
showTray,
+ currentTray,
+ workspaceState,
+ restoreWorkspace,
startsAt,
scrollToElement,
threadsBox,
@@ -145,6 +151,7 @@ export function createAddress({
setPanel(true);
threadsBox.focus({ preventScroll: true });
},
+ active: panelIsOpen,
},
{
id: "navigation.panel.decisions",
@@ -156,6 +163,7 @@ export function createAddress({
showTray("decisions");
(decisionRows()[0] ?? decisionsPanel).focus({ preventScroll: true });
},
+ active: () => currentTray() === "decisions",
},
{
id: "navigation.panel.leaves",
@@ -167,6 +175,7 @@ export function createAddress({
showTray("leaves");
(othersLinks()[0] ?? othersPanel).focus({ preventScroll: true });
},
+ active: () => currentTray() === "leaves",
},
{
id: "navigation.page-map",
@@ -175,6 +184,8 @@ export function createAddress({
line: "Page map",
when: () => true,
go: enterPageMap,
+ active: pageMapIsActive,
+ close: leavePageMap,
},
];
const ADDRESSES = [
@@ -431,6 +442,18 @@ export function createAddress({
does: destination.does,
line: destination.line,
when: () => !aimedList && destination.when(),
+ returnFrame: () => {
+ const workspace = workspaceState();
+ return {
+ active: destination.active,
+ close: () => {
+ destination.close?.();
+ return restoreWorkspace(workspace);
+ },
+ does: `Return from ${destination.line}`,
+ line: "back",
+ };
+ },
run: () => {
setChord(false);
destination.go();
@@ -489,10 +512,9 @@ export function createAddress({
id: "navigation.address.back",
// Two presses in, two presses out. `g` opens the window and a letter names a list
// inside it. The complete routes stay fixed while that letter turns pressed, so one
- // Escape gives the letter back and the next closes the window. It took both at once,
- // which is the same drift `c` had at the panel: a reader who had narrowed to the
- // wrong list wanted the other one, and cancelling put them back on the page, pressing
- // `g` again to reach a window that had been standing the whole time.
+ // Escape gives the letter back and the next closes the window. Collapsing both at
+ // once stranded a reader who had narrowed to the wrong list back on the page, making
+ // them press `g` again to reach a window that had been standing the whole time.
keys: ["Escape"],
chordControl: true,
does: () => (aimedList ? "Back to the lists" : "Cancel the chord"),
diff --git a/skills/leaf/assets/runtime/keyboard/bindings.js b/skills/leaf/assets/runtime/keyboard/bindings.js
index 3afe0381e..12e48f3ff 100644
--- a/skills/leaf/assets/runtime/keyboard/bindings.js
+++ b/skills/leaf/assets/runtime/keyboard/bindings.js
@@ -240,6 +240,14 @@ export function answers(binding, ev) {
export function checked(rows, where) {
const ids = new Set();
rows.forEach((row, i) => {
+ if (row.returnFrame !== undefined && typeof row.returnFrame !== "function")
+ throw new Error(
+ `leaf: ${row.id ?? `row ${i} of ${where}`} has a returnFrame that is not a function`,
+ );
+ if (row.returnFrame && !row.run)
+ throw new Error(
+ `leaf: ${row.id ?? `row ${i} of ${where}`} declares a return frame but runs no entry`,
+ );
if (row.native && !row.run)
throw new Error(
`leaf: row ${i} of ${where} leaves the native press to the platform but runs no result`,
diff --git a/skills/leaf/assets/runtime/keyboard/dispatch.js b/skills/leaf/assets/runtime/keyboard/dispatch.js
index 32903abdb..6cc66afe3 100644
--- a/skills/leaf/assets/runtime/keyboard/dispatch.js
+++ b/skills/leaf/assets/runtime/keyboard/dispatch.js
@@ -16,6 +16,8 @@ export function createDispatch({
paintHere,
REACT,
recoveredLabelFocus,
+ RETURN,
+ returnStack,
SCOPES,
scopesFor,
setChord,
@@ -50,11 +52,19 @@ export function createDispatch({
const typing = takesLetters(active);
return SCOPES.flatMap((scope) => {
if (scope === ELEMENTS) {
- if (!typing) return elementStack;
+ if (!typing) return [...elementStack, RETURN];
const own = elementStack.filter(({ el }) => el === active);
const ancestors = elementStack.filter(({ el }) => el !== active);
- return [...own, TYPING, ...ancestors];
+ // A control's own state is the innermost layer. The command frame that entered
+ // it comes next, before the generic text-box escape and any containing widget:
+ // `/` in Threads can clear its query before returning, while `c` into a plain
+ // composer returns in the same one Escape that entered it.
+ return [...own, RETURN, TYPING, ...ancestors];
}
+ // RETURN is declared in SCOPES so every projection sees it. The element placeholder
+ // above has already placed it at the dynamic boundary between the exact control and
+ // the generic/ancestor scopes, so the static slot contributes no second copy.
+ if (scope === RETURN) return [];
if (scope === TYPING && typing) return [];
return scope;
}).filter(standing);
@@ -130,8 +140,10 @@ export function createDispatch({
if (!matched.row.native) ev.preventDefault();
if (ev.repeat && !matched.row.repeat) return true;
beforeCommand?.(matched.row);
- if (matched.row.run) matched.row.run(matched.binding);
- else recovered.click();
+ returnStack.invoke(matched.row, matched.binding, () => {
+ if (matched.row.run) return matched.row.run(matched.binding);
+ return recovered.click();
+ });
return true;
}
nearer.past(scope);
@@ -192,11 +204,16 @@ export function createDispatch({
}
return available;
}
- function executeCommand(id) {
+ function executeCommand(id, origin = null) {
const command = commandFor(id);
if (!command) return false;
beforeCommand?.(command.row);
- command.row.run(command.binding);
+ returnStack.invoke(
+ command.row,
+ command.binding,
+ () => command.row.run(command.binding),
+ origin,
+ );
return true;
}
diff --git a/skills/leaf/assets/runtime/keyboard/reference.js b/skills/leaf/assets/runtime/keyboard/reference.js
index cce4154ad..f712ac9ba 100644
--- a/skills/leaf/assets/runtime/keyboard/reference.js
+++ b/skills/leaf/assets/runtime/keyboard/reference.js
@@ -9,7 +9,7 @@ import {
word,
} from "./bindings.js";
import { completeRowSteps, keySequence, neutralStates } from "./presentation.js";
-import { focusDestination } from "../widget-elements.js";
+import { captureReturnPlace, restoreReturnPlace } from "./return-stack.js";
export function createReference({
byCommand,
@@ -131,20 +131,14 @@ export function createReference({
// nothing: focusing `body` resets the browser's sequential focus navigation starting
// point, so the reader's next Tab began at the top of the document rather than beside
// the words they had been reading.
- let helpFrom = null;
- // Where they were reading when they had no control, which is the other half of the same
- // question and needs a different answer, because standing on a page block is not the
- // same as standing nowhere: `standingItem` would read that block as the item the reader
- // is in, and `c` would offer to comment on it rather than to open the comments. So the
- // block is focused and then let go of. Focus is what moves the sequential starting
- // point; blur leaves it where it was moved to (tests/CLAUDE.md says why the pair part
- // this way). The reader ends where `letGo` would have put them and their next Tab
- // carries on from the words in front of them.
- let helpPlace = null;
+ let helpOrigin = null;
+ // The shared return-place primitive records a control or the current reading block. A
+ // block is focused and then let go of, moving the browser's sequential starting point
+ // without turning prose into a standing item.
// The layers the reference was opened over. A modal dialog clears every auto popover on
// its way into the top layer — the platform's rule, not Leaf's — so the overlay that
// exists to say what the versions menu's keys are was also what took the menu away, and
- // `helpFrom` then pointed into a layer that was no longer painted: the restore reached a
+ // the stored control then pointed into a layer that was no longer painted: the restore reached a
// row in a hidden popover and focus fell to the body. Note what stood, put it back before
// the restore, and the exemption costs the reader nothing again.
let helpLayers = [];
@@ -181,13 +175,11 @@ export function createReference({
// The dialog itself remains a focus stop, so either route keeps the page suspended.
const preserveSelection = open && Boolean(pageSelection());
const handBack = !open && restoreFocus && helpEl.contains(focused());
- const restore = handBack ? helpFrom : null;
- const place = handBack ? helpPlace : null;
+ const origin = handBack ? helpOrigin : null;
+ const restore = origin?.control ?? null;
const closing = !open && helpEl.open;
if (open && !helpOpen) {
- const here = focused();
- helpFrom = here && here !== document.body ? here : null;
- helpPlace = helpFrom ? null : readingBlock();
+ helpOrigin = captureReturnPlace({ focused, readingBlock });
helpLayers = [...document.querySelectorAll(":popover-open")];
commandsAtOpen = availableCommands();
}
@@ -227,10 +219,13 @@ export function createReference({
characterToggle.onclick = () => {
setCharacterShortcuts(!characterShortcutsOn());
// Re-enter through the page so the dispatch snapshot sees the newly available
- // bindings before this modal scope shadows them. Keep focus on the preference that
- // caused the change instead of returning to search.
- showHelp(false);
+ // bindings before this modal scope shadows them. This rebuilds one surface rather
+ // than entering another: preserve the place the original reference displaced and
+ // focus the replacement preference instead of minting either control as an origin.
+ const origin = helpOrigin;
+ showHelp(false, false);
showHelp(true);
+ helpOrigin = origin;
helpEl.querySelector(".lf-help-shortcuts").focus({ preventScroll: true });
};
preference.append(meta, characterToggle);
@@ -311,12 +306,19 @@ export function createReference({
meta.textContent = availableWhere(row, scopeTitle, scopeReach);
return;
}
+ // Closing a native modal may leave this soon-hidden button focused until
+ // the click finishes. The command's origin is the place the reference
+ // displaced, not that transient implementation node. Run after the close's
+ // focus restoration too, so the command's own destination wins the frame.
+ const origin = helpOrigin;
showHelp(false);
- if (!executeCommand(id)) {
- showHelp(true);
- helpEl.querySelector(".lf-help-meta").textContent =
- "That command is no longer available";
- }
+ requestAnimationFrame(() => {
+ if (!executeCommand(id, origin)) {
+ showHelp(true);
+ helpEl.querySelector(".lf-help-meta").textContent =
+ "That command is no longer available";
+ }
+ });
};
actionCell.append(command);
commandButtons.push(command);
@@ -337,7 +339,7 @@ export function createReference({
total += entries.length;
return { el: t, entries };
};
- for (const scope of declaredStack(helpFrom)) {
+ for (const scope of declaredStack(helpOrigin?.control)) {
// A scope the reader is standing in is filtered by each row's own liveness, because
// they can see which state they are in and a row that would refuse the press must
// not be on screen. A scope they are merely near is listed whole: a row's `when`
@@ -482,16 +484,7 @@ export function createReference({
// press's own focus is the browser's default action, still to come — a restore made from
// out here would be putting focus back for the click to take again.
paintHere();
- if (!open && restore) {
- if (restore.isConnected) focusDestination(restore);
- else
- requestAnimationFrame(() => {
- if (restore.isConnected) focusDestination(restore);
- });
- } else if (!open && place?.isConnected) {
- focusDestination(place);
- place.blur();
- }
+ if (!open && origin) restoreReturnPlace(origin);
}
const helpStops = () =>
diff --git a/skills/leaf/assets/runtime/keyboard/return-stack.js b/skills/leaf/assets/runtime/keyboard/return-stack.js
new file mode 100644
index 000000000..ee9777902
--- /dev/null
+++ b/skills/leaf/assets/runtime/keyboard/return-stack.js
@@ -0,0 +1,115 @@
+import { word } from "./bindings.js";
+import { focusDestination } from "../widget-elements.js";
+
+export function captureReturnPlace({ focused, readingBlock }) {
+ const control = focused();
+ return control && control !== document.body
+ ? { control, reading: null }
+ : { control: null, reading: readingBlock() };
+}
+
+export function restoreReturnPlace({ control, reading }) {
+ if (control) {
+ if (control.isConnected) focusDestination(control);
+ // Reconciliation may replace a control in the same task. Its first paint is
+ // asynchronous, so give that exact node one frame to reconnect before conceding.
+ if (!control.isConnected || !control.matches(":focus"))
+ requestAnimationFrame(() => {
+ if (control.isConnected) focusDestination(control);
+ });
+ return;
+ }
+ if (reading?.isConnected) {
+ focusDestination(reading);
+ reading.blur();
+ return;
+ }
+ document.body.focus({ preventScroll: true });
+}
+
+// A keyboard entry is not travel: it temporarily puts another surface in front of the
+// reader, and Escape owes them the exact place and workspace state the entry displaced.
+// Commands declare that second half as `returnFrame`; the dispatcher is the only code
+// that captures and pushes it. Keeping the stack here makes one entry one frame and one
+// successful Escape one pop, instead of asking the resulting scene to guess how it arose.
+export function createReturnStack({ focused, paintHere, readingBlock }) {
+ const frames = [];
+
+ function descriptorFor(row, binding) {
+ if (!row.returnFrame) return null;
+ const frame = row.returnFrame(binding);
+ if (frame == null) return null;
+ if (
+ !frame ||
+ typeof frame.active !== "function" ||
+ typeof frame.close !== "function" ||
+ !frame.does ||
+ !frame.line
+ )
+ throw new TypeError(
+ `leaf: ${row.id} must return active, close, does, and line from returnFrame`,
+ );
+ return frame;
+ }
+
+ // Capture before the command runs; publish only after it has really entered the layer.
+ // A liveness guard that changed during the command therefore cannot leave a phantom
+ // frame behind.
+ function invoke(row, binding, run, suppliedOrigin = null) {
+ const frame = descriptorFor(row, binding);
+ const origin = frame
+ ? (suppliedOrigin ?? captureReturnPlace({ focused, readingBlock }))
+ : null;
+ const result = run();
+ prune();
+ if (frame?.active()) frames.push({ ...frame, origin });
+ return result;
+ }
+
+ function prune() {
+ while (frames.length && !frames.at(-1).active()) frames.pop();
+ }
+
+ function current() {
+ prune();
+ return frames.at(-1) ?? null;
+ }
+
+ function back() {
+ const frame = current();
+ if (!frame) return false;
+ // A layer may have a deeper, non-command state of its own. Thread and diff filters,
+ // for example, clear a live query first and deliberately retain the entry frame.
+ const replacement = frame.close();
+ if (replacement === false) {
+ paintHere();
+ return true;
+ }
+ frames.pop();
+ restoreReturnPlace(
+ replacement instanceof Element
+ ? { ...frame.origin, control: replacement }
+ : frame.origin,
+ );
+ paintHere();
+ return true;
+ }
+
+ const RETURN = {
+ title: "After entering a surface",
+ when: () => Boolean(current()),
+ at: () => Boolean(current()),
+ rows: [
+ {
+ id: "navigation.return",
+ keys: ["Escape"],
+ does: () => word(current()?.does),
+ line: () => word(current()?.line),
+ runFromReference: false,
+ run: back,
+ },
+ ],
+ };
+
+ return { RETURN, invoke, current };
+}
diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js
index 217418836..c0daddea6 100644
--- a/skills/leaf/assets/runtime/living-margin.js
+++ b/skills/leaf/assets/runtime/living-margin.js
@@ -1241,6 +1241,11 @@ export function createLivingMargin(dependencies) {
if (!focusPageMap()) openSheet();
}
+ const pageMapIsActive = () => sheet.open || availableRows().includes(focused());
+ function leavePageMap() {
+ if (sheet.open) sheet.close();
+ }
+
function focusMapControl(entry = null) {
const marker = entry ? rows.get(entry.key) : null;
if (marker?.isConnected && marker.checkVisibility()) {
@@ -2394,6 +2399,7 @@ export function createLivingMargin(dependencies) {
},
closePreview: () => closePreview(false),
enterPageMap,
+ leavePageMap,
focusForNavigation,
keyboardRung,
marginTargetAt,
@@ -2401,6 +2407,7 @@ export function createLivingMargin(dependencies) {
openInlineThread,
openPageMapItem,
pageMapItems,
+ pageMapIsActive,
presentedControl,
render,
};
diff --git a/skills/leaf/assets/runtime/version.js b/skills/leaf/assets/runtime/version.js
index 0d32ebb71..08a58cc54 100644
--- a/skills/leaf/assets/runtime/version.js
+++ b/skills/leaf/assets/runtime/version.js
@@ -200,13 +200,13 @@ export function createVersion({
// restores focus to on a hide is the element that had it when the popover showed — not
// the `source`, which buys the anchor and the invoker relationship and nothing about
// focus — so every door into this menu shows it from the button and the way back out is
- // the platform's on all of them: the pointer because the press focuses the button first,
- // `v` because the row focuses it before running that same press, and the reference
- // because it stands a layer back up from that layer's invoker. Scoping the handback to
- // the door rather than to the state is what keeps it off a light dismissal, which
- // restores nothing on purpose: a reader who pressed away into the page is left where
- // they pressed rather than moved to the chooser they pressed away from. Leaf is left
- // with the close, which is the only end state it asks for.
+ // the platform's for pointer entry, because that press focuses the button first. Keyboard
+ // `v` clicks the same invoker without moving focus and its return frame restores the real
+ // origin; the reference stands a layer back up from that invoker before restoring its own
+ // origin. Scoping the platform handback to its door rather than to the state is what keeps
+ // it off a light dismissal, which restores nothing on purpose: a reader who pressed away
+ // into the page is left where they pressed rather than moved to the chooser they pressed
+ // away from. Leaf is left with the close, which is the only end state it asks for.
function closeVersionMenu() {
if (versionMenuIsOpen()) versionMenu.hidePopover();
}
@@ -325,7 +325,8 @@ export function createVersion({
);
// The mode represents the menu standing, not whether it has multiple versions to walk.
// It suspends page shortcuts and owns only the Tab-boundary handoff that a popover does
- // not provide. Escape and light dismissal stay native.
+ // not provide. A keyboard-opened menu has CHOOSER's exact return frame; Escape and light
+ // dismissal remain native for pointer-opened menus.
const VERSIONS = {
title: "In the versions menu",
when: versionsOffered,
@@ -372,12 +373,10 @@ export function createVersion({
],
};
- // v names the chooser, the control wearing the version number, and the menu it opens
- // takes the letter again for the current page — one motion whose second half is a key of
- // the scope the first half stood up, so it costs the page's table no row and holds whether
- // or not this page is behind. Named, because the chip that jumps straight to the current
- // page spells that motion in its tooltip, and because the closed control's own title says
- // the press beside what pressing it does.
+ // v names the chooser, the control wearing the version number, and the menu it opens.
+ // Named, because the chip that jumps straight to the current page spells that motion in
+ // its tooltip, and because the closed control's own title says the press beside what
+ // pressing it does.
const CHOOSER = {
id: "version.open",
keys: ["v"],
@@ -387,16 +386,16 @@ export function createVersion({
// The same predicate the menu's Escape stands on, so the key cannot open a layer the
// way out is not live over. The walk being empty is the menu's business, not this key's.
when: versionsOffered,
- // The control's own press, so the key and the pointer are one gesture: the menu is a
- // popover the button declares, and the browser's invoker is what makes a second press a
- // close. The focus first is what makes the handback the same on both doors — a popover
- // restores focus to whatever had it when it showed, which the pointer leaves as the
- // button of its own accord and this key would otherwise leave as the body, putting a
- // reader who pressed `v` and then Escape on the page rather than back on the chooser.
- run: () => {
- versionBtn.focus();
- versionBtn.click();
- },
+ // The popover is the control's own press, while the keyboard register owns the route
+ // back to the place that pressed v. Programmatically focusing the chooser first made
+ // the browser return there instead, discarding the real origin before the menu opened.
+ returnFrame: () => ({
+ active: versionMenuIsOpen,
+ close: closeVersionMenu,
+ does: "Return from the versions menu",
+ line: "back",
+ }),
+ run: () => versionBtn.click(),
};
let lastVersionsKey = "";
diff --git a/skills/leaf/assets/runtime/widget-api.js b/skills/leaf/assets/runtime/widget-api.js
index c96117343..00d9f20cb 100644
--- a/skills/leaf/assets/runtime/widget-api.js
+++ b/skills/leaf/assets/runtime/widget-api.js
@@ -9,7 +9,7 @@ export { conversationBox } from "./conversation/box.js";
export { conversationInput, landInConversation } from "./conversation/landing.js";
export { DISCLOSE } from "./keyboard/disclosure.js";
export { PRESS, labelOf, walkRows } from "./keyboard/bindings.js";
-export { keys, paintKeys, saying } from "./keyboard/scopes.js";
+export { focused, keys, paintKeys, saying } from "./keyboard/scopes.js";
export {
marginAction,
marginActionState,
diff --git a/skills/leaf/packages/default/widgets/lf-options.js b/skills/leaf/packages/default/widgets/lf-options.js
index 3da8e206a..c06ad6e3e 100644
--- a/skills/leaf/packages/default/widgets/lf-options.js
+++ b/skills/leaf/packages/default/widgets/lf-options.js
@@ -101,6 +101,7 @@ import {
actionAvailable,
actionStands,
conversationInput,
+ focused,
inChrome,
keys,
landInConversation,
@@ -383,6 +384,18 @@ customElements.define(
{
...WRITE_ANOTHER,
when: () => Boolean(this.#words()),
+ returnFrame: () => {
+ const box = this.#words();
+ const layer = this.#addition.input
+ ? box?.closest("form")
+ : box?.closest(".lf-thread, .lf-conversation-thread, .lf-conversation");
+ return {
+ active: () => Boolean(layer?.contains(focused())),
+ close: () => box?.blur(),
+ does: "Return to the option",
+ line: "back to option",
+ };
+ },
run: () => {
if (this.#addition.input) this.#addition.input.focus();
else
diff --git a/skills/leaf/packages/diff/widgets/lf-diff.js b/skills/leaf/packages/diff/widgets/lf-diff.js
index 2628e39b4..5f9657962 100644
--- a/skills/leaf/packages/diff/widgets/lf-diff.js
+++ b/skills/leaf/packages/diff/widgets/lf-diff.js
@@ -7,6 +7,7 @@ import {
announce,
dataBody,
failSoft,
+ focused,
inChrome,
keys,
langForPath,
@@ -473,6 +474,34 @@ customElements.define(
keys: ["/"],
does: "Filter the files in this diff",
line: "filter files",
+ returnFrame: () => ({
+ active: () => {
+ const search = this.reviewTools?.search;
+ const held = focused();
+ const inDiff =
+ this.contains(held) || Boolean(this.shadowRoot?.contains(held));
+ return Boolean(
+ search &&
+ inDiff &&
+ (search.value || this.reviewTools.node.contains(held)),
+ );
+ },
+ close: () => {
+ const search = this.reviewTools?.search;
+ if (search?.value) {
+ this.clearFilter();
+ search.focus({ preventScroll: true });
+ return false;
+ }
+ search?.blur();
+ },
+ does: () =>
+ this.reviewTools?.search.value
+ ? "Show every file again"
+ : "Leave the diff filter",
+ line: () =>
+ this.reviewTools?.search.value ? "show all files" : "back",
+ }),
run: () => this.reviewTools?.search.focus(),
},
{
diff --git a/tests/render_cases_layout.py b/tests/render_cases_layout.py
index 46bb7898b..859739135 100644
--- a/tests/render_cases_layout.py
+++ b/tests/render_cases_layout.py
@@ -1128,6 +1128,7 @@ def go(name, title):
d = host_model.state_home() / "pages" / name
result = CliRunner().invoke(cli_model.cli, ["page", "init", str(d)])
assert result.exit_code == 0, result.output
+ (d / ".fixture-versions").mkdir()
(d / ".fixture-versions" / "v1.html").write_text(
LONG_PAGE.replace("long", f"{title}")
)
diff --git a/tests/test_render_anchors.py b/tests/test_render_anchors.py
index e53f45b1d..df80fb30e 100644
--- a/tests/test_render_anchors.py
+++ b/tests/test_render_anchors.py
@@ -2768,7 +2768,17 @@ def test_the_menu_a_first_version_opens_is_a_menu_it_can_close(browser, serve):
expect(page.locator(".lf-version-row")).to_have_count(1)
page.keyboard.press("Escape")
expect(menu).not_to_be_visible()
- expect(page.locator(".lf-version")).to_be_focused()
+ assert page.evaluate("() => document.activeElement === document.body")
+
+ # A keyboard-opened popover returns to the real origin, not to the chooser used as
+ # its implementation door.
+ origin = page.locator("h1")
+ origin.evaluate("node => node.tabIndex = -1")
+ origin.focus()
+ page.keyboard.press("v")
+ expect(menu).to_be_visible()
+ page.keyboard.press("Escape")
+ expect(origin).to_be_focused()
# The pointer's door reaches the same layer, and the same key ends it.
page.locator(".lf-version").click()
diff --git a/tests/test_render_controls.py b/tests/test_render_controls.py
index a3e690f85..de4e4ca9c 100644
--- a/tests/test_render_controls.py
+++ b/tests/test_render_controls.py
@@ -2827,9 +2827,10 @@ def sweep(where):
sweep("the page as it arrives")
- # The panel, and then its list — which is where `c` lands the reader, and the box it
- # used to land in is one press further in.
- page.keyboard.press("c")
+ # The panel, and then its list — which is where `g T` lands the reader; `c` there
+ # enters its page comment box.
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
sweep("standing on the comment list")
page.keyboard.press("c")
@@ -3011,7 +3012,8 @@ def test_a_covering_sheet_lifts_the_key_line_over_all_of_its_foot(browser, serve
the page and the line is capped clear of it instead."""
page, errors = open_page(browser, serve(ADDRESSED_PAGE, comments=1))
resized(page, 420, 900)
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
expect(page.locator(".lf-page-strip .lf-react-trigger")).to_be_visible()
@@ -3845,8 +3847,8 @@ def offered(page, selector):
# the rest. Without this a key that stops working leaves the walk re-walking the page and
# contributing nothing, which the coverage floor catches only where that scope is a
# rule's sole home: one guard over seven setup steps. The page and the comments raise no
-# surface of their own; `c` and `g T` land on the list, which the walk's own first stop
-# reads.
+# surface of their own; `g T` lands on the Threads list, which the walk's own first stop
+# reads, while page `c` enters its comment box and is exercised separately.
RING_SCOPE_SURFACE = {
"a thread card": (".lf-margin-preview:popover-open", None),
"the page map sheet": (".lf-page-map-sheet[open]", None),
diff --git a/tests/test_render_conversations.py b/tests/test_render_conversations.py
index 51e05ad74..089fbfad8 100644
--- a/tests/test_render_conversations.py
+++ b/tests/test_render_conversations.py
@@ -872,7 +872,7 @@ def test_finding_narrows_the_list_and_says_how_much_of_it_is_left(browser, serve
page, errors = open_page(browser, url)
# Slash belongs to the nearest search scope. Out on the prose it opens page search;
- # Escape returns to the prose, and `c` is the route into the panel, where the same
+ # Escape returns to the prose, and `g T` is the route into the panel, where the same
# press opens that list's own search instead. Read the two landings against each other.
#
# A plain paragraph rather than the body's own middle, which is a widget on this
@@ -887,7 +887,8 @@ def test_finding_narrows_the_list_and_says_how_much_of_it_is_left(browser, serve
expect(page.locator(".lf-panel")).not_to_be_visible()
page.keyboard.press("Escape")
assert page.evaluate("() => document.activeElement === document.body")
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
panel_settled(page)
expect(page.locator(".lf-threads")).to_be_focused()
page.keyboard.press("/")
@@ -957,15 +958,16 @@ def test_the_panel_can_show_only_what_is_waiting_on_the_reader(browser, serve):
page.keyboard.press("w")
expect(page.locator(".lf-panel")).not_to_be_visible()
- # `c` stands the reader on the list, where the key is live and the line says so.
+ # `g T` stands the reader on the list, where the key is live and the line says so.
# The control names it, off the row, so the two cannot come to spell it differently.
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
panel_settled(page)
expect(page.locator(".lf-threads")).to_be_focused()
expect(page.locator(".lf-needs")).to_have_text("Waiting on you (1)")
expect(page.locator(".lf-needs")).to_have_attribute("title", re.compile(r"\(w\)$"))
expect(page.locator(".lf-keyline")).to_contain_text("waiting on you")
- # A second `c` is the general box, and there `w` is a character like any other —
+ # `c` from that list enters the general box, and there `w` is a character like any other —
# the typing scope claims what types one, so the row stands down and the line drops
# it. Escape backs out onto the list and it is live again. Both directions, because
# a key that were live in the box would type nothing and read as a dead keyboard.
@@ -981,6 +983,21 @@ def test_the_panel_can_show_only_what_is_waiting_on_the_reader(browser, serve):
expect(page.locator(".lf-panel-head span")).to_have_text("Showing 1 of 2")
expect(page.locator(".lf-needs")).to_have_attribute("aria-pressed", "true")
+ # Closing the owning surface retires both its narrowing frame and the g T frame below
+ # it. The narrowing itself stays set for a later reopen, but Escape on the page must
+ # neither advertise nor mutate a filter the reader cannot see.
+ page.get_by_role("button", name="Close threads", exact=True).click()
+ panel_settled(page, False)
+ expect(page.locator(".lf-keyline")).not_to_contain_text("show all")
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-needs")).to_have_attribute("aria-pressed", "true")
+ expect(page.locator(".lf-panel")).to_be_hidden()
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
+ panel_settled(page)
+ page.keyboard.press("w")
+ page.keyboard.press("w")
+
# Answering the agent's comment takes it out of the reader's list and hands the
# next word to the agent.
reply = page.locator(f'.lf-thread[data-id="{theirs}"] textarea')
@@ -1003,6 +1020,7 @@ def test_the_panel_can_show_only_what_is_waiting_on_the_reader(browser, serve):
expect(page.locator(".lf-threads > .lf-thread")).to_have_count(2)
expect(page.locator(f'.lf-thread[data-id="{mine}"]')).to_have_count(1)
expect(page.locator(".lf-panel-head span")).to_have_text("Threads")
+
assert errors == []
page.close()
@@ -2857,7 +2875,8 @@ def test_no_ring_the_panel_draws_on_a_walk_down_its_list_is_cut_or_covered(
# typed its keys into the box, which is exactly what the non-vacuity check at the
# end caught: thirty-two landings asserted, none of them on a thread.
page.evaluate("() => document.activeElement?.blur()")
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
walked, faults = 0, []
for key in ("t",) * threads + ("Shift+t",) * threads:
@@ -3532,7 +3551,8 @@ def test_the_room_a_run_heading_takes_follows_the_reader_drawing_the_panel(
# into it as characters. COVERED_TOP answers null for a focus outside the list,
# so every one of those landings agreed with the invariant by never being asked.
page.evaluate("() => document.activeElement?.blur()")
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
faults = []
for key in ("t",) * 8 + ("Shift+t",) * 8:
@@ -3558,16 +3578,14 @@ def test_the_line_offers_the_list_its_own_keys_rather_than_the_way_deeper_in(
browser, serve
):
"""The two contextual chips the line paints for a reader standing on the list have
- to be the keys that act on the list, and nothing else: the line is two chips and the
- More control, so a row in front of these is a row instead of them.
+ to be its exact way back and its first local action: the line is two chips and the
+ More control, so an unrelated row in front of these is a row instead of them.
- `c` brought them here so that `w` and `/` would be live — the general box is where
- the typing scope claims every letter, which is the whole reason the press stops at
- the list. This is the one focus position where those two rows can hold a chip at
- all: inside a thread `THREAD` is nearer, inside a box `TYPING` claims the letters,
- and outside the panel this scope is not standing. So a row in front of them here
- spends the slot the landing exists to fill, which is what the panel's own `c` did
- until it was moved to the end of the scope.
+ `g T` brought them here, so its return frame leads and `w` is the first local action.
+ The general box is where the typing scope claims every letter, which is the whole
+ reason the press stops at the list. Inside a thread `THREAD` is nearer, inside a box
+ `TYPING` claims the letters, and outside the panel this scope is not standing. So an
+ unrelated row in front of them here spends the slot the landing exists to fill.
Read off `:not([hidden])`, because `renderLine` leaves every live row in the DOM and
hides the ones it has no room to paint. `to_contain_text` on the line therefore
@@ -3594,14 +3612,15 @@ def test_the_line_offers_the_list_its_own_keys_rather_than_the_way_deeper_in(
page, errors = open_page(browser, url)
page.evaluate("() => document.activeElement?.blur()")
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
shown = page.locator(".lf-keyline .lf-key:not([hidden])")
expect(shown).to_have_count(2)
- # The list's own key leads: something is waiting, so `w` is live and nearest.
- expect(shown.nth(0)).to_contain_text("waiting on you")
- expect(shown.nth(1)).to_contain_text("close threads")
+ # The entry's exact inverse leads, then the list's first local key.
+ expect(shown.nth(0)).to_contain_text("back")
+ expect(shown.nth(1)).to_contain_text("waiting on you")
# And the press it displaced still works, from the placeholder that advertises it.
expect(page.locator(".lf-general textarea")).to_have_attribute(
diff --git a/tests/test_render_drafts.py b/tests/test_render_drafts.py
index a9b1c60af..c31250877 100644
--- a/tests/test_render_drafts.py
+++ b/tests/test_render_drafts.py
@@ -2724,9 +2724,10 @@ def offsets():
)
assert threads_now == threads_was, "the panel took a key aimed at the document"
- # Into the panel, standing on its list rather than in a box — `c`'s own landing,
+ # Into the panel, standing on its list rather than in a box — `g T`'s landing,
# which travels nothing, so the baseline below is the one the control left.
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
page_was, threads_was = offsets()
diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py
index 8037d3cc9..e68bd8703 100644
--- a/tests/test_render_navigation.py
+++ b/tests/test_render_navigation.py
@@ -1549,6 +1549,7 @@ def disclosure_state():
)
page.keyboard.press("Escape")
expect(page.locator(".lf-panel")).not_to_be_visible()
+ assert page.evaluate("() => document.activeElement === document.body")
# The Asks chord follows the same contract: show the panel and land on its first row.
page.keyboard.press("g")
@@ -1559,6 +1560,28 @@ def disclosure_state():
# which of how many from the tray as it does from the page.
expect(page.locator(".lf-decisions")).to_have_text("Asks (1/1)")
expect(page.locator(CHIPS)).to_have_count(0)
+
+ # A direct destination also remembers the workspace it displaced. Threads replaces
+ # Asks while it stands; one Escape restores both that tray and its exact focused row.
+ ask = page.locator(".lf-decisions-row").first
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
+ expect(page.locator(".lf-panel")).to_be_visible()
+ expect(page.locator(".lf-decisions-panel")).not_to_be_visible()
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-panel")).not_to_be_visible()
+ expect(page.locator(".lf-decisions-panel")).to_be_visible()
+ 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.
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+m")
+ expect(page.locator(".lf-margin-marker:focus")).to_have_count(1)
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-decisions-panel")).to_be_visible()
+ expect(ask).to_be_focused()
+
page.keyboard.press("?")
page.keyboard.press("?")
asks_help = page.locator(".lf-help-section").filter(
@@ -1834,11 +1857,10 @@ def test_no_two_hints_on_the_key_line_say_the_same_word(browser, serve):
"""The line is a row of words with keycaps over them, and the word is what is read.
Two rows sharing one leaves the keycaps to carry the whole difference, which is the
- line failing at the one thing it is for. Two pairs did. The versions menu binds Tab
- and Shift-Tab to leaving it forward and backward, and on a one-version menu both are
- at their boundary and live together — both saying "leave versions". And the page's `c`
- says where the press goes; standing nowhere nameable that is the room the comments are
- in, which said "threads" beside the t/T walk's own "threads".
+ line failing at the one thing it is for. The versions menu once called both Tab
+ directions "leave versions"; a keyboard-opened menu now has its precise Escape return
+ beside the remaining directional handoff. The page's `c` says "comment on the page",
+ distinct from the t/T thread walk.
Both scenes are read, and each is asserted to hold the rows at issue first: a line
that had stopped showing them would report a clean result about a page the reader
@@ -1858,15 +1880,15 @@ def test_no_two_hints_on_the_key_line_say_the_same_word(browser, serve):
hint["commands"] for hint in standing
}, f"the line no longer offers both the comments and the thread walk: {standing}"
- # The menu claims every key but the reference, so its own two rows are the whole
- # scene and the shelf has nothing to add to it.
+ # The registered return frame is nearer than the menu's native Tab handoffs, so the
+ # shortlist contains the actual Escape return and one directional handoff.
page.keyboard.press("Escape")
page.keyboard.press("v")
page.evaluate(RENDERED)
versions = page.evaluate(KEY_LINE_HINTS)
- assert {"version.leave-forward", "version.leave-backward"} <= {
+ assert {"navigation.return", "version.leave-forward"} <= {
hint["commands"] for hint in versions
- }, f"the versions menu no longer offers both ways out at once: {versions}"
+ }, f"the versions menu no longer offers its two visible ways out: {versions}"
for scene, hints in (("the page", standing), ("the versions menu", versions)):
said = {}
@@ -2719,10 +2741,7 @@ def test_the_arrows_say_which_way_the_section_under_the_reader_goes(browser, ser
def test_the_key_line_says_what_a_press_will_do(browser, serve):
- """The key line renders the same scene() escapeKey() runs, so what Esc promises
- is what Esc then does, rung by rung: general box → the list → the panel closed.
- And the armed chord is on screen with the panel closed — where the old corner
- badges, display:none inside it, said nothing at all."""
+ """The key line and dispatcher read one return frame for each keyboard entry."""
url = serve(NOTED_PAGE)
events_model.append_event(
serve.page_dir,
@@ -2755,25 +2774,44 @@ def test_the_key_line_says_what_a_press_will_do(browser, serve):
0
)
- # c stands the reader on the list and c again opens the general box: there the line
- # says send, and where Esc goes.
- page.keyboard.press("c")
- expect(page.locator(".lf-threads")).to_be_focused()
+ # c is comment everywhere. From the page it enters the page composer directly, and
+ # one Escape undoes the one entry: field, panel, and focus origin together.
page.keyboard.press("c")
+ expect(page.locator(".lf-general textarea")).to_be_focused()
expect(line).to_contain_text("send")
- expect(line).to_contain_text("back to list")
+ expect(line).to_contain_text("back")
# A send key on an empty box is answered, not swallowed — silence reads as a
# send that happened.
page.keyboard.press("ControlOrMeta+Enter")
expect(page.locator(".lf-notice")).to_contain_text("Nothing to send")
page.keyboard.press("Escape")
+ expect(page.locator(".lf-panel")).to_be_hidden()
+ expect(line).not_to_contain_text("close threads")
+ assert page.evaluate("() => document.activeElement === document.body")
+
+ # g T is navigation to Threads. Its one completed chord enters one surface, and one
+ # Escape restores the page instead of stranding focus on the panel toggle.
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
+ expect(page.locator(".lf-threads")).to_be_focused()
+ page.keyboard.press("?")
+ page.keyboard.press("?")
+ help_el = page.locator(".lf-help")
+ expect(help_el).to_be_visible()
+ returning = help_el.locator(
+ "section",
+ has=page.get_by_role("heading", name="After entering a surface", exact=True),
+ )
+ expect(returning.locator('[data-lf-command="navigation.return"]')).to_contain_text(
+ "Return from Threads panel"
+ )
+ expect(help_el.locator('[data-lf-command="navigation.back"]')).to_have_count(0)
+ page.keyboard.press("Escape")
+ page.keyboard.press("Escape")
expect(page.locator(".lf-threads")).to_be_focused()
- expect(line).to_contain_text("close threads")
page.keyboard.press("Escape")
expect(page.locator(".lf-panel")).to_be_hidden()
- expect(line).not_to_contain_text("close threads")
- # Focus doesn't fall to body: it lands on the control that reopens the panel.
- expect(page.locator(".lf-threads-toggle")).to_be_focused()
+ assert page.evaluate("() => document.activeElement === document.body")
# The fast rung: t reopens onto a thread, and Esc from it is one press out.
# Every rung earns a press here because Esc is the only keyboard collapse.
@@ -2809,7 +2847,8 @@ def test_a_comments_quoted_passage_is_in_the_keyboard_journey(browser, serve):
page.emulate_media(reduced_motion="reduce")
page.wait_for_function("() => document.querySelectorAll('.lf-thread').length === 1")
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
page.keyboard.press("t")
expect(page.locator(".lf-thread")).to_be_focused()
@@ -3003,6 +3042,8 @@ def test_a_scope_cannot_give_one_live_key_two_meanings(browser, serve):
const { keys } = await import('/runtime/widget-api.js');
const { activeRows, answers: bindingAnswers, canonicalBinding } =
await import('/runtime/keyboard/bindings.js');
+ const { createReturnStack } =
+ await import('/runtime/keyboard/return-stack.js');
const { paintKeys } = await import('/runtime/keyboard/scopes.js');
const declare = (id, rows) => {
const button = document.createElement('button');
@@ -3049,6 +3090,23 @@ def test_a_scope_cannot_give_one_live_key_two_meanings(browser, serve):
}
return {declaration, paints};
};
+ const malformedFrame = () => {
+ const stack = createReturnStack({
+ focused: () => document.body,
+ paintHere: () => {},
+ readingBlock: () => document.querySelector('main'),
+ });
+ try {
+ stack.invoke(
+ {id: 'test.bad-frame', returnFrame: () => ({active: () => true})},
+ 'F8',
+ () => {},
+ );
+ return 'accepted';
+ } catch (error) {
+ return error.message;
+ }
+ };
return {
ambiguous: declare('ambiguous', [
{id: 'test.first', keys: ['F2'], does: 'First meaning', line: 'first', run: () => {}},
@@ -3110,6 +3168,15 @@ def test_a_scope_cannot_give_one_live_key_two_meanings(browser, serve):
{id: 'test.named-space', keys: ['Space'], does: 'Named space binding',
line: 'work', run: () => {}},
]),
+ invalidReturnFrame: declare('invalid-return-frame', [
+ {id: 'test.invalid-return-frame', keys: ['F8'], does: 'Enter badly',
+ line: 'enter', returnFrame: {}, run: () => {}},
+ ]),
+ returnWithoutCommand: declare('return-without-command', [
+ {id: 'test.return-without-command', keys: ['F8'], does: 'Enter nowhere',
+ line: 'enter', returnFrame: () => ({})},
+ ]),
+ malformedFrame: malformedFrame(),
immediateTransaction: keptInvalid('kept-immediate'),
gatedTransaction: keptInvalid('kept-gated', () => true),
};
@@ -3133,6 +3200,15 @@ def test_a_scope_cannot_give_one_live_key_two_meanings(browser, serve):
}, answers
assert "write the canonical Mod+Shift+x" in answers["noncanonical"], answers
assert 'write the canonical " "' in answers["namedSpace"], answers
+ assert "returnFrame that is not a function" in answers["invalidReturnFrame"], (
+ answers
+ )
+ assert (
+ "declares a return frame but runs no entry" in answers["returnWithoutCommand"]
+ ), answers
+ assert "must return active, close, does, and line" in answers["malformedFrame"], (
+ answers
+ )
assert "two live meanings for F4" in answers["immediateTransaction"]["declaration"]
assert answers["immediateTransaction"]["paints"] == ["painted", "painted"]
assert answers["gatedTransaction"]["declaration"] == "declared"
@@ -3307,10 +3383,16 @@ def test_character_shortcuts_can_be_turned_off_without_losing_the_keyboard(
create.click()
expect(page.locator(".lf-help")).to_be_hidden()
expect(version).to_have_attribute("aria-keyshortcuts", "v")
+ expect(page.locator(".lf-general textarea")).to_be_focused()
expect(page.locator(".lf-general textarea")).to_have_attribute(
- "placeholder", re.compile(r" · c$")
+ "placeholder", re.compile(r"(⌘⏎|Ctrl\+⏎)$")
)
expect(page.locator(".lf-panel")).to_be_visible()
+ # Running the declaration from the reference goes through the same return-stack
+ # invocation as its physical key, so Escape returns to the reference's door.
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-panel")).to_be_hidden()
+ expect(more).to_be_focused()
assert errors == []
page.close()
@@ -3692,7 +3774,7 @@ def test_the_key_line_keeps_local_and_page_hints_and_progressively_reveals_the_r
page.keyboard.press("c")
expect(visible_hints).to_have_count(2)
expect(visible_hints.nth(0)).to_contain_text("send")
- expect(visible_hints.nth(1)).to_contain_text("back to list")
+ expect(visible_hints.nth(1)).to_contain_text("back to threads")
more = page.get_by_role("button", name="? more", exact=True)
expect(more).to_have_attribute("aria-expanded", "false")
@@ -3794,7 +3876,7 @@ def test_the_resting_key_line_names_the_presses_that_say_something_back(browser,
expect(shown).to_have_count(2)
expect(page.get_by_role("button", name="? more", exact=True)).to_be_visible()
# One settled read, which pins the count, the order, the keys and the words together.
- assert key_line(page) == "c\ngo to threads\nr\nreact\n?\nmore", key_line(page)
+ assert key_line(page) == "c\ncomment on the page\nr\nreact\n?\nmore", key_line(page)
# Still declared, and off the glance nobody asked for. Each is in the DOM and each is
# hidden — the pair of counts is what separates a row the line declined to paint from
@@ -4257,6 +4339,7 @@ def test_a_control_that_types_nothing_keeps_the_pages_keyboard(browser, serve):
expect(page.locator(".lf-composer")).to_be_visible()
expect(page.locator(".lf-composer")).to_contain_text("flip")
page.keyboard.press("Escape")
+ expect(page.locator("#flip")).to_be_focused()
# The box beside it, where every one of those letters is the reader's. The line
# names none of them, which is the same register saying so.
@@ -4509,11 +4592,11 @@ def test_the_key_line_names_the_selected_comment_and_its_other_responses(
line = page.locator(".lf-keyline")
help_el = page.locator(".lf-help")
- # Nothing in hand: there is no box to name, so the word is the room c goes to.
- expect(line).to_contain_text("threads")
+ # Nothing in hand: c names and enters the page comment directly.
+ expect(line).to_contain_text("comment on the page")
page.keyboard.press("?")
page.keyboard.press("?")
- expect(help_el).to_contain_text("Go to the threads")
+ expect(help_el).to_contain_text("Comment on the page")
page.keyboard.press("Escape")
# A real selection keeps the browser selection until Comment explicitly enters its
@@ -4675,12 +4758,9 @@ def test_a_key_on_screen_is_a_key_that_works(browser, serve):
page.keyboard.press("?")
page.keyboard.press("?")
expect(help_el).to_be_visible()
- # Nothing is selected and the reader is standing nowhere, so c's own row says where
- # the press goes — the word is the press's, not the key's (see the row's neighbour
- # test below). Not "Comment on the page", which is the panel's own c saying what its
- # row does, in its own section: naming that sentence here is answered by the wrong
- # row and says nothing about the page's.
- expect(help_el).to_contain_text("Go to the threads")
+ # Nothing is selected and the reader is standing nowhere, so c's own row names the
+ # page comment it enters. Threads navigation remains the separate g T command.
+ expect(help_el).to_contain_text("Comment on the page")
# The chord's section stands on every page — the edges need no list — but holds
# no row for a list this page hasn't got. Each row says the whole press from the
# standing page rather than asking its heading to supply the first g.
@@ -4983,8 +5063,8 @@ def test_c_comments_on_what_the_reader_is_standing_in(browser, serve):
a focus landing.
The control is the same press from the same page with the reader standing nowhere in
- it, where `c` opens no box at all and goes to the comments. Without it a green here
- would follow just as well from a composer that opened on everything.
+ it, where `c` opens the page-comment box rather than this item's composer. Without it a
+ green here would follow just as well from a composer that opened on everything.
Focus is dropped between the phases rather than backed out of, because each press
lands the reader in a box and the typing scope owns the letter there."""
@@ -4992,15 +5072,17 @@ def test_c_comments_on_what_the_reader_is_standing_in(browser, serve):
line = page.locator(".lf-keyline")
def drop():
- if page.locator(".lf-composer[data-lf-open]").count():
+ if (
+ page.locator(".lf-composer[data-lf-open]").count()
+ or page.locator(".lf-general textarea:focus").count()
+ ):
page.keyboard.press("Escape")
page.evaluate("() => document.activeElement?.blur()")
- # Standing nowhere in the page: no box is named, so the press is the room the boxes
- # are in. Read against every phase below, which is what makes those mean anything.
- expect(line).not_to_contain_text("comment on the")
+ # Standing nowhere in the page: the page itself is the contextual target.
+ expect(line).to_contain_text("comment on the page")
page.keyboard.press("c")
- expect(page.locator(".lf-threads")).to_be_focused()
+ expect(page.locator(".lf-general textarea")).to_be_focused()
drop()
# A decision: the composer opens on the question rather than on the option the
@@ -5238,9 +5320,10 @@ def test_c_in_a_thread_reaches_that_threads_own_box(browser, serve):
page, errors = open_page(browser, url)
line = page.locator(".lf-keyline")
- # The control: standing nowhere, the press names no box and goes to the list.
- expect(line).not_to_contain_text("comment on the")
- page.keyboard.press("c")
+ # Threads navigation is g T; page c is reserved for the page comment.
+ expect(line).to_contain_text("comment on the page")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
# Standing in the open thread, it means that thread's reply box. `t` walks on from
@@ -5260,7 +5343,7 @@ def test_c_in_a_thread_reaches_that_threads_own_box(browser, serve):
expect(line).to_contain_text("back to thread")
page.keyboard.press("Escape")
expect(page.locator(f'.lf-thread[data-id="{live}"]')).to_be_focused()
- expect(line).to_contain_text("close threads")
+ expect(line).to_contain_text("back")
page.evaluate("() => document.activeElement?.blur()")
# A resolved thread has no box, so the press falls through to the general box rather
@@ -5444,30 +5527,29 @@ def test_c_travels_to_an_item_its_own_scroller_has_taken_away(browser, serve):
page.close()
-def test_c_reaches_the_panel_and_c_again_the_box(browser, serve):
- """c goes inward and never back out. It doubled as the panel's collapse once,
- which left the box with no shortcut exactly while the panel stood open: the press
- that promised "comment" answered "close". Collapse is the ladder's — Esc from the
- list closes the panel, the rung the key-line test walks — so both stay reachable
- without one key meaning two things.
-
- Where the first press lands is the list rather than the box, because the box is the
- one place in the panel where the panel's own keys are all shadowed: the typing scope
- claims a letter before the panel's scope can see it, so a reader who pressed c to
- reach the comments had to press Escape before w or / would answer. The same letter
- twice is the same intent one scope further in, and the third press below is what says
- the second one is about the scope the reader is standing in rather than about the
- panel being shut."""
+def test_c_comments_and_g_t_navigates_to_threads(browser, serve):
+ """c is contextual comment; g T is the one route into the Threads list."""
page, errors = open_page(browser, serve(NOTED_PAGE))
- page.keyboard.press("c") # closed: opens the panel and stands on its list
+ page.keyboard.press("c") # page: straight into its comment box
+ expect(page.locator(".lf-general textarea")).to_be_focused()
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-panel")).to_be_hidden()
+
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
- page.keyboard.press("c") # standing in the panel: into the box
+ page.keyboard.press("c") # panel context: the same page comment box
expect(page.locator(".lf-general textarea")).to_be_focused()
- page.keyboard.press("Escape") # back out to the list, focus outside any box
+ page.keyboard.press("Escape")
expect(page.locator(".lf-threads")).to_be_focused()
- page.keyboard.press("c") # open, and still the box — never the collapse
+ page.keyboard.press("c")
expect(page.locator(".lf-general textarea")).to_be_focused()
expect(page.locator(".lf-panel")).to_have_class(re.compile("open"))
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-threads")).to_be_focused()
+ page.keyboard.press("Escape")
+ expect(page.locator(".lf-panel")).to_be_hidden()
+ assert page.evaluate("() => document.activeElement === document.body")
assert errors == []
page.close()
@@ -5479,10 +5561,8 @@ def test_the_panels_own_c_answers_a_page_whose_log_has_not_arrived(browser, serv
list, so narrowing by what awaits the reader is dead. Find remains available as the
panel's empty search, and the scope used to take `c` down with the missing list.
- Both presses, because one of them is the whole defect: `c` takes the reader to the
- list, and with the panel's own row out of the stack the second `c` was the page's
- again, which lands focus where it already is. The key line went on offering the box
- while no press could reach it, and there is no third key to try.
+ The page's c enters the box directly. g T independently reaches the empty Threads
+ list, where the panel's own search remains available.
Offline rather than mid-load, because it is the state that stays: a loading page
answers a moment later, and a page whose server has stopped is where a reader sits."""
@@ -5496,15 +5576,15 @@ def test_the_panels_own_c_answers_a_page_whose_log_has_not_arrived(browser, serv
"Server offline — reconnecting. Keep this page open so pending changes can send."
)
- page.keyboard.press("c")
- expect(page.locator(".lf-threads")).to_be_focused()
page.keyboard.press("c")
expect(page.locator(".lf-general textarea")).to_be_focused()
+ page.keyboard.press("Escape")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
+ expect(page.locator(".lf-threads")).to_be_focused()
# The missing list takes away its waiting filter, but not the panel's own search:
# a search over nothing still belongs to the scope in front of the page.
- page.keyboard.press("Escape")
- expect(page.locator(".lf-threads")).to_be_focused()
line = page.locator(".lf-keyline")
expect(line).not_to_contain_text("waiting on you")
expect(line).to_contain_text("find")
diff --git a/tests/test_render_options_addition.py b/tests/test_render_options_addition.py
index d4c077d66..025ab276f 100644
--- a/tests/test_render_options_addition.py
+++ b/tests/test_render_options_addition.py
@@ -63,6 +63,16 @@ def test_enter_keeps_another_option_separate_from_a_clarification_thread(
page.keyboard.press("Enter")
expect(page.locator("#storage-options > .lf-another input")).to_be_focused()
expect(page.locator("#storage-options > lf-option[chosen]")).to_have_count(0)
+ page.keyboard.press("Escape")
+ expect(mark).to_be_focused()
+
+ # c keeps its page-wide meaning: it comments on the focused option instead of being a
+ # second spelling for adding an answer. Its own Escape restores the same mark too.
+ page.keyboard.press("c")
+ expect(page.locator(".lf-fab-input")).to_be_focused()
+ expect(page.locator("#storage-options > .lf-another input")).not_to_be_focused()
+ page.keyboard.press("Escape")
+ expect(mark).to_be_focused()
assert errors == []
page.close()
diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py
index bcfacd356..b7dfcf660 100644
--- a/tests/test_render_projection.py
+++ b/tests/test_render_projection.py
@@ -543,6 +543,32 @@ def test_a_large_diff_filters_navigates_and_replays_explicit_file_reviews(
expect(summaries.nth(2)).to_be_hidden()
expect(progress).to_have_text("1 of 3 reviewed · 1 matching")
+ # The frame belongs to the diff, not to the query value globally. Leaving the widget
+ # retires it: Escape over page prose must not clear a hidden filter or pull focus back.
+ page.locator("#title").click()
+ expect(search).not_to_be_focused()
+ page.keyboard.press("Escape")
+ expect(search).to_have_value("second")
+ expect(search).not_to_be_focused()
+
+ # Filtering is a nested state of the one / entry: the first Escape clears it, and
+ # the second restores the file header that opened the field.
+ summaries.nth(1).focus()
+ page.keyboard.press("/")
+ expect(search).to_be_focused()
+ page.keyboard.press("Escape")
+ expect(search).to_have_value("")
+ expect(search).to_be_focused()
+ expect(summaries).to_have_count(3)
+ for index in range(3):
+ expect(summaries.nth(index)).to_be_visible()
+ page.keyboard.press("Escape")
+ expect(summaries.nth(1)).to_be_focused()
+
+ page.keyboard.press("/")
+ search.fill("second")
+ expect(progress).to_have_text("1 of 3 reviewed · 1 matching")
+
summaries.nth(1).focus()
page.keyboard.press("Alt+ArrowDown")
expect(summaries.nth(1)).to_be_focused()
@@ -2525,7 +2551,7 @@ def test_a_module_that_stages_bare_text_is_refused_in_its_own_name(
"the refusal is still a property name, which reads as leaf being broken: "
+ refusal
)
- assert page.evaluate("() => document.body.dataset.lfUpgraded") is None, (
+ assert page.evaluate("() => document.body.dataset.lfPresented") is None, (
"the page presented anyway, so the words with nothing over them are in it"
)
page.close()
@@ -3524,7 +3550,7 @@ def test_a_thread_question_asks_until_answered(browser, serve):
expect(reply).to_be_focused()
expect(page.locator("#tq-one > lf-option[chosen]")).to_have_count(0)
page.keyboard.press("Escape")
- expect(page.locator(".lf-thread:has(#tq-one)")).to_be_focused()
+ expect(page.locator("#tq-one .lf-pick").first).to_be_focused()
# The group's hairline belongs to the upper neighbour, so the Done press keeps its
# own frame whole. Drawn by the lower neighbour instead, the divider recolored the
diff --git a/tests/test_render_semantic_selection.py b/tests/test_render_semantic_selection.py
index 557beb416..3c078c6e4 100644
--- a/tests/test_render_semantic_selection.py
+++ b/tests/test_render_semantic_selection.py
@@ -520,7 +520,8 @@ def test_slash_stays_native_in_text_entry_and_searches_the_scope_in_front(
page.keyboard.press("Escape")
assert page.evaluate("() => document.activeElement === document.body")
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
expect(page.locator(".lf-threads")).to_be_focused()
page.keyboard.press("/")
thread_search = page.get_by_role("searchbox", name="Find in threads")
@@ -538,7 +539,8 @@ def test_empty_thread_scope_keeps_slash_in_its_search(browser, serve):
html = leaf_page("empty scoped slash", "Searchable page words.
")
page, errors = open_page(browser, serve(html))
- page.keyboard.press("c")
+ page.keyboard.press("g")
+ page.keyboard.press("Shift+t")
threads = page.locator(".lf-threads")
expect(threads).to_be_focused()
expect(page.locator(".lf-keyline")).not_to_contain_text("search page")