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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
229 changes: 131 additions & 98 deletions skills/leaf/CLAUDE.md

Large diffs are not rendered by default.

295 changes: 195 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
29 changes: 23 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,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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

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