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
5 changes: 0 additions & 5 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@ the relevant design note or in git history.
race belongs to version travel generally; independent pane scrollports only exposed
more instances of it.

- **#24 — Make covering workspaces modal.** When Threads or a tray covers the document,
keep focus and scrolling inside that workspace instead of leaving covered document
controls keyboard-reachable. Preserve the subject, draft, and reading landmark when
crossing the responsive threshold.

## Workspace follow-ups

The workspace research after [PR #455](https://github.com/max-sixty/leaf/pull/455)
Expand Down
2 changes: 2 additions & 0 deletions skills/leaf/assets/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ read their edges. Only the authored interaction loader and registry widget loade
computed imports; those load content modules rather than runtime owners.
`runtime/chrome.js` owns only the shared chrome root; `leaf.js` assembles its parts,
mounts them, and wires behavior that needs them in the document;
`runtime/workspace-modality.js` owns the shared inert, scrim, focus, semantic, and
reading-scroll boundary while an auxiliary workspace covers the document;
`runtime/repaint.js` owns the shared frame, whose fixed phases are wired at boot:
first keyboard-scope reflection, standing content, chrome layout, requested page
movement, then standing geometry. Work requested during a phase belongs to the next
Expand Down
23 changes: 21 additions & 2 deletions skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
reserveListClearance,
} from "./runtime/trays.js";
import { createWorkspaceNavigation } from "./runtime/workspace.js";
import { createWorkspaceModality } from "./runtime/workspace-modality.js";
import { restoreArrangements } from "./runtime/arrangements.js";
import { readerStore } from "./runtime/storage.js";
import { createVersionController, versionBtn, versionMenu } from "./runtime/version.js";
Expand Down Expand Up @@ -180,7 +181,18 @@ let pageKeys;

const panelVisibility = createPanelVisibility();
const { panelIsOpen } = panelVisibility;
const navigation = createNavigation({ panelIsOpen });
const workspaceModality = createWorkspaceModality({ chromeRoot, focusable: FOCUSABLE });
const navigation = createNavigation({
panelIsOpen,
coveringWorkspaceScroller: workspaceModality.coveringScroller,
});
const panelModality = workspaceModality.register({
surface: panel,
scroller: () => threadsBox,
covers: navigation.panelCovers,
focus: () => threadsBox,
dismiss: () => panelWorkspace.setPanel(false),
});

const targetPaintCaps = {
clearAim: targetPaint.clearAim,
Expand Down Expand Up @@ -518,6 +530,7 @@ layout = createChromeLayout({
bottomChromeBoxes,
reserveListClearance,
restateTrayEdge: () => trays.traysEdge.state(),
syncWorkspaces: workspaceModality.sync,
syncReactLayout: reactions.syncReactLayout,
refreshFab: responseSurface.refreshFab,
dockSeats: anchorControls.dockSeats,
Expand All @@ -541,6 +554,7 @@ panelWorkspace = createPanelWorkspace({
syncGeneral: panelComposer.syncGeneral,
refreshHover: anchorPaint.refreshHover,
rememberOpen: (open) => readerStore.set(PANEL_KEY, open ? "1" : "0"),
modality: panelModality,
repaint,
});
trays = createTrays({
Expand All @@ -554,6 +568,7 @@ trays = createTrays({
paintLeavesOffer,
renderAsks: asks.renderAsks,
renderMargin: app.margin.renderMargin,
registerModalWorkspace: workspaceModality.register,
});
const workspace = createWorkspaceNavigation({
panelIsOpen,
Expand Down Expand Up @@ -583,12 +598,14 @@ address = createAddress({
placeThreadEdge,
seenScroller: navigation.seenScroller,
stopGlide,
coveringWorkspaceSurface: workspaceModality.coveringSurface,
enterPageMap: pageMap.enterPageMap,
leavePageMap: pageMap.leavePageMap,
pageMapIsActive: pageMap.pageMapIsActive,
});
pageKeys = createPageKeys({
panelIsOpen,
coveringWorkspaceSurface: workspaceModality.coveringSurface,
stepReading: navigation.stepReading,
openAsks: app.openAsks,
GO: address.GO,
Expand Down Expand Up @@ -685,6 +702,7 @@ chromeRoot.append(
liveEl,
mediaViewer,
shortcutReferenceDialog,
workspaceModality.scrim,
bottomStatusEl,
shortcutBarEl,
inspectEl,
Expand All @@ -704,7 +722,8 @@ mountBanner({
paintApproval: paintVersionApproval,
});
reserveBannerControls();
registerPageScopes(pageKeys.scopes, REFERENCE, pageKeys.typing);
registerPageScopes(pageKeys.scopes, REFERENCE, pageKeys.typing, workspaceModality);
workspaceModality.mount();
panelComposer.mount();
selectionComposer.mount();
responseSurface.mount();
Expand Down
12 changes: 12 additions & 0 deletions skills/leaf/assets/runtime/banner-shelf.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ export function unfoldShelf() {
for (const control of back) if (newsControls.has(control)) paintPresence(control);
overflowBtn.hidden = true;
}

// A workspace can close while its banner address is folded into a popover that light
// dismissal has just hidden. Restore that address's own surface before returning focus.
export function focusBannerAddress(control) {
const menu = control.closest("[popover]");
if (menu && !menu.matches(":popover-open")) menu.showPopover();
control.focus({ preventScroll: true });
}

export function dismissBannerAddresses() {
if (overflowMenu.matches(":popover-open")) overflowMenu.hidePopover();
}
// The addresses this row may fold, in the row's own order: everything before the
// reading loop at its end.
function foldable() {
Expand Down
7 changes: 5 additions & 2 deletions skills/leaf/assets/runtime/chrome-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
// list and by nothing else. Threads and trays are alternate auxiliary workspaces, so only
// one stands at a time. The strip-taking workspaces—Threads and Asks—take room when the
// viewport can hold them and cover the page under their respective media query otherwise;
// Leaves always covers because its rows leave this page. A workspace covering the page is
// only ever `show()`n; the reference and the page map keep `showModal()`. The shell's
// Leaves always covers because its rows leave this page. Workspace modality is a shared
// inert boundary outside this geometry owner; the reference and page map keep native
// `showModal()`. The shell's
// inline size already reflects the margins a beside panel or tray takes. `--strip-l`, `--strip-r`,
// `--lf-room`, and `--lf-sidebar-posture` are CSS-owned readings resolved on `main`, which is
// the named `lf-page` style container a margin resident asks for them; `--lf-shell-inset-left`
Expand Down Expand Up @@ -94,6 +95,7 @@ export function createChromeLayout({
bottomChromeBoxes,
reserveListClearance,
restateTrayEdge,
syncWorkspaces,
syncReactLayout,
refreshFab,
dockSeats,
Expand Down Expand Up @@ -338,6 +340,7 @@ export function createChromeLayout({
addEventListener("resize", () => {
commentsEdge.state();
restateTrayEdge();
syncWorkspaces();
pageShifted();
syncLayout();
});
Expand Down
16 changes: 11 additions & 5 deletions skills/leaf/assets/runtime/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,14 @@ body:is(.lf-aiming, .lf-design).lf-over-item .lf-mark-el { cursor: pointer; }
border-right: 1px solid var(--rule); display: none; flex-direction: column;
padding-bottom: var(--lf-safe-bottom); padding-left: var(--lf-safe-left); }
.lf-tray-panel.open { display: flex; }
/* A modal workspace takes the whole window: the banner is one of the inert siblings,
not an available strip above it. Its shared scrim therefore stands above the banner
and below the active surface. Chrome-owned targeting and native top-layer dialogs
keep their higher planes so a command inside the workspace can still show its aim. */
.lf-workspace-scrim { position: fixed; inset: 0;
z-index: 9010; background: color-mix(in srgb, var(--ink) 24%, transparent); }
.lf-workspace-scrim[hidden] { display: none; }
:is(.lf-tray-panel, .lf-panel)[aria-modal="true"] { z-index: 9020; }
/* The rows scroll in a box of their own rather than in the tray, which is the comment
panel's shape (.lf-threads) reflected, and here it is what lets the edge exist at
all: a scroll container clips to its padding box, so an edge straddling the border
Expand Down Expand Up @@ -1093,10 +1101,8 @@ body:is(.lf-aiming, .lf-design).lf-over-item .lf-mark-el { cursor: pointer; }
left, and both take the bottom. */
padding: 0 var(--lf-safe-right) var(--lf-safe-bottom) 0; }
.lf-panel[open] { display: flex; }
/* No ::backdrop: the panel is shown rather than shown modally (showPanelLayer says
why), so a non-modal dialog paints none, and a scrim over a page the reader can
still press would be saying the opposite of what the page now does. The two layers
that are modal keep theirs below. */
/* A covering panel shares the workspace scrim with the trays. A beside panel leaves
that scrim hidden, so this non-modal dialog needs no ::backdrop of its own. */
/* An edge, offered as a thing to take hold of — the thread panel's on the right of
the page, the trays' on the left, and nothing here knows which it is drawing except
the two lines that place it. It draws nothing of its own: the region's inner border
Expand Down Expand Up @@ -1142,7 +1148,7 @@ body:is(.lf-aiming, .lf-design).lf-over-item .lf-mark-el { cursor: pointer; }
drag that follows it. */
.lf-edge:hover::before, .lf-edge:is(:focus-visible, .lf-focus-visible)::before { opacity: 1; }
.lf-edge:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring); --lf-here-ring: edge; }
.lf-panel-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--rule); font-weight: 600; }
.lf-panel-head, .lf-tray-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--rule); font-weight: 600; }
/* The words in here, against the apparatus the root has already answered for: a reply
and the passage it is about. A quote is the page's own words and a message is
somebody's, so a reader copying either is copying what was said rather than the
Expand Down
6 changes: 5 additions & 1 deletion skills/leaf/assets/runtime/keyboard/address.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
panel address toggles that panel, matching its visible control. Completing one that
opens a surface exchanges the transient sequence for a return frame which restores the
standing and workspace captured before `g` armed; completing it again closes the
surface without adding a frame.
surface without adding a frame. These destinations remain available when a workspace
covers the page: the sequence belongs to that modal surface while the inert document's
ordinary scopes remain unavailable.

`BUILTIN_DIRECT_DESTINATIONS` declares the uppercase destinations this owner implements;
another owner contributes a complete row through `directDestinations`. `TARGET_KINDS`
Expand Down Expand Up @@ -127,6 +129,7 @@ export function createAddress({
placeThreadEdge,
seenScroller,
stopGlide,
coveringWorkspaceSurface,
enterPageMap,
leavePageMap,
pageMapIsActive,
Expand Down Expand Up @@ -736,6 +739,7 @@ export function createAddress({
const GO = {
title: "Go to",
escape: "inner",
root: () => coveringWorkspaceSurface() ?? document,
reach: "with g armed",
sequence: sequenceKeys,
sequencePrefix,
Expand Down
34 changes: 29 additions & 5 deletions skills/leaf/assets/runtime/keyboard/dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
closing the layer exposes the same frame again. The universal reference is the
boundary's one route through to another layer.

A covering workspace uses the same modal command floor without entering the browser's
top layer. Its owner makes the background DOM inert, and this dispatcher keeps only
scopes rooted in the workspace plus the return frame that can close it. A native layer
opened above the workspace keeps its own scopes above that floor.

A popover hands focus back to whatever had it when the popover showed — not to its
invoker, and not to `showPopover({source})`, which buys the anchor and the invoker
relationship and nothing about focus. So a key that opens a layer runs the press from
Expand All @@ -73,6 +78,7 @@
such as refusing the mouseup that ends a text-selection drag. */
import { answers, bindings, commandEntries, live, spell, word } from "./bindings.js";
import {
coveringWorkspaceSurface,
ELEMENTS,
pageScopes,
textEntryScope,
Expand Down Expand Up @@ -163,22 +169,40 @@ export function stack(binding = null) {
if (scope === TYPING && typing) return [];
return scope;
});
const workspace = coveringWorkspaceSurface();
const layer = currentNativeLayer(active);
const ordered = (scopes) => {
const activeScopes = scopes.filter(standing);
return binding === "Escape" ? escapeOrder(activeScopes, active) : activeScopes;
};
if (!layer) return ordered(expanded);
if (!layer) {
if (!workspace) return ordered(expanded);
const owned = expanded.filter((scope) => {
const root = scopeRoot(scope);
return scope === RETURN || root === workspace || workspace.contains(root);
Comment thread
leaf-agent marked this conversation as resolved.
});
return ordered([...owned, MODAL_BOUNDARY]);
}
const modal = currentModalLayer(active);
const inLayer = (scope) => nativeLayerFor(scopeRoot(scope)) === layer;
if (layer !== modal) {
// The popover, its focused controls, and explicitly inner modes stand above the
// browser's light-dismiss boundary. Everything else remains reachable for keys the
// boundary does not claim, but its Escape cannot fall through into the covered page.
const aboveModal = modal
const aboveBoundary = modal
? (scope) => nativeLayersFor(scopeRoot(scope)).includes(modal)
: () => true;
const available = expanded.filter(aboveModal);
: workspace
? (scope) => {
const root = scopeRoot(scope);
return (
scope === RETURN ||
inLayer(scope) ||
Comment thread
leaf-agent marked this conversation as resolved.
root === workspace ||
workspace.contains(root)
);
}
: () => true;
const available = expanded.filter(aboveBoundary);
const foreground = (scope) =>
inLayer(scope) ||
elementStack.includes(scope) ||
Expand All @@ -189,7 +213,7 @@ export function stack(binding = null) {
POPOVER_BOUNDARY,
...available.filter((scope) => !foreground(scope)),
];
if (modal) popoverStack.push(MODAL_BOUNDARY);
if (modal || workspace) popoverStack.push(MODAL_BOUNDARY);
return ordered(popoverStack);
}
const owned = expanded.filter((scope) =>
Expand Down
Loading
Loading