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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/corpus.html
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,8 @@ <h2>What would you report on the final revision?</h2>
<p id="pr-outcome-context">
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, <kbd>Enter</kbd> writes
another answer; <kbd>c</kbd> comments on that option instead.
</p>
<lf-options id="pr-outcome-choice" choose>
<lf-option id="pr-outcome-follow-up">
Expand Down
3 changes: 2 additions & 1 deletion examples/pr-walkthrough.html
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,8 @@ <h2>What would you report on the final revision?</h2>
<p id="pr-outcome-context">
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, <kbd>Enter</kbd> writes
another answer; <kbd>c</kbd> comments on that option instead.
</p>
<lf-options id="pr-outcome-choice" choose>
<lf-option id="pr-outcome-follow-up">
Expand Down
224 changes: 126 additions & 98 deletions skills/leaf/CLAUDE.md

Large diffs are not rendered by default.

293 changes: 193 additions & 100 deletions skills/leaf/assets/leaf.js

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions skills/leaf/assets/runtime/chrome-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 26 additions & 4 deletions skills/leaf/assets/runtime/keyboard/address.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function createAddress({
claimsEsc,
el,
enterPageMap,
leavePageMap,
focused,
focusedThread,
fragmentId,
Expand All @@ -31,12 +32,17 @@ export function createAddress({
pageParts,
paintHere,
panelCovers,
panelIsOpen,
pageMapIsActive,
placeThreadEdge,
resolveAnchor,
saying,
seenScroller,
setPanel,
showTray,
currentTray,
workspaceState,
restoreWorkspace,
startsAt,
scrollToElement,
threadsBox,
Expand Down Expand Up @@ -145,6 +151,7 @@ export function createAddress({
setPanel(true);
threadsBox.focus({ preventScroll: true });
},
active: panelIsOpen,
},
{
id: "navigation.panel.decisions",
Expand All @@ -156,6 +163,7 @@ export function createAddress({
showTray("decisions");
(decisionRows()[0] ?? decisionsPanel).focus({ preventScroll: true });
},
active: () => currentTray() === "decisions",
},
{
id: "navigation.panel.leaves",
Expand All @@ -167,6 +175,7 @@ export function createAddress({
showTray("leaves");
(othersLinks()[0] ?? othersPanel).focus({ preventScroll: true });
},
active: () => currentTray() === "leaves",
},
{
id: "navigation.page-map",
Expand All @@ -175,6 +184,8 @@ export function createAddress({
line: "Page map",
when: () => true,
go: enterPageMap,
active: pageMapIsActive,
close: leavePageMap,
},
];
const ADDRESSES = [
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"),
Expand Down
8 changes: 8 additions & 0 deletions skills/leaf/assets/runtime/keyboard/bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
25 changes: 19 additions & 6 deletions skills/leaf/assets/runtime/keyboard/dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export function createDispatch({
paintHere,
REACT,
recoveredLabelFocus,
RETURN,
returnStack,
SCOPES,
scopesFor,
setChord,
Expand Down Expand Up @@ -50,10 +52,14 @@ 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];
}
if (scope === TYPING && typing) return [];
return scope;
Expand Down Expand Up @@ -130,8 +136,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);
Expand Down Expand Up @@ -192,11 +200,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;
}

Expand Down
65 changes: 29 additions & 36 deletions skills/leaf/assets/runtime/keyboard/reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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`
Expand Down Expand Up @@ -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 = () =>
Expand Down
Loading
Loading