diff --git a/TODO.md b/TODO.md index e1d29cf82..4949cc3c0 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/skills/leaf/assets/CLAUDE.md b/skills/leaf/assets/CLAUDE.md index d6d7476a8..597fbfa07 100644 --- a/skills/leaf/assets/CLAUDE.md +++ b/skills/leaf/assets/CLAUDE.md @@ -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 diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js index b47dd0859..b8e247f6c 100644 --- a/skills/leaf/assets/leaf.js +++ b/skills/leaf/assets/leaf.js @@ -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"; @@ -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, @@ -518,6 +530,7 @@ layout = createChromeLayout({ bottomChromeBoxes, reserveListClearance, restateTrayEdge: () => trays.traysEdge.state(), + syncWorkspaces: workspaceModality.sync, syncReactLayout: reactions.syncReactLayout, refreshFab: responseSurface.refreshFab, dockSeats: anchorControls.dockSeats, @@ -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({ @@ -554,6 +568,7 @@ trays = createTrays({ paintLeavesOffer, renderAsks: asks.renderAsks, renderMargin: app.margin.renderMargin, + registerModalWorkspace: workspaceModality.register, }); const workspace = createWorkspaceNavigation({ panelIsOpen, @@ -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, @@ -685,6 +702,7 @@ chromeRoot.append( liveEl, mediaViewer, shortcutReferenceDialog, + workspaceModality.scrim, bottomStatusEl, shortcutBarEl, inspectEl, @@ -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(); diff --git a/skills/leaf/assets/runtime/banner-shelf.js b/skills/leaf/assets/runtime/banner-shelf.js index a7149fe9d..5b81c9ddd 100644 --- a/skills/leaf/assets/runtime/banner-shelf.js +++ b/skills/leaf/assets/runtime/banner-shelf.js @@ -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() { diff --git a/skills/leaf/assets/runtime/chrome-layout.js b/skills/leaf/assets/runtime/chrome-layout.js index 934c1e157..25f6d606f 100644 --- a/skills/leaf/assets/runtime/chrome-layout.js +++ b/skills/leaf/assets/runtime/chrome-layout.js @@ -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` @@ -94,6 +95,7 @@ export function createChromeLayout({ bottomChromeBoxes, reserveListClearance, restateTrayEdge, + syncWorkspaces, syncReactLayout, refreshFab, dockSeats, @@ -338,6 +340,7 @@ export function createChromeLayout({ addEventListener("resize", () => { commentsEdge.state(); restateTrayEdge(); + syncWorkspaces(); pageShifted(); syncLayout(); }); diff --git a/skills/leaf/assets/runtime/chrome.css b/skills/leaf/assets/runtime/chrome.css index c9b4d943b..bdcc1bba4 100644 --- a/skills/leaf/assets/runtime/chrome.css +++ b/skills/leaf/assets/runtime/chrome.css @@ -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 @@ -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 @@ -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 diff --git a/skills/leaf/assets/runtime/keyboard/address.js b/skills/leaf/assets/runtime/keyboard/address.js index f42b0d03a..073aeb6e5 100644 --- a/skills/leaf/assets/runtime/keyboard/address.js +++ b/skills/leaf/assets/runtime/keyboard/address.js @@ -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` @@ -127,6 +129,7 @@ export function createAddress({ placeThreadEdge, seenScroller, stopGlide, + coveringWorkspaceSurface, enterPageMap, leavePageMap, pageMapIsActive, @@ -736,6 +739,7 @@ export function createAddress({ const GO = { title: "Go to", escape: "inner", + root: () => coveringWorkspaceSurface() ?? document, reach: "with g armed", sequence: sequenceKeys, sequencePrefix, diff --git a/skills/leaf/assets/runtime/keyboard/dispatch.js b/skills/leaf/assets/runtime/keyboard/dispatch.js index c45b479b9..78abae2c0 100644 --- a/skills/leaf/assets/runtime/keyboard/dispatch.js +++ b/skills/leaf/assets/runtime/keyboard/dispatch.js @@ -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 @@ -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, @@ -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); + }); + 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) || + root === workspace || + workspace.contains(root) + ); + } + : () => true; + const available = expanded.filter(aboveBoundary); const foreground = (scope) => inLayer(scope) || elementStack.includes(scope) || @@ -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) => diff --git a/skills/leaf/assets/runtime/keyboard/page.js b/skills/leaf/assets/runtime/keyboard/page.js index 99c7d2a48..078052559 100644 --- a/skills/leaf/assets/runtime/keyboard/page.js +++ b/skills/leaf/assets/runtime/keyboard/page.js @@ -57,6 +57,7 @@ import { keeps } from "../widget-elements.js"; export function createPageKeys({ panelIsOpen, + coveringWorkspaceSurface, stepReading, openAsks, GO, @@ -373,6 +374,52 @@ export function createPageKeys({ run: () => rung().out(), }; + const PAGE_MOVE = { + id: "page.move", + keys: ["d", "u"], + routes: [ + { + id: "page.down", + binding: "d", + does: "Move 60% of a page down", + line: "page down", + }, + { + id: "page.up", + binding: "u", + does: "Move 60% of a page up", + line: "page up", + }, + ], + does: "Move 60% of a page down or up", + line: "page down / up", + repeat: true, + run: (binding) => stepReading(binding === "d" ? 0.6 : -0.6, "page"), + }; + + const SCROLL_MOVE = { + id: "scroll.move", + keys: ["j", "k"], + routes: [ + { + id: "scroll.down", + binding: "j", + does: "Scroll down a little", + line: "scroll down", + }, + { + id: "scroll.up", + binding: "k", + does: "Scroll up a little", + line: "scroll up", + }, + ], + does: "Scroll down or up a little", + line: "scroll down / up", + repeat: true, + run: (binding) => stepReading(binding === "j" ? 60 : -60, "pixel"), + }; + // ---------- what a scope takes ---------- // A scope shadows what stands behind it two ways, and they are one rule: a row of its own // that names the key, and a claim on keys it has no row for. The second is the platform's @@ -1096,56 +1143,10 @@ export function createPageKeys({ repeat: true, run: (binding) => stepAsk(binding === "a" ? 1 : -1), }, - { - id: "page.move", - keys: ["d", "u"], - routes: [ - { - id: "page.down", - binding: "d", - does: "Move 60% of a page down", - line: "page down", - }, - { - id: "page.up", - binding: "u", - does: "Move 60% of a page up", - line: "page up", - }, - ], - does: "Move 60% of a page down or up", - line: "page down / up", - // An ordinary row, ranked where it stands. It was the one persistent declaration in - // the runtime, which spent a third of the resting line restating what every reader - // already does with a wheel, a trackpad or the space bar — and spent it on every - // page, in every scope, beside whatever the reader was actually doing. Scrolling is - // the one capability no page has to advertise. The shelf and the reference still - // name it, which is where a key the reader has not asked after belongs. - repeat: true, - run: (binding) => stepReading(binding === "d" ? 0.6 : -0.6, "page"), - }, - { - id: "scroll.move", - keys: ["j", "k"], - routes: [ - { - id: "scroll.down", - binding: "j", - does: "Scroll down a little", - line: "scroll down", - }, - { - id: "scroll.up", - binding: "k", - does: "Scroll up a little", - line: "scroll up", - }, - ], - does: "Scroll down or up a little", - line: "scroll down / up", - repeat: true, - run: (binding) => stepReading(binding === "j" ? 60 : -60, "pixel"), - }, + // Scrolling is available in the page and in a covering workspace. The latter + // reuses these rows while the modal floor suspends the rest of page scope. + PAGE_MOVE, + SCROLL_MOVE, { // The last thing the reader did to this page, put back. Its own key rather // than the platform's ⌘Z, which belongs to the box a reader is typing in and @@ -1207,6 +1208,20 @@ export function createPageKeys({ AIM, ], }; + const COVERING_WORKSPACE = { + title: "In the covering workspace", + root: coveringWorkspaceSurface, + when: () => Boolean(coveringWorkspaceSurface()), + at: () => Boolean(coveringWorkspaceSurface()), + // The global address vocabulary is still a route out of this workspace. Reuse its + // one entry row here; GO moves its own root to the same modal surface while armed. + rows: [ + PAGE_MOVE, + SCROLL_MOVE, + GOTO, + { ...BACK_OUT, when: () => Boolean(rung()) }, + ], + }; const scopes = [ SHORTCUT_REFERENCE, SHORTCUT_SHELF, @@ -1222,6 +1237,7 @@ export function createPageKeys({ TYPING, THREAD, PANEL, + COVERING_WORKSPACE, LINK, DISCLOSURE, DRAW, diff --git a/skills/leaf/assets/runtime/keyboard/reference.js b/skills/leaf/assets/runtime/keyboard/reference.js index 79887d8d1..0c3dc437d 100644 --- a/skills/leaf/assets/runtime/keyboard/reference.js +++ b/skills/leaf/assets/runtime/keyboard/reference.js @@ -7,8 +7,10 @@ captured `shortcutReferenceOrigin`, 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 dialog that says what a menu's keys are cannot be what takes the menu away. It - stands each one back up from that layer's own invoker — `lfInvoker`, the link a layer + — the dialog that says what a menu's keys are cannot be what takes the menu away. If a + covering workspace began while the reference stood, its owner declines a pre-boundary + layer outside that workspace and supplies the new return place instead. Otherwise the + reference stands each layer back up from its own invoker — `lfInvoker`, the link a layer declares because the platform's own runs one way only — so the layer's way out survives the round trip too. @@ -47,7 +49,13 @@ import { beginWalk, listWalkPosition } from "../walk-position.js"; import { completeRowSteps, keySequence, neutralStates } from "./presentation.js"; import { restoreReturnPlace } from "./return-stack.js"; import { el } from "../widget-elements.js"; -import { ELEMENTS, pageScopes } from "./register.js"; +import { + coveringWorkspaceFocus, + coveringWorkspaceSurface, + ELEMENTS, + pageScopes, + workspaceAllowsNativeLayer, +} from "./register.js"; import { byCommand, elementScopes, @@ -185,6 +193,7 @@ let shortcutReferenceOrigin = null; // 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 shortcutReferenceLayers = []; +let shortcutReferenceBoundary = null; const shortcutReferenceWords = (value) => String(value ?? "") .toLocaleLowerCase() @@ -225,12 +234,13 @@ function showShortcutReference(open, restoreFocus, invokeCommand, captureOrigin) // The dialog itself remains a focus stop, so either route keeps the page suspended. const preserveSelection = open && Boolean(pageSelection()); const handBack = !open && restoreFocus && shortcutReferenceDialog.contains(focused()); - const origin = handBack ? shortcutReferenceOrigin : null; + let origin = handBack ? shortcutReferenceOrigin : null; const restore = origin?.control ?? null; const closing = !open && shortcutReferenceDialog.open; if (open && !shortcutReferenceOpen) { shortcutReferenceOrigin = captureOrigin(); shortcutReferenceLayers = [...document.querySelectorAll(":popover-open")]; + shortcutReferenceBoundary = coveringWorkspaceSurface(); commandsAtOpen = availableCommands(); } shortcutReferenceOpen = open; @@ -588,11 +598,16 @@ function showShortcutReference(open, restoreFocus, invokeCommand, captureOrigin) if (open && !shortcutReferenceDialog.open) shortcutReferenceDialog.showModal(); else if (!open && shortcutReferenceDialog.open) shortcutReferenceDialog.close(); // Back in the same order they were in: the dialog is out of the top layer by here, so a - // popover that is still on the page can stand again, and the restore below then reaches - // a control that is painted. + // eligible popover that is still on the page can stand again, and the restore below + // then reaches a control that is painted. if (closing) { for (const layer of shortcutReferenceLayers) { if (!layer.isConnected || layer.matches(":popover-open")) continue; + // A responsive change may have established a modal workspace while the native + // dialog stood above both surfaces. A popover captured before that boundary may + // return only when it belongs inside it. A layer captured over this same boundary + // was deliberately opened above it and keeps the ordinary reference round trip. + if (!workspaceAllowsNativeLayer(layer, shortcutReferenceBoundary)) continue; // A popover hands focus back to whatever had it when it was shown, and what the // closing dialog leaves focused is the body — so a layer stood back up from here // would have no way out, and the reader's exit from the menu would be the one thing @@ -603,7 +618,16 @@ function showShortcutReference(open, restoreFocus, invokeCommand, captureOrigin) layer.lfInvoker?.focus({ preventScroll: true }); layer.showPopover(); } + const originNode = origin?.control ?? origin?.reading; + if ( + originNode && + !workspaceAllowsNativeLayer(originNode, shortcutReferenceBoundary) + ) { + const focus = coveringWorkspaceFocus(); + origin = focus ? { control: focus, reading: null } : null; + } shortcutReferenceLayers = []; + shortcutReferenceBoundary = null; } // The reference is a list long enough to scroll, and anything a mouse can scroll a // keyboard has to reach. `reachScrollers` is the runtime's one answer to that and had diff --git a/skills/leaf/assets/runtime/keyboard/register.js b/skills/leaf/assets/runtime/keyboard/register.js index 5bd36b33d..0d1d2a6eb 100644 --- a/skills/leaf/assets/runtime/keyboard/register.js +++ b/skills/leaf/assets/runtime/keyboard/register.js @@ -5,12 +5,24 @@ export const ELEMENTS = Symbol("the scopes of the focused element"); let scopes; let reference; let typing; -export function registerPageScopes(declarations, universalReference, textEntry) { +let workspace; +export function registerPageScopes( + declarations, + universalReference, + textEntry, + workspaceLayer, +) { scopes = declarations; reference = universalReference; typing = textEntry; + workspace = workspaceLayer; } export const pageScopes = () => scopes; export const universalReference = () => reference; export const allButTheReference = (binding) => !bindings(reference).includes(binding); export const textEntryScope = () => typing; +export const coveringWorkspaceSurface = () => workspace.coveringSurface(); +export const coveringWorkspaceFocus = () => workspace.coveringFocus(); +export const workspaceAllowsNativeLayer = (node, establishedOver) => + workspace.allowsNativeLayer(node, establishedOver); +export const openWorkspaceSurfaceFor = (node) => workspace.openSurfaceFor(node); diff --git a/skills/leaf/assets/runtime/keyboard/return-stack.js b/skills/leaf/assets/runtime/keyboard/return-stack.js index 383fef06a..597e5a530 100644 --- a/skills/leaf/assets/runtime/keyboard/return-stack.js +++ b/skills/leaf/assets/runtime/keyboard/return-stack.js @@ -70,6 +70,11 @@ import { nativeLayerFor, nativeLayerOrder, } from "../native-layers.js"; +import { coveringWorkspaceSurface, openWorkspaceSurfaceFor } from "./register.js"; + +// A native layer opened above a covering workspace owns entries made inside it. The +// workspace remains the fallback floor when no browser top layer stands. +const currentLayer = () => currentNativeLayer(focused()) ?? coveringWorkspaceSurface(); export function restoreReturnPlace({ control, reading }) { if (control) { @@ -122,18 +127,22 @@ export function invoke(row, binding, run, suppliedOrigin = null) { const result = run(); prune(); if (frame?.active()) { - const root = currentNativeLayer(focused()) ?? document; + const root = currentLayer() ?? document; frames.push({ ...frame, origin, root, order: nativeLayerOrder(root) }); } return result; } function prune() { - const layer = currentNativeLayer(focused()); + const layer = currentLayer(); while (frames.length) { const frame = frames.at(-1); if (layer && frame.root !== layer && nativeLayerOrder(layer) > frame.order) return; - if (frame.root !== document && nativeLayerFor(frame.root) !== frame.root) { + if ( + frame.root !== document && + !openWorkspaceSurfaceFor(frame.root) && + nativeLayerFor(frame.root) !== frame.root + ) { frames.pop(); continue; } diff --git a/skills/leaf/assets/runtime/native-layers.js b/skills/leaf/assets/runtime/native-layers.js index ef590244e..599412878 100644 --- a/skills/leaf/assets/runtime/native-layers.js +++ b/skills/leaf/assets/runtime/native-layers.js @@ -40,6 +40,13 @@ const prune = () => { if (!open(layers[index])) layers.splice(index, 1); }; +// Modal owners close pre-existing popovers before establishing a new floor. Read the +// tracked native stack so a popover promoted from a declared shadow root is included too. +export function openNativePopovers() { + prune(); + return layers.filter((node) => node.matches(":popover-open")); +} + export function currentNativeLayer(focused = null) { const containing = nativeLayerFor(focused); prune(); diff --git a/skills/leaf/assets/runtime/navigation.js b/skills/leaf/assets/runtime/navigation.js index ea6e2fcb8..7b7943cdc 100644 --- a/skills/leaf/assets/runtime/navigation.js +++ b/skills/leaf/assets/runtime/navigation.js @@ -117,17 +117,19 @@ const holding = (box) => glide?.box === box && Math.abs(box.scrollTop - glide.wrote) <= 1; // The visible box used by page-edge navigation. A covering panel replaces the page; // beside it, the document keeps its own top and bottom. -const seenScroller = (panelCovers) => (panelCovers() ? threadsBox : pageScroller); +const seenScroller = (coveringWorkspaceScroller) => + coveringWorkspaceScroller() ?? pageScroller; // Reading-page keys follow the region the reader is working in. Focus can put them in a // panel beside the page; a covering panel remains the only visible region even when // focus is still on the banner control that opened it. -const stepScroller = (panelCovers, inPanel) => { - if (panelCovers()) return threadsBox; +const stepScroller = (coveringWorkspaceScroller, inPanel) => { + const covering = coveringWorkspaceScroller(); + if (covering) return covering; const region = readingRegionFor(document.activeElement); return region ? effectiveScroller(region) : inPanel() ? threadsBox : pageScroller; }; -function stepReading(amount, unit, panelCovers, inPanel) { - const box = stepScroller(panelCovers, inPanel); +function stepReading(amount, unit, coveringWorkspaceScroller, inPanel) { + const box = stepScroller(coveringWorkspaceScroller, inPanel); if (unit === "page") { const clear = parseFloat(getComputedStyle(box).scrollPaddingTop) || 0; amount *= box.clientHeight - clear; @@ -182,13 +184,14 @@ export function stopGlide(box) { glide = null; } -export function createNavigation({ panelIsOpen }) { +export function createNavigation({ panelIsOpen, coveringWorkspaceScroller }) { const panelCovers = () => panelIsOpen() && panelWouldCover(); const inPanel = () => panelFocusIsInside(panelIsOpen); return { panelCovers, - seenScroller: () => seenScroller(panelCovers), - stepReading: (amount, unit) => stepReading(amount, unit, panelCovers, inPanel), + seenScroller: () => seenScroller(coveringWorkspaceScroller), + stepReading: (amount, unit) => + stepReading(amount, unit, coveringWorkspaceScroller, inPanel), stepThread: (dir, commands) => stepThread(dir, commands, panelIsOpen), }; } diff --git a/skills/leaf/assets/runtime/panel-workspace.js b/skills/leaf/assets/runtime/panel-workspace.js index fac85f01b..23c9589ee 100644 --- a/skills/leaf/assets/runtime/panel-workspace.js +++ b/skills/leaf/assets/runtime/panel-workspace.js @@ -30,21 +30,9 @@ export function createPanelWorkspace({ syncGeneral, refreshHover, rememberOpen, + modality, repaint, }) { - // The panel is shown, never shown modally, at either posture. A modal dialog makes the - // rest of the document inert, and the panel covering the page is the posture in which the - // page most needs to stay live: the toggle that opened it is out in the banner and is how - // it closes, the Asks toggle beside it is the other workspace this one replaces - // (test_workspaces_replace_each_other_instead_of_stacking), and the strip of page still - // showing beside a covering sheet is still page a reader can point a hint at - // (test_selection_hints_do_not_name_page_content_behind_a_covering_panel, which is the - // one that states what "covering" means here — the panel covers the page rather than - // clipping it, and what it covers is out of reach only where it is actually painted over). - // What modality was carrying instead is already owned elsewhere and stays: the covering - // sheet's scroll lock is the stylesheet's (COVERING's `overflow-y: hidden`), while this - // non-modal workspace remains one rung in the keyboard stack. - // // Opening a runs the browser's dialog focusing steps whichever way it is opened, // 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 @@ -64,6 +52,7 @@ export function createPanelWorkspace({ } function setPanel(open, { remember = true } = {}) { if (open) hideTray({ remember }); + else modality.sync(false); // Closing while focus is inside would drop it on body, the user's place // lost silently; it lands on the one control that reopens what just closed. if (!open && panel.contains(document.activeElement)) @@ -90,6 +79,7 @@ export function createPanelWorkspace({ showPanelLayer(); refreshConversation(); syncGeneral(); // a restored draft has to reach the Send button's disabled state + modality.sync(true); } else if (panel.open) panel.close(); syncLayout(); if (open) closePreview(); diff --git a/skills/leaf/assets/runtime/trays.js b/skills/leaf/assets/runtime/trays.js index 3bad1ccfc..9ebb9f385 100644 --- a/skills/leaf/assets/runtime/trays.js +++ b/skills/leaf/assets/runtime/trays.js @@ -10,6 +10,8 @@ import { pagePresented } from "./presentation.js"; import { allAsks } from "./asks/model.js"; import { walkRows } from "./keyboard/bindings.js"; import { beginWalk, listWalkPosition } from "./walk-position.js"; +import { iconElement } from "./icons.js"; +import { dismissBannerAddresses, focusBannerAddress } from "./banner-shelf.js"; // The left side holds one tray at a time. `showTray` owns `trayUp` and renders the // complete outcome for leaves and asks. The leaves tray overlays the document because its // rows leave the page. The asks tray takes a strip because its rows travel within the @@ -67,11 +69,18 @@ export const trayCovers = () => trayCovering.matches; // walked to the end of. Callers state the clearance; this owner decides which lists it // reaches and how each one spends it. const trayLists = []; -function trayList(panel) { +function trayFurniture(panel, name) { + const head = el("div", "lf-tray-head"); + const title = el("span", "lf-panel-title", name); + const close = el("button", "lf-btn lf-icon-action lf-close-action"); + close.append(iconElement("cross", "lf-action-icon")); + close.title = `Close ${name.toLowerCase()} (Esc)`; + close.setAttribute("aria-label", `Close ${name.toLowerCase()}`); const list = el("div", "lf-tray-list"); - panel.append(list); + head.append(title, close); + panel.append(head, list); trayLists.push(list); - return list; + return { list, close }; } export function reserveListClearance(clear) { for (const list of trayLists) { @@ -100,7 +109,8 @@ export const othersPanel = el("nav", "lf-ui lf-tray-panel lf-others-panel"); othersPanel.id = "lf-leaves"; othersPanel.setAttribute("aria-label", "Leaves on this machine"); othersPanel.tabIndex = -1; -export const leavesList = trayList(othersPanel); +const leavesFurniture = trayFurniture(othersPanel, "Leaves"); +export const leavesList = leavesFurniture.list; // A tray of the page's active asks, on the same edge: open and answered rows in the // order the page asks them. The list is declaration-driven, so a widget joins without // a row here knowing what kind of thing it is standing for. @@ -108,7 +118,8 @@ export const asksPanel = el("nav", "lf-ui lf-tray-panel lf-asks-panel"); asksPanel.id = "lf-asks"; asksPanel.setAttribute("aria-label", "Asks from this page"); asksPanel.tabIndex = -1; -export const asksList = trayList(asksPanel); +const asksFurniture = trayFurniture(asksPanel, "Asks"); +export const asksList = asksFurniture.list; // The left edge holds one tray at a time. Leaves and asks are the same furniture asking // at two scopes — which page needs me, and what this page needs of me — and each has to @@ -145,6 +156,7 @@ export function createTrays({ paintLeavesOffer, renderAsks, renderMargin, + registerModalWorkspace, }) { const trays = new Map(); const beforeOpen = ({ remember = true } = {}) => { @@ -163,13 +175,17 @@ export function createTrays({ land: landEdge, }); - function showTray(key, { remember = true } = {}) { + function showTray(key, { remember = true, returnFocus = true } = {}) { if (trayUp === key) return; // Threads and trays are alternate workspaces. Retire the standing one before another // opens so layout, focus, and persisted state never have to reconcile two of them. - if (key) beforeOpen({ remember }); + if (key) { + dismissBannerAddresses(); + beforeOpen({ remember }); + } + trays.get(trayUp)?.workspace.sync(false); trayUp = key; - for (const [name, { panel, btn, paint }] of trays) { + for (const [name, { panel, btn, paint, workspace }] of trays) { const open = name === key; btn.setAttribute("aria-expanded", String(open)); if (open) { @@ -179,12 +195,14 @@ export function createTrays({ // reader watches the list they just closed blank out and an empty card slide away. paint?.(); panel.classList.add("open"); + workspace.sync(true); motion( panel, [{ transform: "translateX(-100%)" }, { transform: "translateX(0)" }], 200, ); } else if (panel.classList.contains("open")) { + workspace.sync(false); // Slid out before hidden, and hidden only if still closed on arrival — a // reopen mid-slide leaves the panel standing rather than racing the finish. const out = motion( @@ -199,7 +217,8 @@ export function createTrays({ }; if (out) out.finished.then(hide, () => {}); else hide(); - if (panel.contains(document.activeElement)) btn.focus(); + if (returnFocus && panel.contains(document.activeElement)) + focusBannerAddress(btn); } } if (remember) readerStore.set(TRAY_KEY, key ?? ""); @@ -216,13 +235,21 @@ export function createTrays({ // Registration only. No tray opens while this module evaluates: showTray runs from a // press, and restoreTrays from the arrangement restore at boot, after every owner has // evaluated. - function trayIs(key, panel, btn, paint) { - trays.set(key, { panel, btn, paint }); + function trayIs(key, panel, btn, close, paint) { + const workspace = registerModalWorkspace({ + surface: panel, + scroller: () => panel.querySelector(".lf-tray-list"), + covers: () => key === "leaves" || trayCovers(), + focus: () => + panel.querySelector(".lf-tray-list button, .lf-tray-list a[href]") ?? panel, + dismiss: () => showTray(null), + }); + trays.set(key, { panel, btn, close, paint, workspace }); } // The painters are thunks: each tray's owner imports this module back, so neither // painter is a binding this module can read as it evaluates. - trayIs("leaves", othersPanel, othersBtn, paintLeavesOffer); - trayIs("asks", asksPanel, asksBtn, renderAsks); + trayIs("leaves", othersPanel, othersBtn, leavesFurniture.close, paintLeavesOffer); + trayIs("asks", asksPanel, asksBtn, asksFurniture.close, renderAsks); const trayNames = Object.freeze([...trays.keys()]); // A persisted tray is state-dependent chrome: Asks folds the log and Leaves comes from @@ -237,6 +264,7 @@ export function createTrays({ tray.btn.setAttribute("aria-expanded", "true"); tray.paint?.(); tray.panel.classList.add("open"); + tray.workspace.sync(true); document.body.dataset.lfTray = trayUp; } function restoreTrays() { @@ -256,9 +284,10 @@ export function createTrays({ function mountTrays() { traysEdge.handle(othersPanel, () => othersBtn); traysEdge.handle(asksPanel, () => asksBtn); - for (const [key, { btn }] of trays) { + for (const [key, { btn, close }] of trays) { btn.classList.add("lf-workspace"); btn.onclick = () => showTray(openTray(key) ? null : key); + close.onclick = () => showTray(null); btn.setAttribute("aria-expanded", "false"); } keys( diff --git a/skills/leaf/assets/runtime/workspace-modality.js b/skills/leaf/assets/runtime/workspace-modality.js new file mode 100644 index 000000000..a5863407d --- /dev/null +++ b/skills/leaf/assets/runtime/workspace-modality.js @@ -0,0 +1,225 @@ +/* The shared modal boundary for an auxiliary workspace that covers the document. + + Visibility owners keep their ordinary surfaces and scrollports while responsive + layout decides whether they stand beside the page or over it. In the covering + posture this owner makes every sibling reading surface inert, dims that entire + background, gives the workspace modal semantics, and moves focus in only when it was + outside. It re-derives those siblings when the live version replaces the authored + page. Leaving that posture restores exactly the inert and role state it found; it + does not rebuild, hide, or scroll either side. + + Native inertness owns sequential focus and pointer reach. This owner adds the Tab + wrap and programmatic-focus recovery that a non-top-layer workspace still needs. + Entering the boundary dismisses pre-existing outside popovers. Native dialogs, and + popovers deliberately opened after entry, remain available to their top-layer owner. */ + +import { openNativePopovers } from "./native-layers.js"; +import { under } from "./shadow.js"; + +export function createWorkspaceModality({ chromeRoot, focusable }) { + const controllers = new Set(); + const scrim = document.createElement("div"); + scrim.className = "lf-workspace-scrim"; + scrim.hidden = true; + scrim.setAttribute("aria-hidden", "true"); + let active = null; + let placingFocus = false; + let mounted = false; + const layerAllowedBy = (controller, node, establishedOver = null) => + !controller || + controller.surface === establishedOver || + under(node, controller.surface); + + const deepestFocus = () => { + let node = document.activeElement; + while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement; + return node; + }; + const stops = (surface) => + [...surface.querySelectorAll(focusable)].filter( + (node) => + node.tabIndex >= 0 && + !node.matches(":disabled") && + !node.inert && + node.checkVisibility(), + ); + const place = (node) => { + placingFocus = true; + try { + node.focus({ preventScroll: true }); + } finally { + placingFocus = false; + } + }; + const nativeLayerContains = (node) => node?.closest?.("dialog:modal, :popover-open"); + const overlay = (node) => node.matches?.("dialog:not(.lf-panel), [popover]"); + const background = (surface) => { + const nodes = []; + for (const child of document.body.children) { + if (child !== chromeRoot) nodes.push(child); + } + for (const child of chromeRoot.children) { + if (child !== surface && child !== scrim && !overlay(child)) nodes.push(child); + } + return nodes; + }; + + const syncBackground = (controller) => { + const next = new Set(background(controller.surface)); + for (const [node, inert] of controller.suspended) { + if (next.has(node)) continue; + node.inert = inert; + controller.suspended.delete(node); + } + for (const node of next) { + if (!controller.suspended.has(node)) controller.suspended.set(node, node.inert); + node.inert = true; + } + }; + + const backgroundMutations = new MutationObserver(() => { + if (active) syncBackground(active); + }); + + const focusMutations = new MutationObserver(() => { + if ( + active && + !active.surface.contains(document.activeElement) && + !nativeLayerContains(document.activeElement) + ) + place(active.focus() ?? active.surface); + }); + + function enter(controller) { + if (active && active !== controller) + throw new Error("leaf: two covering workspaces cannot be modal together"); + if (active === controller) return; + + for (const popover of openNativePopovers()) + if (!layerAllowedBy(controller, popover)) popover.hidePopover(); + active = controller; + syncBackground(controller); + controller.role = controller.surface.getAttribute("role"); + controller.surface.setAttribute("role", "dialog"); + controller.surface.setAttribute("aria-modal", "true"); + document.body.dataset.lfModalWorkspace = controller.surface.id; + scrim.hidden = false; + backgroundMutations.observe(document.body, { childList: true }); + backgroundMutations.observe(chromeRoot, { childList: true }); + focusMutations.observe(controller.surface, { childList: true, subtree: true }); + + if (!controller.surface.contains(document.activeElement)) + place(controller.focus() ?? controller.surface); + } + + function leave(controller) { + if (active !== controller) return; + backgroundMutations.disconnect(); + focusMutations.disconnect(); + for (const [node, inert] of controller.suspended) node.inert = inert; + controller.suspended.clear(); + if (controller.role === null) controller.surface.removeAttribute("role"); + else controller.surface.setAttribute("role", controller.role); + controller.surface.removeAttribute("aria-modal"); + delete document.body.dataset.lfModalWorkspace; + scrim.hidden = true; + active = null; + } + + function register({ surface, scroller, covers, focus, dismiss }) { + if (!surface?.id || !scroller || !covers || !focus || !dismiss) + throw new Error( + "leaf: a modal workspace needs a named surface, scroller, covering reading, focus destination, and dismissal", + ); + const controller = { + surface, + scroller, + covers, + focus, + dismiss, + open: false, + role: null, + suspended: new Map(), + sync(open) { + controller.open = open; + if (open && covers()) enter(controller); + else leave(controller); + }, + }; + controllers.add(controller); + return controller; + } + + function sync() { + for (const controller of controllers) controller.sync(controller.open); + } + + function mount() { + if (mounted) return; + mounted = true; + scrim.addEventListener("pointerdown", (event) => event.preventDefault()); + scrim.addEventListener("click", () => active?.dismiss()); + document.addEventListener( + "keydown", + (event) => { + if ( + !active || + event.key !== "Tab" || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + const available = stops(active.surface); + if (!available.length) { + event.preventDefault(); + place(active.surface); + return; + } + const at = available.indexOf(deepestFocus()); + if ( + (!event.shiftKey && at === available.length - 1) || + (event.shiftKey && at === 0) + ) { + event.preventDefault(); + place(event.shiftKey ? available.at(-1) : available[0]); + } + }, + true, + ); + document.addEventListener("focusin", (event) => { + if ( + !active || + placingFocus || + active.surface.contains(event.target) || + nativeLayerContains(event.target) + ) + return; + place(active.focus() ?? active.surface); + }); + } + + const coveringSurface = () => active?.surface ?? null; + const coveringScroller = () => active?.scroller() ?? null; + const coveringFocus = () => (active ? (active.focus() ?? active.surface) : null); + const allowsNativeLayer = (node, establishedOver = null) => + layerAllowedBy(active, node, establishedOver); + const openSurfaceFor = (node) => { + for (const controller of controllers) + if (controller.open && controller.surface.contains(node)) + return controller.surface; + return null; + }; + + return { + scrim, + register, + sync, + mount, + coveringSurface, + coveringScroller, + coveringFocus, + allowsNativeLayer, + openSurfaceFor, + }; +} diff --git a/skills/leaf/assets/runtime/workspace.js b/skills/leaf/assets/runtime/workspace.js index bb7ff3f4c..66e1895f1 100644 --- a/skills/leaf/assets/runtime/workspace.js +++ b/skills/leaf/assets/runtime/workspace.js @@ -62,10 +62,10 @@ export function createWorkspaceNavigation({ const { panel: hadPanel, tray } = state; if (tray) showTray(tray); else if (hadPanel) { - showTray(null); + showTray(null, { returnFocus: false }); setPanel(true); } else { - showTray(null); + showTray(null, { returnFocus: false }); setPanel(false); } return state.control(); diff --git a/skills/leaf/assets/theme.css b/skills/leaf/assets/theme.css index 8f81c07a9..17fde5fb6 100644 --- a/skills/leaf/assets/theme.css +++ b/skills/leaf/assets/theme.css @@ -496,10 +496,13 @@ body { position: static !important; box-sizing: border-box !important; ) body { margin-right: var(--lf-panel-w); } } @media screen and (width <= 840px) { - :is( - html[data-lf-restore-panel], - html[data-lf-live]:has(body[data-lf-panel]) - ) { overflow-y: hidden; } + html[data-lf-restore-panel] { overflow-y: hidden; } +} +/* A workspace that covers the document owns the one visible reading scrollport. The + * runtime's shared modality boundary states this independently of which workspace is + * open, including Leaves, whose overlay posture does not depend on viewport width. */ +@media screen { + html[data-lf-live]:has(body[data-lf-modal-workspace]) { overflow-y: hidden; } } @media screen and (not (width <= 600px)) { :is( @@ -511,10 +514,7 @@ body { position: static !important; box-sizing: border-box !important; } } @media screen and (width <= 600px) { - :is( - html[data-lf-restore-tray="asks"], - html[data-lf-live]:has(body[data-lf-tray="asks"]) - ) { overflow-y: hidden; } + html[data-lf-restore-tray="asks"] { overflow-y: hidden; } } /* Authored HTML is the page's useful initial condition, so it paints from stylesheet * parse while JavaScript upgrades widgets and reads the standing log. Generated page diff --git a/tests/test_render_aim.py b/tests/test_render_aim.py index 1dbb54085..27debd2e2 100644 --- a/tests/test_render_aim.py +++ b/tests/test_render_aim.py @@ -53,7 +53,6 @@ TYPED_PARTS_PAGE, TYPED_PARTS_V2, aim_targets, - banner_address, draw_edge, edge_settled, geometry, @@ -928,20 +927,19 @@ def test_an_aim_tracks_an_equal_width_workspace_swap_every_frame(browser, serve) def test_covering_workspaces_separate_page_paint_from_chrome_target_paint( browser, serve ): - """A covering workspace owns its pixels until the reader targets that workspace. + """A covering workspace owns its pixels and remains a chrome target itself. - The aim, response bar, design legend, and inspect name share two semantic stacking - planes. Paint attached to page content stays below the sheet; paint naming a target - inside Leaf's chrome rises above it. The target decides the plane, so the same aim and - response bar can serve both without a viewport-width z-index exception. + Covered page content is inert, so aim and comment cannot reach it through the visible + remainder. The sheet remains part of Leaf's chrome: its aim, inspect name, and response + bar use the chrome plane above it, while the page's standing design legend stays below. """ page, errors = open_page(browser, serve(ASKS_PAGE)) - resized(page, 560, 900) - # A window this narrow folds the row's destinations into the banner's one menu, so - # the address is asked for where the page has put it rather than where a wider - # window would have left it. - banner_address(page, ".lf-asks").click() + resized(page, 700, 900) + page.locator(".lf-asks").click() edge_settled(page, EDGES[1]) + page.keyboard.press("l") + expect(page.locator("body")).to_have_class(re.compile(r"\blf-design\b")) + resized(page, 560, 900) tray = page.locator(".lf-asks-panel") expect(tray).to_be_visible() @@ -954,37 +952,11 @@ def test_covering_workspaces_separate_page_paint_from_chrome_target_paint( } page.mouse.move(point["x"], point["y"]) page.keyboard.down("Alt") - expect(page.locator(".lf-aim")).to_have_attribute("data-for", "lq-keep") - page_plane = page.evaluate( - """() => { - const tray = document.querySelector('.lf-asks-panel'); - const aim = document.querySelector('.lf-aim'); - return {tray: Number(getComputedStyle(tray).zIndex), - aim: Number(getComputedStyle(aim).zIndex), - plane: aim.dataset.lfPaintPlane}; - }""" - ) - assert page_plane["plane"] == "page" and page_plane["aim"] < page_plane["tray"], ( - f"page aim paints over the covering Asks sheet: {page_plane}" - ) - page.mouse.click(point["x"], point["y"]) page.keyboard.up("Alt") - expect(page.locator(".lf-composer")).to_be_visible() - response_plane = page.locator(".lf-fab-bar").evaluate( - "node => ({plane: node.dataset.lfPaintPlane, " - "z: Number(getComputedStyle(node).zIndex), " - "tray: Number(getComputedStyle(document.querySelector('.lf-asks-panel')).zIndex)})" - ) - assert ( - response_plane["plane"] == "page" - and response_plane["z"] < response_plane["tray"] - ), f"page response bar paints over the covering Asks sheet: {response_plane}" - page.keyboard.press("Escape") + expect(page.locator('.lf-aim[data-for="lq-keep"]')).to_be_hidden() + expect(page.locator(".lf-composer")).to_be_hidden() - page.locator("body").focus() - page.keyboard.press("l") - expect(page.locator("body")).to_have_class(re.compile(r"\blf-design\b")) tray_box = tray.bounding_box() assert tray_box is not None page.mouse.move(tray_box["x"] + 12, tray_box["y"] + 12) diff --git a/tests/test_render_controls.py b/tests/test_render_controls.py index 91fefc7ac..f97981af1 100644 --- a/tests/test_render_controls.py +++ b/tests/test_render_controls.py @@ -1871,6 +1871,10 @@ def drag(edge, dx): assert ( narrow_decisions["edge"]["right"] <= narrow_decisions["viewport"] + 0.1 ), narrow_decisions + page.keyboard.press("Escape") + expect(page.locator(".lf-asks-panel")).not_to_have_class( + re.compile(r"\bopen\b") + ) # Exercise both mirrored owners in different layout postures. A swipe beside the # visible grip scrolls its list without moving the boundary; a horizontal drag on @@ -2905,23 +2909,15 @@ def test_a_closed_leaf_clears_itself_off_the_tray(browser, serve, other_leaf): expect(rows).to_have_count(0) expect(btn).to_have_text("All leaves (1)") expect(page.locator(".lf-others-self .lf-others-title")).to_have_text("long") - # The open panel remains a destination after its last link leaves. Its own nav is - # the fallback landing, and it promises no row walk while there is nothing to walk. + # The open panel remains the modal destination after its last link leaves. Its own + # nav is the fallback landing, and its global address remains the same toggle as the + # banner door even though that inert door is unavailable to a pointer. page.keyboard.press("g") - expect(page.locator(".lf-shortcut-bar")).to_contain_text("All leaves panel") page.keyboard.press("Shift+l") - expect(page.locator(".lf-others-panel")).to_be_focused() - expect(page.locator(".lf-shortcut-bar")).not_to_contain_text("walk the leaves") - assert page.locator(".lf-others-panel").get_attribute("aria-keyshortcuts") is None - # Two presses in, two Escapes out. The second `g L` entered a tray that was already - # standing, so its own Escape gives that press back and leaves the workspace it - # found; the tray the first press stood up closes on the one after. Nothing live left - # to open: the button stands while the panel does and stands down with it, which is - # the count's other half. - page.keyboard.press("Escape") - expect(page.locator(".lf-others-panel")).to_be_visible() - page.keyboard.press("Escape") expect(page.locator(".lf-others-panel")).not_to_be_visible() + expect(btn).to_have_attribute("aria-expanded", "false") + # Nothing live remains to reopen it: its button stands while the panel does and + # stands down with it, which is the count's other half. told(page) expect(btn).not_to_be_visible() assert errors == [] @@ -3105,6 +3101,8 @@ def test_workspaces_replace_each_other_and_name_the_open_one( The open workspace keeps its semantic expanded state and also wears the banner's active face. Its peers return to rest as it takes their place, so the tint names exactly the workspace the reader can see rather than merely the last one pressed. + A workspace beside the page can be replaced directly; a covering one is modal and + must close before its banner peers become available again. """ page, errors = open_page(browser, serve(MANY_ASKS_PAGE)) resized(page, width, 700) @@ -3146,16 +3144,24 @@ def expect_open(name): page.locator(".lf-asks").click() expect(asks).to_have_class(re.compile(r"\bopen\b")) expect_open("asks") + if page.locator("main").evaluate("el => el.inert"): + page.keyboard.press("Escape") + expect(asks).not_to_have_class(re.compile(r"\bopen\b")) page.locator(".lf-threads-toggle").click() panel_settled(page) expect(asks).not_to_have_class(re.compile(r"\bopen\b")) expect_open("threads") + if page.locator("main").evaluate("el => el.inert"): + page.keyboard.press("Escape") + panel_settled(page, open=False) page.keyboard.press("g") page.keyboard.press("Shift+l") expect(page.locator(".lf-others-panel")).to_have_class(re.compile(r"\bopen\b")) expect_open("leaves") + page.keyboard.press("Escape") + expect(page.locator(".lf-others-panel")).not_to_have_class(re.compile(r"\bopen\b")) page.locator(".lf-asks").click() panel_settled(page, open=False) expect(asks).to_have_class(re.compile(r"\bopen\b")) @@ -3165,6 +3171,422 @@ def expect_open(name): page.close() +def test_covering_threads_keeps_the_reader_and_their_work_inside(browser, serve): + """A covering Threads sheet is the one place the reader can work until it closes. + + The same open panel stands beside the document on a wide window and over it on a + narrow one. Crossing that line must not rebuild the conversation: the exact thread + in focus, the general draft, and the list's reading place survive both directions. + While it covers, Tab, the Leaf reading keys, native paging, and the wheel all stay in + the panel; none can move to or scroll the covered document. Closing gives a keyboard + entrant their prior page focus and unchanged document reading back. + """ + page, errors = open_page(browser, serve(LONG_PAGE, comments=12)) + resized(page, 1000, 640) + page.evaluate("() => document.scrollingElement.scrollTop = 240") + document_at = page.evaluate("() => document.scrollingElement.scrollTop") + page.locator("body").focus() + page.keyboard.press("g") + page.keyboard.press("Shift+t") + panel_settled(page) + expect(page.locator(".lf-panel")).not_to_have_attribute("aria-modal", "true") + + draft = "Keep this draft through both workspace postures." + page.locator(".lf-general textarea").fill(draft) + threads = page.locator(".lf-threads") + thread = threads.locator(".lf-thread").nth(5) + thread.focus() + thread.evaluate("el => el.scrollIntoView({block: 'start'})") + list_at = threads.evaluate("el => el.scrollTop") + identity = thread.get_attribute("data-id") + assert identity and list_at > 0, "the fixture established no thread reading place" + + resized(page, 500, 640) + panel_settled(page) + assert page.locator("main").evaluate("el => el.inert") + expect(page.locator(".lf-panel")).to_have_attribute("aria-modal", "true") + expect(page.locator(f'.lf-thread[data-id="{identity}"]')).to_be_focused() + expect(page.locator(".lf-general textarea")).to_have_value(draft) + assert threads.evaluate("el => el.scrollTop") == pytest.approx(list_at, abs=1) + + # A complete pass through more stops than this panel holds has to wrap within it. + focus_stops = page.locator( + ".lf-panel button:visible, .lf-panel input:visible, " + ".lf-panel textarea:visible, .lf-panel [tabindex='0']:visible" + ) + assert focus_stops.count() > 8, ( + "the panel has too few stops to expose a focus escape" + ) + focus_stops.last.focus() + page.keyboard.press("Tab") + expect(page.locator(".lf-panel > .lf-edge")).to_be_focused() + for _ in range(focus_stops.count() + 3): + page.keyboard.press("Tab") + assert page.evaluate( + "() => document.querySelector('.lf-panel').contains(document.activeElement)" + ), "Tab reached a control behind the covering Threads workspace" + + open_filter = page.locator('[data-filter-value="open"]') + open_filter.focus() + resized(page, 1000, 640) + panel_settled(page) + expect(open_filter).to_be_focused() + expect(page.locator(".lf-panel")).not_to_have_attribute("aria-modal", "true") + resized(page, 500, 640) + panel_settled(page) + expect(open_filter).to_be_focused() + expect(page.locator(".lf-panel")).to_have_attribute("aria-modal", "true") + + threads.evaluate("el => el.scrollTop = 0") + page.locator(".lf-threads").focus() + page.keyboard.press("d") + page.wait_for_function("() => document.querySelector('.lf-threads').scrollTop > 0") + after_key = threads.evaluate("el => el.scrollTop") + assert page.evaluate("() => document.scrollingElement.scrollTop") == document_at + + page.keyboard.press("PageDown") + page.wait_for_function( + "at => document.querySelector('.lf-threads').scrollTop > at", arg=after_key + ) + after_page = threads.evaluate("el => el.scrollTop") + box = threads.bounding_box() + assert box is not None + page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + page.mouse.wheel(0, 300) + page.wait_for_function( + "at => document.querySelector('.lf-threads').scrollTop > at", arg=after_page + ) + page.evaluate( + "() => { window.__lfWorkspaceScroll = -1;" + " window.__lfWorkspaceScrollSince = performance.now(); }" + ) + page.wait_for_function( + "hold => { const now = document.querySelector('.lf-threads').scrollTop;" + " if (now !== window.__lfWorkspaceScroll) { window.__lfWorkspaceScroll = now;" + " window.__lfWorkspaceScrollSince = performance.now(); return false; }" + " return performance.now() - window.__lfWorkspaceScrollSince > hold; }", + arg=50, + ) + assert page.evaluate("() => document.scrollingElement.scrollTop") == document_at + + # Focus already inside the workspace is not a reason to move it at either crossing. + thread = page.locator(f'.lf-thread[data-id="{identity}"]') + thread.focus() + list_at = threads.evaluate("el => el.scrollTop") + resized(page, 1000, 640) + panel_settled(page) + assert not page.locator("main").evaluate("el => el.inert") + expect(thread).to_be_focused() + expect(page.locator(".lf-panel")).not_to_have_attribute("aria-modal", "true") + expect(page.locator(".lf-general textarea")).to_have_value(draft) + assert threads.evaluate("el => el.scrollTop") == pytest.approx(list_at, abs=1) + resized(page, 500, 640) + panel_settled(page) + expect(thread).to_be_focused() + expect(page.locator(".lf-panel")).to_have_attribute("aria-modal", "true") + + page.keyboard.press("Escape") + expect(page.locator(".lf-panel")).to_be_hidden() + assert page.evaluate("() => document.activeElement === document.body") + assert not page.locator("main").evaluate("el => el.inert") + assert page.evaluate("() => document.scrollingElement.scrollTop") == document_at + assert errors == [] + page.close() + + +def test_a_covering_workspace_keeps_a_replacement_document_inert(browser, serve): + """A live version can replace main without reopening the covering workspace. + + The modal boundary follows that replacement, so the new document cannot become a + pointer or keyboard target while the sheet still claims modal semantics. Closing + restores the new document rather than the detached one it replaced. + """ + url = serve(LONG_PAGE, comments=2) + page, errors = open_page(browser, live_url(url)) + resized(page, 700, 640) + page.locator(".lf-threads-toggle").click() + panel_settled(page) + page.evaluate("() => { window.__lfReplacedMain = document.querySelector('main'); }") + assert page.locator("main").evaluate("el => el.inert") + + revised = LONG_PAGE.replace( + '

Long

', '

Long after replacement

' + ) + (serve.page_dir / ".fixture-versions" / "v2.html").write_text(revised) + stamp_version_file(serve.page_dir, 2, "replace the document") + told(page) + expect(page.locator("#t")).to_have_text("Long after replacement") + + state = page.evaluate( + """() => ({ + oldConnected: window.__lfReplacedMain.isConnected, + sameMain: window.__lfReplacedMain === document.querySelector('main'), + inert: document.querySelector('main').inert, + modal: document.querySelector('.lf-panel').getAttribute('aria-modal'), + focusInside: document.querySelector('.lf-panel').contains(document.activeElement), + })""" + ) + assert state == { + "oldConnected": False, + "sameMain": False, + "inert": True, + "modal": "true", + "focusInside": True, + }, f"the replacement escaped its standing modal workspace: {state}" + + page.get_by_role("button", name="Close threads").click() + panel_settled(page, open=False) + assert not page.locator("main").evaluate("el => el.inert") + expect(page.locator(".lf-threads-toggle")).to_be_focused() + assert errors == [] + page.close() + + +def test_a_covering_tray_uses_the_same_modal_workspace_boundary(browser, serve): + """The Asks tray gets the covering workspace contract rather than a tray-specific + focus trap. Its exact Ask and reading place survive both responsive crossings, its + reading keys move its own list, and closing returns to its door without moving the + document behind it.""" + page, errors = open_page(browser, serve(MANY_ASKS_PAGE)) + resized(page, 500, 640) + page.evaluate("() => document.scrollingElement.scrollTop = 180") + document_at = page.evaluate("() => document.scrollingElement.scrollTop") + page.locator(".lf-asks").click() + tray = page.locator(".lf-asks-panel") + expect(tray).to_have_class(re.compile(r"\bopen\b")) + assert page.locator("main").evaluate("el => el.inert") + expect(tray).to_have_attribute("role", "dialog") + expect(tray).to_have_attribute("aria-modal", "true") + + rows = tray.locator(".lf-asks-row") + row = rows.nth(8) + row.focus() + row.evaluate("el => el.scrollIntoView({block: 'start'})") + identity = row.get_attribute("data-lf-at") + list_box = tray.locator(".lf-tray-list") + list_at = list_box.evaluate("el => el.scrollTop") + assert identity and list_at > 0, "the fixture established no Ask reading place" + + for _ in range(rows.count() + 3): + page.keyboard.press("Tab") + assert tray.evaluate("el => el.contains(document.activeElement)"), ( + "Tab reached a control behind the covering Asks workspace" + ) + + list_box.evaluate("el => el.scrollTop = 0") + rows.first.focus() + page.keyboard.press("d") + page.wait_for_function( + "() => document.querySelector('.lf-asks-panel .lf-tray-list').scrollTop > 0" + ) + page.evaluate( + "() => { window.__lfWorkspaceScroll = -1;" + " window.__lfWorkspaceScrollSince = performance.now(); }" + ) + page.wait_for_function( + "hold => { const now = document.querySelector(" + "'.lf-asks-panel .lf-tray-list').scrollTop;" + " if (now !== window.__lfWorkspaceScroll) { window.__lfWorkspaceScroll = now;" + " window.__lfWorkspaceScrollSince = performance.now(); return false; }" + " return performance.now() - window.__lfWorkspaceScrollSince > hold; }", + arg=50, + ) + assert page.evaluate("() => document.scrollingElement.scrollTop") == document_at + + row = tray.locator(f'.lf-asks-row[data-lf-at="{identity}"]') + row.focus() + list_at = list_box.evaluate("el => el.scrollTop") + resized(page, 900, 640) + assert not page.locator("main").evaluate("el => el.inert") + expect(row).to_be_focused() + expect(tray).not_to_have_attribute("aria-modal", "true") + expect(tray).not_to_have_attribute("role", "dialog") + assert list_box.evaluate("el => el.scrollTop") == pytest.approx(list_at, abs=1) + resized(page, 500, 640) + assert page.locator("main").evaluate("el => el.inert") + expect(row).to_be_focused() + expect(tray).to_have_attribute("aria-modal", "true") + expect(tray).to_have_attribute("role", "dialog") + + page.keyboard.press("Escape") + expect(tray).not_to_have_class(re.compile(r"\bopen\b")) + expect(page.locator(".lf-asks")).to_be_focused() + assert not page.locator("main").evaluate("el => el.inert") + assert page.evaluate("() => document.scrollingElement.scrollTop") == document_at + assert errors == [] + page.close() + + +def test_covering_trays_have_a_pointer_route_back_to_their_banner_controls( + browser, serve, other_leaf +): + """Each tray can be dismissed from inside the modal surface by pointer. + + Pointer entry starts in the list rather than on its dismissal furniture. The banner + controls are inert while a tray covers the document, so they cannot be the only + pointer route out. Closing either tray returns focus to the control that opened it, + ready to reopen the same workspace. + """ + page, errors = open_page(browser, serve(MANY_ASKS_PAGE)) + resized(page, 500, 640) + expect(page.locator(".lf-others")).to_have_text("All leaves (2)") + assert page.locator(".lf-others").evaluate( + "el => Boolean(el.closest('.lf-banner-menu'))" + ), "the fixture did not fold Leaves behind the banner menu" + + for selector, panel, name, first_destination in ( + (".lf-asks", ".lf-asks-panel", "asks", ".lf-asks-row"), + (".lf-others", ".lf-others-panel", "leaves", "a.lf-others-row"), + ): + door = banner_address(page, selector) + door.click() + tray = page.locator(panel) + expect(tray).to_have_class(re.compile(r"\bopen\b")) + assert page.locator("main").evaluate("el => el.inert") + expect(page.locator(".lf-banner-menu")).not_to_be_visible() + expect(tray.locator(first_destination).first).to_be_focused() + + page.get_by_role("button", name=f"Close {name}").click() + expect(tray).not_to_have_class(re.compile(r"\bopen\b")) + expect(door).to_be_focused() + assert not page.locator("main").evaluate("el => el.inert") + + assert errors == [] + page.close() + + +def test_the_shared_workspace_scrim_marks_and_dismisses_a_covering_surface( + browser, serve, other_leaf +): + """A covering workspace stands above a scrim covering every inert surface.""" + page, errors = open_page(browser, serve(LONG_PAGE)) + resized(page, 1200, 700) + scrim = page.locator(".lf-workspace-scrim") + expect(scrim).to_be_hidden() + + leaves_door = banner_address(page, ".lf-others") + leaves_door.click() + leaves = page.locator(".lf-others-panel") + expect(leaves).to_have_class(re.compile(r"\bopen\b")) + expect(scrim).to_be_visible() + assert page.locator("main").evaluate("el => el.inert") + scrim_reading = page.evaluate( + """() => { + const scrim = document.querySelector('.lf-workspace-scrim'); + const banner = document.querySelector('.lf-banner'); + const bannerBox = banner.getBoundingClientRect(); + const scrimBox = scrim.getBoundingClientRect(); + const surface = document.querySelector('.lf-others-panel'); + const tray = document.querySelector('.lf-others-panel').getBoundingClientRect(); + const point = {x: Math.max(tray.right + 20, innerWidth * .75), y: innerHeight / 2}; + const bannerPoint = { + x: bannerBox.left + bannerBox.width / 2, + y: bannerBox.top + bannerBox.height / 2, + }; + return { + color: getComputedStyle(scrim).backgroundColor, + point, + hit: document.elementFromPoint(point.x, point.y) === scrim, + top: scrimBox.top, + coversBanner: scrimBox.top <= bannerBox.top && scrimBox.bottom >= bannerBox.bottom, + bannerHit: document.elementFromPoint(bannerPoint.x, bannerPoint.y) === scrim, + bannerLayer: Number(getComputedStyle(banner).zIndex), + scrimLayer: Number(getComputedStyle(scrim).zIndex), + surfaceLayer: Number(getComputedStyle(surface).zIndex), + }; + }""" + ) + assert scrim_reading["color"] != "rgba(0, 0, 0, 0)", scrim_reading + assert scrim_reading["hit"], scrim_reading + assert scrim_reading["top"] == 0, scrim_reading + assert scrim_reading["coversBanner"], scrim_reading + assert scrim_reading["bannerHit"], scrim_reading + assert ( + scrim_reading["surfaceLayer"] + > scrim_reading["scrimLayer"] + > scrim_reading["bannerLayer"] + ), scrim_reading + page.mouse.click(scrim_reading["point"]["x"], scrim_reading["point"]["y"]) + expect(leaves).not_to_have_class(re.compile(r"\bopen\b")) + expect(leaves_door).to_be_focused() + expect(scrim).to_be_hidden() + assert not page.locator("main").evaluate("el => el.inert") + + threads_door = page.locator(".lf-threads-toggle") + threads_door.click() + panel_settled(page) + expect(scrim).to_be_hidden() + assert not page.locator("main").evaluate("el => el.inert") + resized(page, 700, 700) + panel_settled(page) + expect(scrim).to_be_visible() + assert page.locator("main").evaluate("el => el.inert") + assert page.locator(".lf-panel").evaluate( + "panel => Number(getComputedStyle(panel).zIndex) > " + "Number(getComputedStyle(document.querySelector('.lf-workspace-scrim')).zIndex)" + ) + page.get_by_role("button", name="Close threads").click() + panel_settled(page, open=False) + expect(threads_door).to_be_focused() + expect(scrim).to_be_hidden() + assert errors == [] + page.close() + + +@pytest.mark.parametrize( + ("key", "surface", "close_name"), + [ + ("Shift+t", ".lf-panel", "Close threads"), + ("Shift+a", ".lf-asks-panel", "Close asks"), + ], +) +def test_a_keyboard_workspace_entry_survives_covering_to_beside( + browser, serve, key, surface, close_name +): + """Posture does not retire a live return, while closing its workspace does.""" + page, errors = open_page(browser, serve(MANY_ASKS_PAGE)) + resized(page, 500, 640) + origin = page.locator("main .lf-pick").first + origin.focus() + + page.keyboard.press("g") + page.keyboard.press(key) + if surface == ".lf-panel": + panel_settled(page) + else: + expect(page.locator(surface)).to_have_class(re.compile(r"\bopen\b")) + assert page.locator("main").evaluate("el => el.inert") + + resized(page, 1000, 640) + if surface == ".lf-panel": + panel_settled(page) + assert not page.locator("main").evaluate("el => el.inert") + expect(page.locator(surface)).to_have_class(re.compile(r"\bopen\b")) + + page.keyboard.press("Escape") + if surface == ".lf-panel": + panel_settled(page, open=False) + else: + expect(page.locator(surface)).not_to_have_class(re.compile(r"\bopen\b")) + expect(origin).to_be_focused() + + page.keyboard.press("g") + page.keyboard.press(key) + if surface == ".lf-panel": + panel_settled(page) + page.get_by_role("button", name=close_name).click() + if surface == ".lf-panel": + panel_settled(page, open=False) + else: + expect(page.locator(surface)).not_to_have_class(re.compile(r"\bopen\b")) + page.keyboard.press("Escape") + assert page.evaluate("() => document.activeElement === document.body"), ( + "closing the workspace left its keyboard return frame live" + ) + assert errors == [] + page.close() + + def test_a_walk_down_the_tray_stops_clear_of_the_shortcut_bar_text( browser, serve, live_leaf ): @@ -3494,6 +3916,7 @@ def sweep(where): sweep("in the keyboard reference") page.keyboard.press("Escape") expect(page.locator(".lf-shortcut-reference")).to_be_hidden() + page_at_rest(page) # And the sequence's generated target hints, painted over the visible page rather than # inserted into it. @@ -3694,20 +4117,13 @@ def boxes(): f"the disjoint line overrode the panel list's own inset: {separate}" ) - # The g sequence is wider in this same viewport. Once it reaches across the footer's - # lane, the line lifts and the list reserves the band it really covers. - page.locator("body").focus() + # The global address sequence belongs to the modal workspace here. It must not restore + # the inert page's wider line, so the line and list remain in the same disjoint posture. page.keyboard.press("g") page.evaluate(RENDERED) sequence = boxes() - assert sequence["shortcut_bar"]["right"] > sequence["foot"]["left"], sequence - assert sequence["shortcut_bar"]["bottom"] <= sequence["foot"]["top"], ( - f"the intersecting sequence stood on the panel foot: {sequence}" - ) - sequence_cover = sequence["list"]["bottom"] - sequence["shortcut_bar"]["top"] - assert sequence_cover > 0 and sequence["listPad"] >= sequence_cover, sequence - assert sequence["listScrollPad"] >= sequence_cover, sequence - page.keyboard.press("Escape") + assert sequence["shortcut_bar"]["right"] < sequence["foot"]["left"], sequence + assert sequence["listPad"] < 20 and sequence["listScrollPad"] < 20, sequence # Beside the page the line is capped left of the panel, so the list keeps the inset # the stylesheet gives it rather than room for a line that never reaches it. @@ -4867,8 +5283,15 @@ def offered(page, selector): # walks use a wide window where their page-margin surfaces can stand beside the source. # Every other scope is read at the width the page opened at. RING_WALK_VIEWPORT = (1200, 900) -# Scopes whose page-margin surfaces the standing panel takes the place of. -RING_SCOPES_WITHOUT_PANEL = {"a contents link", "a thread card"} +# Scopes whose own route starts with Threads shut. A thread card and the narrow map need +# page-margin surfaces the panel replaces; the thread-list walk's own `g T` is the door +# under test, and now correctly toggles an already-open panel closed. +RING_SCOPES_STARTING_WITHOUT_PANEL = { + "the thread list", + "a contents link", + "a thread card", + "the page map sheet", +} RING_SCOPE_WIDTH = { "a contents link": 1600, "a thread card": 1600, @@ -5222,9 +5645,9 @@ def test_every_ring_the_layer_draws_is_shown_whole_somewhere_in_the_corpus( page.evaluate(RING_WALK_START) # Threads and a target's own Thread card are one surface offered two ways: # with the panel standing, a thread margin element sends the reader there instead of - # building the card, so the card's walk is the one scope that starts with the - # panel shut. Every other scope starts from the same open-panel page. - if scope in RING_SCOPES_WITHOUT_PANEL: + # building the card. The scopes listed above need the panel shut either to expose + # their own page surface or to exercise the panel's entry route itself. + if scope in RING_SCOPES_STARTING_WITHOUT_PANEL: if page.locator(".lf-panel.open").count(): page.get_by_role("button", name="Close threads").click() panel_settled(page, open=False) diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index 38e8964f2..fb9d2acb8 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -5323,18 +5323,26 @@ def test_the_arrows_say_which_way_the_section_under_the_reader_goes(browser, ser def test_named_workspace_chords_toggle_their_panels(browser, serve, live_leaf): - """Repeating a panel's complete address closes the panel it opened.""" + """Desktop panels toggle beside the page; Leaves toggles in its covering posture.""" live_leaf("second", "A second leaf") page, errors = open_page(browser, serve(ASKS_PAGE, comments=1)) - for key, command, name, surface, control in ( - ("Shift+t", "threads", "Threads", ".lf-panel", ".lf-threads-toggle"), - ("Shift+a", "asks", "Asks", ".lf-asks-panel", ".lf-asks"), - ("Shift+l", "leaves", "All leaves", ".lf-others-panel", ".lf-others"), + for key, command, name, surface, control, covering in ( + ("Shift+t", "threads", "Threads", ".lf-panel", ".lf-threads-toggle", False), + ("Shift+a", "asks", "Asks", ".lf-asks-panel", ".lf-asks", False), + ( + "Shift+l", + "leaves", + "All leaves", + ".lf-others-panel", + ".lf-others", + True, + ), ): page.keyboard.press("g") page.keyboard.press(key) expect(page.locator(surface)).to_be_visible() + assert page.locator("main").evaluate("main => main.inert") is covering page.keyboard.press("g") expect(page.locator("body")).to_have_attribute("data-lf-goto", "") @@ -5351,6 +5359,190 @@ def test_named_workspace_chords_toggle_their_panels(browser, serve, live_leaf): page.close() +def test_global_destinations_switch_from_a_covering_workspace( + browser, serve, live_leaf +): + """A modal workspace keeps the global addresses that can replace or cover it.""" + live_leaf("second", "A second leaf") + url = serve(ASKS_PAGE, comments=1) + _publish(serve.page_dir, 2, ASKS_PAGE, "two") + page, errors = open_page(browser, url) + resized(page, 500, 800) + + page.keyboard.press("g") + page.keyboard.press("Shift+l") + expect(page.locator(".lf-others-panel")).to_be_visible() + + for key, opened, closed in ( + ("Shift+a", ".lf-asks-panel", ".lf-others-panel"), + ("Shift+t", ".lf-panel", ".lf-asks-panel"), + ): + page.keyboard.press("g") + expect(page.locator("body")).to_have_attribute("data-lf-goto", "") + expect(page.locator(".lf-goto-targets > [data-lf-address]")).to_have_count(0) + page.keyboard.press(key) + expect(page.locator(opened)).to_be_visible() + expect(page.locator(closed)).to_be_hidden() + assert page.locator("main").evaluate("main => main.inert") + + page.keyboard.press("g") + page.keyboard.press("Shift+m") + page_map = page.locator(".lf-page-map-sheet") + expect(page_map).to_be_visible() + expect(page_map.locator(".lf-page-map-search")).to_be_focused() + assert page.locator("main").evaluate("main => main.inert") + assert not page_map.evaluate("surface => surface.inert") + page.keyboard.press("Escape") + expect(page_map).to_be_hidden() + + origin = page.locator(".lf-thread").first + origin.focus() + page.keyboard.press("g") + page.keyboard.press("Shift+v") + versions = page.locator(".lf-version-menu") + expect(versions).to_be_visible() + expect(versions.locator('.lf-version-row[data-lf-version="1"]')).to_be_focused() + assert page.locator("main").evaluate("main => main.inert") + assert not versions.evaluate("surface => surface.inert") + page.evaluate(RENDERED) + version_hints = {hint["commands"] for hint in page.evaluate(KEY_LINE_HINTS)} + assert {"version.later version.earlier", "version.open-v1 version.open-v2"} <= ( + version_hints + ), f"the covering workspace displaced the versions scope: {version_hints}" + + page.keyboard.press("ArrowUp") + expect(versions.locator('.lf-version-row[data-lf-version="2"]')).to_be_focused() + page.keyboard.press("1") + expect(versions).to_be_hidden() + expect(origin).to_be_focused() + + page.keyboard.press("g") + page.keyboard.press("Shift+v") + expect(versions).to_be_visible() + versions.locator("button:visible").last.focus() + page.keyboard.press("Tab") + expect(versions).to_be_hidden() + assert page.locator(".lf-panel").evaluate( + "panel => panel.contains(document.activeElement)" + ), "Tab left the version popover but escaped its modal workspace" + + origin.focus() + page.keyboard.press("g") + page.keyboard.press("Shift+v") + expect(versions).to_be_visible() + page.keyboard.press("Escape") + expect(versions).to_be_hidden() + expect(origin).to_be_focused() + expect(page.locator(".lf-panel")).to_be_visible() + + assert errors == [] + page.close() + + +def test_entering_a_covering_workspace_dismisses_an_existing_popover(browser, serve): + """Responsive modal entry closes a native layer that stood over the beside panel.""" + url = serve(LONG_PAGE, comments=2) + _publish(serve.page_dir, 2, LONG_PAGE, "two") + page, errors = open_page(browser, url) + resized(page, 1000, 800) + page.locator("body").focus() + page.keyboard.press("g") + page.keyboard.press("Shift+t") + panel_settled(page) + assert not page.locator("main").evaluate("main => main.inert") + + origin = page.locator(".lf-thread").first + origin.focus() + page.keyboard.press("g") + page.keyboard.press("Shift+v") + versions = page.locator(".lf-version-menu") + expect(versions).to_be_visible() + expect(versions.locator('.lf-version-row[data-lf-version="1"]')).to_be_focused() + + resized(page, 500, 800) + panel_settled(page) + expect(versions).to_be_hidden() + expect(origin).to_be_focused() + assert page.locator("main").evaluate("main => main.inert") + page.evaluate(RENDERED) + hints = {hint["commands"] for hint in page.evaluate(KEY_LINE_HINTS)} + assert {"thread.primary", "navigation.return"} <= hints, hints + assert not any(command.startswith("version.") for command in hints), hints + + page.keyboard.press("g") + page.keyboard.press("Shift+v") + expect(versions).to_be_visible() + page.keyboard.press("ArrowUp") + expect(versions.locator('.lf-version-row[data-lf-version="2"]')).to_be_focused() + page.keyboard.press("Escape") + expect(versions).to_be_hidden() + expect(origin).to_be_focused() + + resized(page, 1000, 800) + panel_settled(page) + expect(origin).to_be_focused() + assert not page.locator("main").evaluate("main => main.inert") + page.keyboard.press("Escape") + expect(page.locator(".lf-panel")).to_be_hidden() + assert page.evaluate("() => document.activeElement === document.body") + assert errors == [] + page.close() + + +def test_reference_does_not_restore_a_popover_across_modal_entry(browser, serve): + """A layer stashed beside Threads cannot return behind its new modal boundary.""" + url = serve(LONG_PAGE, comments=2) + _publish(serve.page_dir, 2, LONG_PAGE, "two") + page, errors = open_page(browser, url) + resized(page, 1000, 800) + page.locator("body").focus() + page.keyboard.press("g") + page.keyboard.press("Shift+t") + panel_settled(page) + + page.locator(".lf-thread").first.focus() + page.keyboard.press("g") + page.keyboard.press("Shift+v") + versions = page.locator(".lf-version-menu") + expect(versions).to_be_visible() + page.keyboard.press("?") + page.keyboard.press("?") + reference = page.locator(".lf-shortcut-reference") + expect(reference).to_be_visible() + + resized(page, 500, 800) + panel_settled(page) + page.keyboard.press("Escape") + expect(reference).to_be_hidden() + expect(versions).to_be_hidden() + panel = page.locator("#lf-threads") + assert panel.evaluate("panel => panel.contains(document.activeElement)") + assert page.locator("main").evaluate("main => main.inert") + page.evaluate(RENDERED) + hints = {hint["commands"] for hint in page.evaluate(KEY_LINE_HINTS)} + assert {"navigation.return", "thread.find"} <= hints, hints + assert not any(command.startswith("version.") for command in hints), hints + + # A layer explicitly opened over the established modal boundary still makes the + # reference round trip. Only the layer captured before that boundary was stale. + page.keyboard.press("g") + page.keyboard.press("Shift+v") + expect(versions).to_be_visible() + page.keyboard.press("?") + page.keyboard.press("?") + expect(reference).to_be_visible() + page.keyboard.press("Escape") + expect(reference).to_be_hidden() + expect(versions).to_be_visible() + page.keyboard.press("ArrowUp") + expect(versions.locator('.lf-version-row[data-lf-version="2"]')).to_be_focused() + page.keyboard.press("Escape") + expect(versions).to_be_hidden() + assert panel.evaluate("panel => panel.contains(document.activeElement)") + assert errors == [] + page.close() + + def test_the_key_line_says_what_a_press_will_do(browser, serve): """The shortcut bar and dispatcher read one return frame for each keyboard entry.""" url = serve(NOTED_PAGE) @@ -5416,6 +5608,9 @@ def test_the_key_line_says_what_a_press_will_do(browser, serve): expect(returning.locator('[data-lf-command="navigation.return"]')).to_contain_text( "Return from Threads panel" ) + expect( + help_el.get_by_role("heading", name="In the covering workspace", exact=True) + ).to_have_count(0) expect(help_el.locator('[data-lf-command="navigation.back"]')).to_have_count(0) page.keyboard.press("Escape") page.keyboard.press("Escape") diff --git a/tests/test_render_semantic_selection.py b/tests/test_render_semantic_selection.py index 657b10c09..cbc0aad9c 100644 --- a/tests/test_render_semantic_selection.py +++ b/tests/test_render_semantic_selection.py @@ -503,32 +503,21 @@ def test_s_opens_the_same_comment_field_on_a_declared_visual_part(browser, serve def test_selection_hints_do_not_name_page_content_behind_a_covering_panel( browser, serve ): - """A fixed panel covers rather than clips the page. Hint geometry read from the page - alone therefore still exists behind it, but a key drawn above the chrome there would - appear to name a panel control and choose hidden document content. The rendered stack - at each target's corner decides whether it is actually exposed.""" + """A covering panel removes the inert document from page-target selection.""" page, errors = open_page(browser, serve(ROOT / "examples" / "corpus.html")) resized(page, 700, 900) - page.get_by_role("button", name=re.compile(r"^Threads")).click() - expect(page.locator(".lf-panel")).to_be_visible() page.keyboard.press("s") expect(page.locator(".lf-target-hint")).not_to_have_count(0) + page.keyboard.press("Escape") + expect(page.locator(".lf-target-hint")).to_have_count(0) - geometry = page.evaluate( - """() => { - const panel = document.querySelector('.lf-panel').getBoundingClientRect(); - return { - panelLeft: panel.left, - centres: [...document.querySelectorAll('.lf-target-hint')].map((hint) => { - const box = hint.getBoundingClientRect(); - return box.left + box.width / 2; - }), - }; - }""" - ) - assert geometry["centres"] - assert max(geometry["centres"]) < geometry["panelLeft"], ( - f"a selection hint is painted on the covering thread panel: {geometry}" + page.get_by_role("button", name=re.compile(r"^Threads")).click() + expect(page.locator(".lf-panel")).to_be_visible() + assert page.locator("main").evaluate("el => el.inert") + expect(page.locator(".lf-panel")).to_have_attribute("aria-modal", "true") + page.keyboard.press("s") + assert page.locator(".lf-target-hint").count() == 0, ( + "page target selection crossed the covering workspace boundary" ) assert errors == [] page.close()