From 949c630d908ffb78bf24548ea553b1ac07f332f8 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 31 Aug 2026 18:35:13 -0700 Subject: [PATCH 1/3] Open anchored response fields immediately --- docs/example-previews.json | 2 +- docs/how-it-works.html | 13 +- docs/index.html | 12 +- examples/corpus.html | 4 +- examples/release-notes.html | 4 +- skills/leaf/CLAUDE.md | 85 +++-- skills/leaf/assets/leaf.js | 73 ++-- skills/leaf/assets/runtime/anchors.js | 13 +- skills/leaf/assets/runtime/chrome-layout.js | 23 +- skills/leaf/assets/runtime/chrome-style.js | 70 +++- skills/leaf/assets/runtime/composing/aim.js | 4 +- skills/leaf/assets/runtime/composing/input.js | 17 +- .../assets/runtime/composing/selection.js | 94 +++-- .../leaf/assets/runtime/composing/surface.js | 303 ++++++++-------- skills/leaf/assets/runtime/reactions.js | 200 ++++++++--- tests/render_cases_navigation.py | 8 +- tests/test_render_aim.py | 111 +++--- tests/test_render_anchors.py | 336 ++++++------------ tests/test_render_controls.py | 2 +- tests/test_render_drafts.py | 50 +-- tests/test_render_margin.py | 8 +- tests/test_render_navigation.py | 187 ++++------ tests/test_render_options_settled.py | 10 +- tests/test_render_outbox.py | 158 ++++---- tests/test_render_pages.py | 11 +- tests/test_render_projection.py | 10 +- tests/test_render_reactions.py | 255 ++++++++----- tests/test_render_semantic_selection.py | 151 ++++---- tests/test_render_startup.py | 26 +- tests/test_render_widgets.py | 43 +-- tests/test_site.py | 16 +- 31 files changed, 1174 insertions(+), 1125 deletions(-) diff --git a/docs/example-previews.json b/docs/example-previews.json index 61f112fe2..97d8c0eda 100644 --- a/docs/example-previews.json +++ b/docs/example-previews.json @@ -1,5 +1,5 @@ { - "inputs_sha256": "ae6f4b2df724a80367f54f2da7feee56279f6b52b23369557e3013b953a2cd02", + "inputs_sha256": "3ca377a401fe032c4096e6316866efe9615f723f29ac4fa31e18e91464236ba4", "previews": { "command-hub": { "file": "example-command-hub.jpg", diff --git a/docs/how-it-works.html b/docs/how-it-works.html index b424f71df..0c489a928 100644 --- a/docs/how-it-works.html +++ b/docs/how-it-works.html @@ -487,11 +487,14 @@

Edits are events; the document stays the state

The cheapest answer is one token. Select a passage, select an item with s, or stand in an agent reply, then open or - press r. Alt-clicking an item opens its comment box directly. The - ellipsis becomes the ok, no, lost, - cut, more, and this buttons; digits are - optional shortcuts. The token paints as a glyph in the margin and a faint wash - on the words, opens no thread, and comes off with a press on the glyph. An + press r. Selecting or Alt-clicking an item immediately focuses a + compact comment field beside it. The field grows as you type; Enter sends and + Shift-Enter adds a line. Tab replaces it with the other responses, including a + replacement-text suggestion for selected words. The ellipsis becomes the + ok, no, lost, cut, + more, and this buttons; digits are optional + shortcuts. The token paints as a glyph in the margin and a faint wash on the + words, opens no thread, and comes off with a press on the glyph. An ok on the agent's latest reply request takes the thread out of "waiting on you" without a word typed. The tokens are the layer's, not the machine's: a project's .leaf/ can rename, add, or remove them. diff --git a/docs/index.html b/docs/index.html index aa1d9ef98..84fa272e8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -137,11 +137,13 @@

Example: Review a plan

Mark it up. Select any passage and comment; the passage stays lit while you write, and the comment anchors to it and stays anchored as versions change — to the passage you picked, even where the page says the - same thing twice. Alt-clicking an item opens its comment box directly; - diagrams and images also expose response actions by click. A suggestion mode - proposes replacement text that the agent takes verbatim or answers with why - not. The agent opens threads the same way — a question about one sentence - arrives in the margin beside it, not in the terminal. + same thing twice. Selecting or Alt-clicking an item immediately focuses a + compact comment field beside it; the field grows in place, Enter sends, and + Tab reveals the other response options. Diagrams and images also expose + response actions by click. A suggestion mode in that response row proposes + replacement text that the agent takes verbatim or answers with why not. The + agent opens threads the same way — a question about one sentence arrives in + the margin beside it, not in the terminal.
  • Your comment reaches the session. On Claude Code, a diff --git a/examples/corpus.html b/examples/corpus.html index 17af4f211..f04bd1246 100644 --- a/examples/corpus.html +++ b/examples/corpus.html @@ -927,8 +927,8 @@

    Left out on purpose

    The dependency bumps and the flaky-test fixes stay in the changelog only — nothing a user would change behavior over. If one of them deserves a note - after all, Alt-click this section and say why; a draft arrives in the next - version. + after all, Alt-click this section and type in the focused field beside it; + Enter starts the thread, and a draft arrives in the next version.

    diff --git a/examples/release-notes.html b/examples/release-notes.html index 8c522f221..8a9577704 100644 --- a/examples/release-notes.html +++ b/examples/release-notes.html @@ -107,8 +107,8 @@

    Left out on purpose

    The dependency bumps and the flaky-test fixes stay in the changelog only — nothing a user would change behavior over. If one of them deserves a note - after all, Alt-click this section and say why; a draft arrives in the next - version. + after all, Alt-click this section and type in the focused field beside it; + Enter starts the thread, and a draft arrives in the next version.

    diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index eaa292e29..b1402220f 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -1029,9 +1029,9 @@ element outlines, and the open composer's pending mark. It clears and paints through the same composed-tree helpers, then records exactly what it drew in `marked`, `pendingMarks`, and `pendingOutline`. Other features consult those records rather than looking for arbitrary DOM paint. -The anchor runtime exposes only the questions those features ask — `isMarked`, -`placedAt`, and a snapshot from `pendingMarkParts` — so the pass-owned maps and -arrays cannot acquire a second writer through the entrypoint. +The anchor runtime exposes only the questions other features ask — `isMarked` and +`placedAt` — so the pass-owned maps and arrays cannot acquire a second writer through +the entrypoint. The same pass answers a second question and records it apart. `placed` is where each thread's passage lands in this version; `marked` is what was drawn for it. @@ -1122,20 +1122,30 @@ has no hover. Export keeps the glyph with its press taken off and writes the was into the words as a `` (BAKE), the highlight registry being script state no file can hold. -The bar a selection or keyboard-selected item raises is `.lf-fab-bar`: the `.lf-fab` -comment glyph followed by one reaction ellipsis. -For a page target, the ellipsis hands Comment and the layer's token buttons to -that target's shared margin item; it never opens a box below the floating bar. -`showFab` shows and places the compact bar; `selectResponseTarget` raises it for a -keyboard item hint. The ⌥ press has already chosen Comment, so `openTargetComposer` -opens the composer directly on the same captured anchor. `r` opens the same choices on the selection, -the standing item, or the latest agent message in the thread the reader is in. +The bar a selection or keyboard-selected item raises is `.lf-fab-bar`: the durable, +compact `.lf-fab-input` followed by one response ellipsis. Naming a target opens and +focuses that field immediately; it grows in place and never transfers text into a +second composer card. Enter sends and Shift-Enter inserts a newline. Tab changes the +same bar into Comment, Suggest when the anchor is a quote, and the layer's reaction +tokens. `.lf-response-control` keeps the field and every choice on one baseline with +one type, border, and elevation; the bar keeps its DOM owner and accessible name while +its contents change. Comment restores the field and Suggest restores it in +replacement-text mode. + +`showFab` places the bar; `openComposer` binds its field to the durable draft and focuses +it. `selectResponseTarget` does both for a keyboard item hint, and the ⌥ press uses +`focusTargetComment` on the same captured anchor. An already-open unsent draft re-anchors +directly so its standing words survive. Submitted words still in flight remain owned by +their original anchor, while a later target starts clean and keeps focus. `r` opens a +target-margin reaction row on the selection or standing item, or the conversation-owned +row on the latest agent message in the thread the reader is in. With none of those targets, it shows “Select something to react to” and opens nothing. Page-wide reactions remain an explicit ellipsis above the panel's general -comment box. `REACT` claims the keyboard while a list is open: arrows move among -tokens, Enter or Space presses the focused one, digits remain optional accelerators -in declaration order, and a stray key closes the list before keeping its ordinary -meaning. +comment box. `REACT` claims the keyboard while a list is open. Arrow keys wrap through +every choice in the row, including Comment and Suggest when the target offers them; +Tab and Shift-Tab follow that same order. +Enter or Space presses the focused choice, digits remain optional reaction accelerators +in declaration order, and a stray key closes the list before keeping its ordinary meaning. `conversation/model.js` reads the log by `isReaction`, `spoken`, `turns`, and `bareReaction`, the names `events.py` reads it by, and answers `reactionsOn` and @@ -1368,12 +1378,13 @@ host docks into flow. Visibility and vertical placement read `shownParts` and while its rendered descendants remain usable, and a collapsed target has no rendered part to offer. -The reaction key extends this same item for a page selection or item. It moves -Comment and the declared reaction buttons to the right of the existing marker; -it does not open a palette below the target. Conversation reactions remain in -their conversation-owned strip. The event still carries its durable authored -anchor, while the temporary item resolves selected text to the first rendered -block, matching the target where replay later seats its standing reaction. +The `r` key extends this same item for a page selection or item. It puts Comment, +Suggest where available, and the declared reaction buttons to the right of the +existing marker; it does not open a palette below the target. The compact response +bar's Tab state stays in that bar instead. Conversation reactions remain in their +conversation-owned strip. The event still carries its durable authored anchor, while +the temporary item resolves selected text to the first rendered block, matching the +target where replay later seats its standing reaction. ### Presentation and state motion @@ -1632,9 +1643,12 @@ so its ordinary Escape rung remains the route back. `s` names the visible items and declared visual parts that Alt-click can aim at. Both routes read `aimTargetAt`, and the target kind changes only the anchor: a whole item names its authored id, while a visual part adds its declared token. Their next surface -follows the gesture's stated intent. `s` selects the target and raises its Comment and -reaction bar; Alt-click promises Comment and opens the composer directly. The same -anchor resolves either surface against the target's geometry. +follows the gesture's stated intent. Both `s` and Alt-click select the target, open the +compact Comment field, and focus its cursor in the same transaction. Tab exchanges that +field for choices in the same bar and focuses Comment first. Tab, Shift-Tab, and the +arrow keys then wrap through every choice. Comment and Escape restore the field; Escape +from the field hides the draft. The same anchor resolves both states against the +target's geometry. The short, viewport-local hints form a prefix-free tree over one alphabet. Most targets cost one letter; only the tail branches when the viewport holds more targets than the @@ -2441,16 +2455,19 @@ reveal authored disclosures and tabs. `paintAnchors` marks a link detached when this version no longer has the id and refuses its press. A thread outlives its version, but a fragment target may not. -`wireInput` gives runtime textareas the same input contract: persist each edit, -send with `Mod+Enter`, keep the send button and placeholder current, and prevent -parallel sends of one local surface. The stylesheet owns textarea growth through -`field-sizing: content`. Script does not measure or set textarea height. - -The selection composer keeps its passage painted after focus moves into the -textarea. It quotes the passage in the box only when the current version can no -longer paint it. `showComposer` states the whole visible and focus outcome from -`composerOpen`, `pendingAnchor`, and `fabAnchor`. Outside clicks and Escape hide -without discarding words; Cancel explicitly discards. +`wireInput` gives runtime textareas one configurable input contract: persist each edit, +keep the send button and placeholder current, and prevent parallel sends of one local +surface. Ordinary boxes send with `Mod+Enter`; the compact anchored composer passes +`Enter`, leaving Shift-Enter to the textarea's native newline. The stylesheet owns +textarea growth through `field-sizing: content`. Script does not measure or set textarea +height. + +The selection composer keeps its passage painted after focus moves into the textarea. +Its `.lf-composer` wrapper contributes state and draft machinery through +`display: contents`; only `.lf-fab-input` draws. `showComposer` states the whole visible +and focus outcome from `composerOpen`, `pendingAnchor`, and `fabAnchor`. Outside clicks +and Escape hide without discarding words. A successful send or an explicit draft close +discards the local record. An accepted anchored comment opens its inline thread. When the reserved margin is too narrow, that thread may cover the page in its bounded card; it does not substitute the diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js index 2365b9ede..d42c1a9f1 100644 --- a/skills/leaf/assets/leaf.js +++ b/skills/leaf/assets/leaf.js @@ -281,7 +281,11 @@ import { quietSince, } from "./runtime/presence.js"; import { createPointer } from "./runtime/pointer.js"; -import { createReactions, paintReactionStanding } from "./runtime/reactions.js"; +import { + createReactions, + paintReactionStanding, + responseAction, +} from "./runtime/reactions.js"; import { createStateApplication } from "./runtime/state-application.js"; import { createStateFeed } from "./runtime/state-feed.js"; import { createUpdates } from "./runtime/updates.js"; @@ -915,18 +919,22 @@ const panelFoot = el("div", "lf-panel-foot"); panelFoot.append(generalRow); panel.append(panelHead, findRow, threadsBox, panelFoot); -// The floating Comment control names a selection before it has a stable target row. -// Pressing its ellipsis or `r` moves Comment into that target's shared margin item and -// adds the registry-declared reaction buttons to its right. One affordance, raised only -// where the reader has already pointed: a selection, a visual's click, an aimed item or -// visual part, or `r`. +// The floating field immediately accepts a comment on the target the reader named. +// Pressing Tab or its ellipsis exchanges its field for the other responses in place. +// One affordance, raised only where the reader has already pointed: +// a selection, a visual's click, an aimed item, or a visual part. const fabBar = el("div", "lf-ui lf-fab-bar"); fabBar.setAttribute("role", "group"); fabBar.setAttribute("aria-label", "Respond"); -const fab = marginAction(el("button", "lf-ui lf-pill lf-fab"), { +const fabInput = document.createElement("textarea"); +fabInput.className = "lf-ui lf-response-control lf-fab-input"; +fabInput.rows = 1; +fabInput.autocomplete = "off"; +fabInput.placeholder = "Comment…"; +fabInput.setAttribute("aria-label", "Comment"); +const fab = responseAction(el("button", "lf-ui lf-fab"), { glyph: "💬", label: "Comment", - collapse: "always", }); fab.setAttribute("aria-label", "Comment"); fab.title = "Comment"; @@ -949,17 +957,20 @@ const suggestRow = el("label", "lf-suggest-row"); const suggestCheck = document.createElement("input"); suggestCheck.type = "checkbox"; suggestRow.append(suggestCheck, document.createTextNode("Suggest replacement text")); -const composerInput = document.createElement("textarea"); +// The page-anchored composer is the extended Comment control itself. The hidden +// composer node keeps the draft's controls and quote description, while this textarea +// stays in the response bar and never jumps to a second box. +const composerInput = fabInput; // The mark is a paint, and a paint is nothing to a screen reader (see "Paint; don't wrap" // in CLAUDE.md). So what the box is anchored to travels as the box's own description, // announced on focus — which is more than the visible quote ever said, since nothing // pointed a reader at it. composerInput.setAttribute("aria-describedby", composerQuote.id); const composerRow = el("div", "lf-composer-row"); -const composerCancel = el("button", "lf-btn", "Cancel"); const composerSend = el("button", "lf-btn primary", "Comment"); -composerRow.append(composerCancel, composerSend); +composerRow.append(composerSend); composer.append(composerQuote, suggestRow, composerInput, composerRow); +fabBar.prepend(composer); const toastEl = el("div", "lf-ui lf-toast"); const liveEl = el("div", "lf-ui lf-live"); liveEl.setAttribute("aria-live", "polite"); @@ -1060,7 +1071,6 @@ chromeRoot.append( selectionSearch, aimBox, fabBar, - composer, toastEl, liveEl, helpEl, @@ -1130,8 +1140,6 @@ chromeLayout = createChromeLayout({ chromeRoot, closeReactions: () => setReact(false), commentsEdge, - composer, - composerIsOpen: () => composerOpen, containsAcross: (...args) => containsAcross(...args), currentTray, dockSeats: () => anchorRuntime?.dockSeats(), @@ -1145,7 +1153,6 @@ chromeLayout = createChromeLayout({ }, panelFoot, panelList: threadsBox, - placeComposer: (...args) => placeComposer(...args), readerStore, refreshFab: (...args) => refreshFab(...args), refreshHover: (...args) => refreshHover(...args), @@ -1197,23 +1204,24 @@ const { beside, dismissFab, fabAnchorAt, + fabOptionsAvailable, fabTargetAt, fabReturnTo, + focusFabComment, + focusTargetComment, openOnItem, - openTargetComposer, placeClear, - placeComposer, refreshFab, selectResponseTarget, showFab, + showFabOptions, standDown, updateFab, } = createSelectionSurface({ anchoringIsReady: () => anchoringReady, anchorLabel: (...args) => anchorLabel(...args), + banner, blockAt: (...args) => blockAt(...args), - composer, - composerInput, composerIsOpen: () => composerOpen, closeVersionMenu, collapseKeyline: () => keyline?.less(), @@ -1221,8 +1229,11 @@ const { designTarget, fab, fabBar, + fabInput, hideComposer: () => hideComposer(), hideReference: () => reference.show(false, false), + hasOtherResponses: (anchor) => + reactionTokens().length > 0 || Boolean(anchor?.quote && !designOn), inChrome: (node) => inChrome(node), isReactArmed: () => isReactArmed(), keylineEl, @@ -1240,7 +1251,6 @@ const { paintStanding: paintReactionStanding, panel, panelCovers, - pendingMarkParts, pointerAt, reactionsOn: (anchor) => conversationRuntime.reactionsOn(anchor), referenceIsOpen: () => reference.open, @@ -1263,7 +1273,7 @@ const { AIM, aimIsOn, aimedItem } = createAim({ designPress, designTarget, inChrome: (node) => inChrome(node), - openTargetComposer, + focusTargetComment, openOnDesign, pointerAt, refreshAim, @@ -1296,8 +1306,8 @@ function openOnDesign(...args) { selectionComposerRuntime = createSelectionComposer(runtime, { clearDraft, + closeReactions: () => setReact(false), composer, - composerCancel, composerInput, composerSend, designIsOn: () => designOn, @@ -1310,7 +1320,6 @@ selectionComposerRuntime = createSelectionComposer(runtime, { openInlineThread: (...args) => livingMargin?.openInlineThread(...args) ?? null, paintAnchors, paintHere, - placeComposer, post, saveDraft, sendDraft, @@ -1569,7 +1578,7 @@ const commentDestination = () => { ...commenting( anchor.quote ? "selection" : itemWord(elementById(anchor.section)) || "item", ), - go: () => fab.click(), + go: focusFabComment, }; const said = standingConversation(); if (said) return { ...commenting("thread"), go: () => landIn(said) }; @@ -1606,7 +1615,7 @@ const hasCapturedTarget = () => Boolean(fabAnchorAt()); // raised the 💬 on something has said what they mean more recently than the focus they left // behind, which is the order decisionPosition reads its own answers in. function commentKey() { - updateFab(); // the selection may be newer than the mouseup that last placed the button + updateFab(); // the selection may be newer than the mouseup that last placed the bar commentDestination().go(); } @@ -1986,6 +1995,7 @@ const { fabReturnTo, fabBar, focused, + hideComposer: () => hideComposer(), itemWord, offer, paintHere, @@ -1996,6 +2006,7 @@ const { showToast, standingConversation, standingItem, + suggestHere: () => selectionComposerRuntime.setSuggestionMode(true), undoable: (...args) => undoable(...args), visualPartLabel: (...args) => visualPartLabel(...args), withdraw: (...args) => withdraw(...args), @@ -2082,11 +2093,20 @@ const COMPOSER = { title: "In the composer", at: () => composerOpen, rows: [ + { + id: "comment.options", + keys: ["Tab"], + does: "Show other responses", + line: "other responses", + when: fabOptionsAvailable, + run: showFabOptions, + }, { id: "composer.close", keys: ["Escape"], does: "Close the composer, keeping the draft", line: "close — draft kept", + promoteEscape: false, run: () => { hideComposer(); showFab(null); @@ -3108,9 +3128,6 @@ function isMarked(...args) { function placedAt(...args) { return anchorRuntime.placedAt(...args); } -function pendingMarkParts(...args) { - return anchorRuntime.pendingMarkParts(...args); -} const passageRuntime = createPassages({ PAGE_PAINT_ATTRIBUTE, diff --git a/skills/leaf/assets/runtime/anchors.js b/skills/leaf/assets/runtime/anchors.js index 528f2f27f..7da6bde2e 100644 --- a/skills/leaf/assets/runtime/anchors.js +++ b/skills/leaf/assets/runtime/anchors.js @@ -748,9 +748,9 @@ export function createAnchors(dependencies) { composerIsOpen() && composerAnchor() ? resolveAnchor(composerAnchor(), text) : null; - // Where the draft's passage is, recorded the way the threads' is, because placeComposer - // has to keep the box off it. An element a thread already outlines belongs - // in the record too — it is marked, just in the posted colour rather than the accent. + // Where the draft's passage is, recorded the way the threads' is. An element a thread + // already outlines belongs in the record too — it is marked, just in the posted colour + // rather than the accent. pendingMarks = draft ? draft.element ? (draft.marks ?? shownParts(draft.element)) @@ -760,8 +760,8 @@ export function createAnchors(dependencies) { if (draft?.element) { // Part by part, because a thread's outline is claimed the same way: the draft takes // whichever boxes are still free and leaves the rest in the posted colour. The record - // above is the parts too, so placeComposer stands the box off the passage the reader - // can see rather than off a wrapper whose rect sits at the top of the document. + // above records the same shown parts rather than a wrapper whose rect may sit at the + // top of the document. const taken = allMarks(); for (const part of pendingMarks) if (!taken.includes(part)) { @@ -771,7 +771,7 @@ export function createAnchors(dependencies) { } if (draft?.segments) pending.push(...pendingMarks); - const active = actionAnchor(); + const active = composerIsOpen() ? null : actionAnchor(); const action = active && !active.quote ? resolveAnchor(active, text) : null; actionOutline = action?.element ? (action.marks ?? shownParts(action.element)) : []; for (const part of actionOutline) part.classList.add("lf-action-target"); @@ -1304,7 +1304,6 @@ export function createAnchors(dependencies) { NOTE, isMarked: (id) => marked.has(id), placedAt: (id) => placed.get(id), - pendingMarkParts: () => [...pendingMarks], refreshAim, dockSeats, paintAnchors, diff --git a/skills/leaf/assets/runtime/chrome-layout.js b/skills/leaf/assets/runtime/chrome-layout.js index 9711508f0..399084383 100644 --- a/skills/leaf/assets/runtime/chrome-layout.js +++ b/skills/leaf/assets/runtime/chrome-layout.js @@ -40,8 +40,6 @@ export const PANEL_KEY = "lf-panel-open"; export function createChromeLayout({ chromeRoot, commentsEdge, - composer, - composerIsOpen, closeReactions, containsAcross, currentTray, @@ -54,7 +52,6 @@ export function createChromeLayout({ panelChanged, panelFoot, panelList, - placeComposer, readerStore, refreshFab, refreshHover, @@ -182,20 +179,11 @@ export function createChromeLayout({ syncFloats(); dockSeats(); } - // The floats live in the document, and syncLayout is where its box changes shape — the - // panel takes or returns its strip, a resize moves every rect, the composer's own - // textarea grows under typing — so whatever float is up is placed again against the - // new geometry: the composer from its own marks (a detached one re-clamps where it - // stands), the button from the live selection where one still stands, and by - // re-clamping alone where none does. Skipping this leaves a float placed at a wide - // window's edge overhanging the box a panel then narrows, and an absolute child past - // body's client box is sideways-scrollable overflow: the document panned 328px left - // under a trackpad, with the composer standing on the panel that had displaced it. + // The response bar lives in the document, and syncLayout is where its containing box + // changes shape — the panel takes or returns its strip and a resize moves every rect. + // Re-place it against the durable anchor so it cannot overhang the narrowed shell and + // create sideways-scrollable overflow. function syncFloats() { - if (composerIsOpen()) { - const box = composer.getBoundingClientRect(); - placeComposer(box.left, box.top); - } if (syncReactLayout()) return; refreshFab(); } @@ -284,9 +272,6 @@ export function createChromeLayout({ layoutSizes.observe(document.body); layoutSizes.observe(panelFoot); layoutSizes.observe(keylineEl); - // The composer grows under typing (field-sizing), and a box placed above its passage - // grows downward, back over the mark it was moved off — so its own resize re-places it. - layoutSizes.observe(composer); return { inPanel, panelCovers, panelIsOpen, setPanel, syncLayout }; } diff --git a/skills/leaf/assets/runtime/chrome-style.js b/skills/leaf/assets/runtime/chrome-style.js index e66fda6b7..a5dd2979e 100644 --- a/skills/leaf/assets/runtime/chrome-style.js +++ b/skills/leaf/assets/runtime/chrome-style.js @@ -243,6 +243,7 @@ export function chromeStyle({ font: 600 var(--t-6)/1 var(--sans); white-space: nowrap; scroll-margin-block: var(--here-ring-room); } + .lf-margin-action[hidden] { display: none; } /* The look is the control's and the hand is the press's, which is one rule apart — the same split the pill above states, and for the same reason. A copy keeps the shape and loses the gesture: BAKE takes the role and the tab stop off a standing @@ -330,7 +331,9 @@ export function chromeStyle({ inside @scope. */ .lf-address { ${KEY_BOX} display: none; border-color: var(--accent); background: var(--card); color: var(--accent); z-index: 1; } - /* The leaf text box, in one rule. field-sizing does the growing, so no script + /* The general leaf text box, in one rule. The compact response field is excluded + because it shares its complete control geometry with the response buttons below. + field-sizing does the growing, so no script measures a textarea: the JS that did had to reset height to auto to re-measure, which made the box briefly too small for its own text on every keystroke — and a box that overflows, however briefly, flashes a scrollbar. Past max-height the @@ -338,8 +341,14 @@ export function chromeStyle({ of lines: 200px stopped a long comment at ten lines with the screen mostly empty. Both selectors: the panel's boxes sit inside .lf-ui, a widget's own box wears the class itself. */ - .lf-ui textarea, textarea.lf-ui { padding: 8px 10px; border: 1px solid var(--border-2); border-radius: 6px; background: var(--card); color: inherit; resize: none; field-sizing: content; max-height: 50vh; overflow-y: auto; } - .lf-ui textarea:is(:focus, .lf-focus), textarea.lf-ui:is(:focus, .lf-focus) { outline: none; border-color: color-mix(in srgb, var(--accent) 45%, var(--card)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } + .lf-ui textarea:not(.lf-fab-input), + textarea.lf-ui:not(.lf-fab-input) { padding: 8px 10px; border: 1px solid var(--border-2); + border-radius: 6px; background: var(--card); color: inherit; resize: none; + field-sizing: content; max-height: 50vh; overflow-y: auto; } + .lf-ui textarea:not(.lf-fab-input):is(:focus, .lf-focus), + textarea.lf-ui:not(.lf-fab-input):is(:focus, .lf-focus) { outline: none; + border-color: color-mix(in srgb, var(--accent) 45%, var(--card)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } ${MARK_RULES} body.lf-over-mark { cursor: pointer; } /* Holding ⌥ changes what a click means, and nothing on the page said so — the chord's @@ -1041,10 +1050,42 @@ ${MARK_RULES} .lf-fab-bar { position: absolute; z-index: 8950; display: none; align-items: center; gap: 4px; white-space: nowrap; } .lf-fab-bar[data-lf-margin-raised] { display: none !important; } - /* The comment glyph and ellipsis are the bar's two stable presses. Both carry the - shadow the floating surface earns. The ellipsis gives its place to the reaction - buttons without moving Comment. */ - .lf-fab-bar > .lf-pill { box-shadow: 0 2px 6px rgba(0,0,0,.14); } + /* The field and the choices are two states of one anchored response control. This + primitive owns their height, vertical padding, type, border, and elevation; Tab + changes only the contents of the bar. */ + .lf-response-control { --lf-response-height: 32px; + box-sizing: border-box; min-height: var(--lf-response-height); + border: 1px solid var(--border-2); border-radius: 999px; background: var(--card); + color: var(--ink-2); font: 400 var(--t-6)/1.4 var(--sans); + padding-block: calc((var(--lf-response-height) - 1lh - 2px) / 2); + box-shadow: 0 2px 6px rgba(0,0,0,.14); } + .lf-response-control:is(:focus, :focus-visible, .lf-focus, .lf-focus-visible) { + outline: none; + border-color: color-mix(in srgb, var(--accent) 45%, var(--card)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent), + 0 2px 6px rgba(0,0,0,.14); } + textarea.lf-fab-input { width: 216px; min-width: 0; + max-height: min(132px, 30vh); border-radius: 999px; + padding-inline: 12px; + resize: none; + field-sizing: content; overflow-y: auto; } + textarea.lf-fab-input::placeholder { color: var(--muted); opacity: 1; } + .lf-response-action { min-width: 30px; padding-inline: 9px; + display: inline-flex; align-items: center; justify-content: center; gap: 0; + white-space: nowrap; cursor: pointer; } + .lf-response-action[hidden] { display: none; } + .lf-response-action:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--card)); + background: var(--card); } + .lf-response-action-glyph { line-height: 1; } + .lf-response-action-space { white-space: pre; } + .lf-response-action[data-lf-collapse] > :is(.lf-response-action-space, + .lf-response-action-label) { display: none; } + .lf-fab-bar:not(.lf-react-open) > :is(.lf-fab, .lf-fab-suggest, + .lf-react-palette) { display: none !important; } + .lf-fab-bar.lf-react-open { max-width: calc(100vw - 16px); flex-wrap: wrap; } + .lf-fab-bar.lf-react-open > .lf-composer { display: none !important; } + .lf-fab-bar.lf-react-open > .lf-react-palette { display: contents; } /* A reaction surface offers one quiet ellipsis. It disappears when its list opens; a standing token remains visible in a closed message or page strip as the reader's receipt and eraser. */ @@ -1065,6 +1106,7 @@ ${MARK_RULES} .lf-react { display: inline-flex; align-items: center; gap: 4px; min-width: 26px; justify-content: center; } .lf-react > .lf-react-word { display: none; } + .lf-fab-bar.lf-react-open .lf-react-word { display: inline; } .lf-react[aria-pressed="true"] > .lf-react-word { display: inline; } .lf-react[aria-pressed="true"] { border-color: var(--mark-ink); color: var(--mark-ink); background: var(--mark); } @@ -1084,6 +1126,8 @@ ${MARK_RULES} .lf-react-palette > .lf-react:not([aria-pressed="true"]):is(:hover, :focus-visible, .lf-focus-visible, [aria-busy="true"]), .lf-react-open .lf-react-palette > .lf-react:not([aria-pressed="true"]) { border-color: var(--border-2); background: var(--chip); color: var(--ink-2); } + .lf-fab-bar .lf-react-palette > .lf-response-action:not([aria-pressed="true"]) { + background: var(--card); } .lf-react-palette > .lf-react[aria-pressed="true"]:hover { background: var(--mark); } /* A thread whose root is a mark: the glyph and its word where the comment's words would stand, in the chrome's face. */ @@ -1110,20 +1154,13 @@ ${MARK_RULES} .lf-aim { position: absolute; z-index: 8920; display: none; pointer-events: none; border: 2px solid var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); } - .lf-composer { position: absolute; z-index: 8950; display: none; width: 320px; background: var(--card); - border: 1px solid var(--border-2); border-radius: var(--r); box-shadow: 0 8px 24px rgba(0,0,0,.12); padding: 10px; } - /* A stranded quote is the whole passage, and the box is 320px wide. Only while showing: - on the hidden one this would out-specify .lf-unseen's own overflow. */ - .lf-composer .lf-quote:not(.lf-unseen) { max-height: 4.2em; overflow-y: auto; } - .lf-suggest-row { display: none; align-items: center; gap: 6px; margin: 0 0 6px; color: var(--muted); font-size: var(--t-6); cursor: pointer; } - .lf-suggest-row input { margin: 0; accent-color: var(--accent); } + .lf-composer { display: none; } + .lf-fab-bar .lf-composer > :not(.lf-fab-input) { display: none !important; } .lf-suggest-label { font-size: var(--t-6); letter-spacing: .05em; text-transform: uppercase; color: var(--ok-ink); margin: 4px 0 2px; } /* A suggestion renders verbatim — its characters are what the next version carries (see msgNode) — so this is where they keep their own line breaks. */ .lf-msg-body.lf-suggest-body { background: var(--add-tint); padding: 4px 8px; border-radius: 6px; white-space: pre-wrap; } - .lf-composer textarea { width: 100%; min-height: 56px; } - .lf-composer-row { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; } .lf-toast { position: fixed; bottom: calc(18px + var(--lf-safe-bottom)); right: calc(18px + var(--lf-safe-right)); z-index: 9200; max-width: calc(100vw - 36px - var(--lf-safe-left) - var(--lf-safe-right)); @@ -1415,6 +1452,7 @@ ${MARK_RULES} .lf-btn, .lf-pill:is(button, [role="button"]), .lf-margin-action { min-height: 44px; } + .lf-response-control { --lf-response-height: 44px; } .lf-banner-actions > .lf-btn { min-height: 44px; } .lf-panel-head .lf-btn { min-width: 44px; } .lf-pill:is(button, [role="button"]) { display: inline-flex; align-items: center; } diff --git a/skills/leaf/assets/runtime/composing/aim.js b/skills/leaf/assets/runtime/composing/aim.js index dfce0d32c..4eb397311 100644 --- a/skills/leaf/assets/runtime/composing/aim.js +++ b/skills/leaf/assets/runtime/composing/aim.js @@ -1,5 +1,5 @@ export function createAim({ - openTargetComposer, + focusTargetComment, aimTargetAt, designIsOn, designPress, @@ -149,7 +149,7 @@ export function createAim({ ev.stopPropagation(); if (ev.type !== "click") return; if (claimedPress.aim) - openTargetComposer(claimedPress.aim, { + focusTargetComment(claimedPress.aim, { left: ev.clientX + 6, top: ev.clientY - 40, }); diff --git a/skills/leaf/assets/runtime/composing/input.js b/skills/leaf/assets/runtime/composing/input.js index b08f9bf55..439674ff7 100644 --- a/skills/leaf/assets/runtime/composing/input.js +++ b/skills/leaf/assets/runtime/composing/input.js @@ -1,7 +1,7 @@ -// One helper wires every composer: the general box, each per-thread reply, and the -// selection composer. They persist a draft on each keystroke, send on ⌘/Ctrl+Enter, and -// can't be double-sent by an impatient second click. Growing with their content is the -// stylesheet's job (field-sizing), not this file's. +// One helper wires every durable text surface: the general box, each per-thread reply, +// and the compact anchored composer. They persist a draft on each keystroke, send on the +// binding their caller supplies, and can't be double-sent by an impatient second click. +// Growing with their content is the stylesheet's job (field-sizing), not this file's. // wire() returns a sync() the caller runs after setting .value programmatically, so the // send button agrees with what's in the box. export function createInput({ focused, keys, showToast, spell }) { @@ -9,7 +9,6 @@ export function createInput({ focused, keys, showToast, spell }) { // tooltip and the row a box declares all read one string, where the constant they used to // share sat beside a listener that bound the chord independently. const SEND = "Mod+Enter"; - const SEND_KEYS = spell(SEND); // Focus-derived hints join the runtime's one standing paint. Only the input losing the // standing and the one gaining it can change for that reason. const inputPaints = new WeakMap(); @@ -36,6 +35,7 @@ export function createInput({ focused, keys, showToast, spell }) { altBtn = null, altSend = null, busy = () => false, + sendKey = SEND, }, ) { // The hint goes in the placeholder, where it's visible exactly while the box is @@ -45,10 +45,11 @@ export function createInput({ focused, keys, showToast, spell }) { // Both hint and address may be functions because their labels can change while the // box stands. const label = () => (typeof hint === "function" ? hint() : hint); + const sendKeys = spell(sendKey); const paint = () => { // Read the shared logical focus so this hint agrees with the key line and rings. const standing = focused() === ta; - const suffix = standing ? SEND_KEYS : address?.(); + const suffix = standing ? sendKeys : address?.(); const placeholder = suffix ? `${label()} · ${suffix}` : label(); if (ta.placeholder !== placeholder) ta.placeholder = placeholder; }; @@ -57,7 +58,7 @@ export function createInput({ focused, keys, showToast, spell }) { if (focused() === ta) paintInputs(); else paint(); }; - sendBtn.title = `Send (${SEND_KEYS})`; + sendBtn.title = `Send (${sendKeys})`; if (altBtn) altBtn.title = altBtn.textContent; let sending = false; // Keep a disabled send reachable so the reader can discover why it will not send; @@ -104,7 +105,7 @@ export function createInput({ focused, keys, showToast, spell }) { keys(ta, "In a text box", [ { id: "text.send", - keys: [SEND], + keys: [sendKey], does: "Send what you have typed", line: sends, run: () => sendBtn.click(), diff --git a/skills/leaf/assets/runtime/composing/selection.js b/skills/leaf/assets/runtime/composing/selection.js index 6b46ca42f..724423c34 100644 --- a/skills/leaf/assets/runtime/composing/selection.js +++ b/skills/leaf/assets/runtime/composing/selection.js @@ -5,8 +5,8 @@ export let composerOpen = false; export function createSelectionComposer(runtime, dependencies) { const { clearDraft, + closeReactions, composer, - composerCancel, composerInput, composerSend, designIsOn, @@ -19,7 +19,6 @@ export function createSelectionComposer(runtime, dependencies) { openInlineThread, paintAnchors, paintHere, - placeComposer, post, saveDraft, sendDraft, @@ -81,42 +80,52 @@ export function createSelectionComposer(runtime, dependencies) { } return best; } + let inFlight = null; + let composerEpoch = 0; const syncComposer = wireInput(composerInput, { hint: () => suggestCheck.checked ? "Replacement text" : pendingAbout ? "About the layer" - : "Your comment", + : "Comment…", sends: () => (suggestCheck.checked ? "suggest" : "comment"), sendBtn: composerSend, + sendKey: "Enter", save: saveComposerDraft, send: async (text, raw) => { const anchor = structuredClone(pendingAnchor); const ctx = composerCtx(anchor); const suggestion = suggestCheck.checked; const about = pendingAbout; - const sent = await sendDraft( - ctx, - () => composerCtx(pendingAnchor) === ctx && composerInput.value === raw, - (attempt) => { - const event = { - kind: "comment", - revision: runtime.currentRevision, - anchor, - text, - attempt, - }; - if (suggestion) event.suggestion = true; - if (about) event.about = about; - return post(event); - }, - ); + const flight = { ctx, raw, epoch: composerEpoch }; + inFlight = flight; + let sent; + try { + sent = await sendDraft( + ctx, + () => composerCtx(pendingAnchor) === ctx && composerInput.value === raw, + (attempt) => { + const event = { + kind: "comment", + revision: runtime.currentRevision, + anchor, + text, + attempt, + }; + if (suggestion) event.suggestion = true; + if (about) event.about = about; + return post(event); + }, + ); + } finally { + if (inFlight === flight) inFlight = null; + } if (!sent) return; // A later edit is still the reader's standing gesture. The earlier comment may // render in another conversation view, but it may not close or move the composer // holding that edit. - if (loadDraft(ctx) !== null) return; + if (composerEpoch !== flight.epoch || loadDraft(ctx) !== null) return; let reply = threadsBox.querySelector(`.lf-thread[data-id="${sent.id}"] textarea`); const shouldLand = mayLandTyping(reply, composerInput); // Opening an inline view closes the panel and moves focus. Decide from the standing @@ -147,7 +156,8 @@ export function createSelectionComposer(runtime, dependencies) { syncComposer(); paintHere(); // the line's send row says which of the two the box will do } - suggestCheck.onchange = () => { + function setSuggestionMode(suggest) { + suggestCheck.checked = Boolean(suggest); // Entering suggestion mode seeds the box with the passage to edit in place. if (suggestCheck.checked && !composerInput.value.trim() && pendingAnchor?.quote) { composerInput.value = seededQuote = pendingAnchor.quote; @@ -155,7 +165,9 @@ export function createSelectionComposer(runtime, dependencies) { } syncSuggestMode(); saveComposerDraft(); - }; + composerInput.focus({ preventScroll: true }); + } + suggestCheck.onchange = () => setSuggestionMode(suggestCheck.checked); // Whether the composer is up, and the only thing that decides it. The stylesheet renders // this state; nothing reads it back, because the rendering has a third value the state @@ -164,7 +176,10 @@ export function createSelectionComposer(runtime, dependencies) { // click. Painting hangs off the same call, so the mark and the box are up together. function showComposer(open) { composerOpen = open; - composer.style.display = open ? "block" : "none"; + // The wrapper contributes no card or box. Its textarea is the extended Comment + // control inside the response bar; the other composer controls stay hidden there. + composer.style.display = open ? "contents" : "none"; + composer.toggleAttribute("data-lf-open", open); // The reader's own selection is gone by now — focusing a textarea drops it — so this // mark is the only thing left pointing at the passage being quoted. paintAnchors(); @@ -185,9 +200,21 @@ export function createSelectionComposer(runtime, dependencies) { suggest = false, about = designIsOn() ? "layer" : null, ) { + closeReactions(); if (composerInput.value === seededQuote) composerInput.value = ""; seededQuote = ""; const ctx = composerCtx(anchor || null); + const previousCtx = composerCtx(pendingAnchor); + if (previousCtx !== ctx) composerEpoch += 1; + // Re-anchoring while this exact value is being sent is a new response gesture, not + // an edit of the submitted words on a different passage. Leave the sending draft at + // its original coordinate so a failed request can recover it, and start the new + // field clean. The eventual success settles that original generation in place. + const leavesFlight = + previousCtx !== ctx && + inFlight?.ctx === previousCtx && + composerInput.value === inFlight.raw; + if (leavesFlight) composerInput.value = ""; // A draft already standing on this passage is what the box opens with — one left hidden // here, or one being typed in another tab — unless the caller brought words of its own // or the box is already carrying some. @@ -197,18 +224,15 @@ export function createSelectionComposer(runtime, dependencies) { // on lets go of them as they arrive on the next one. A press that re-anchors an open // draft is where this lands, and a key left standing there would hand the same words // back on the old passage at the next load. - if (composerCtx(pendingAnchor) !== ctx) clearDraft(composerCtx(pendingAnchor)); + if (previousCtx !== ctx && !leavesFlight) clearDraft(previousCtx); pendingAnchor = anchor || null; pendingAbout = about; composerInput.value = text || composerInput.value; suggestCheck.checked = Boolean(suggest); syncSuggestMode(); - // before placing: a hidden box has no height to fit, and the pass inside this call is - // both what decides whether the quote takes up some of that height and what records - // where the passage is that the box has to stay off. showComposer(true); + showFab(anchor); syncComposer(); - placeComposer(left, top); composerInput.focus(); watchComposer(); // The store hears about the anchor now, not at the next keystroke: saving only on @@ -254,22 +278,16 @@ export function createSelectionComposer(runtime, dependencies) { pendingAbout = null; syncSuggestMode(); // after the state it renders, which is now all of it hideComposer(); + showFab(null, null, { returnFocus: "none" }); } - // The button opens the composer where it stands, on the anchor it is carrying. Where it - // stands, and not where it was asked for: placement moves it — down past the controls it - // would cover, and off the viewport's edges — so the two are no longer the same point, - // and handing on the asked-for one put the composer straight back over the row the button - // had just stepped off. + // The response bar's Comment action returns to this same compact field on the anchor + // the bar is carrying. It remains a button only while the choices are visible. fab.onclick = () => { if (!fabAnchor()) return; const anchor = fabAnchor(); const { left, top } = fab.getBoundingClientRect(); - showFab(null, null, { returnFocus: "none" }); openComposer(anchor, "", left, top); }; - // Cancel discards. Escape and outside clicks only hide, keeping the draft either way. - composerCancel.onclick = closeComposer; - - return { hideComposer, openComposer, pendingComposer }; + return { hideComposer, openComposer, pendingComposer, setSuggestionMode }; } diff --git a/skills/leaf/assets/runtime/composing/surface.js b/skills/leaf/assets/runtime/composing/surface.js index c3ac2aa36..749c47a6f 100644 --- a/skills/leaf/assets/runtime/composing/surface.js +++ b/skills/leaf/assets/runtime/composing/surface.js @@ -4,9 +4,8 @@ import { documentPoint } from "../geometry.js"; export function createSelectionSurface({ anchoringIsReady, anchorLabel, + banner, blockAt, - composer, - composerInput, composerIsOpen, closeVersionMenu, collapseKeyline, @@ -14,8 +13,10 @@ export function createSelectionSurface({ designTarget, fab, fabBar, + fabInput, hideComposer, hideReference, + hasOtherResponses, inChrome, isReactArmed, keylineEl, @@ -33,7 +34,6 @@ export function createSelectionSurface({ panel, panelCovers, paintStanding, - pendingMarkParts, pointerAt, reactionsOn, referenceIsOpen, @@ -57,13 +57,14 @@ export function createSelectionSurface({ const rightEdge = () => (panelCovers() ? innerWidth - panel.offsetWidth - : document.body.getBoundingClientRect().right) - 8; + : Math.min(innerWidth, document.body.getBoundingClientRect().right)) - 8; // The floats live in the document — they scroll with the passage they stand beside — // while every caller reasons in viewport terms: rects, the pointer, the banner's own - // band. Named, because four sites had the number written out and it is neither of the - // two it stands near — the banner is 42px and the scroller's scroll-padding-top 54px, - // this being the slack over the first that says what the reader can actually see. + // band. The fixed floor covers the ordinary one-line banner; its live box takes over + // when compact chrome wraps to a second line. const BANNER_CLEAR = 48; + const topEdge = () => + Math.max(BANNER_CLEAR, banner.getBoundingClientRect().bottom + 6); // So the one writer of their position is where the coordinates change space: clamp in // the viewport and above any key line it would cross, then store in the document. function place(node, left, top) { @@ -74,92 +75,11 @@ export function createSelectionSurface({ const bottom = overlapsKeyline ? keyline.top - 8 : innerHeight - 8; const at = documentPoint( x, - Math.max(BANNER_CLEAR, Math.min(top, bottom - node.offsetHeight)), + Math.max(topEdge(), Math.min(top, bottom - node.offsetHeight)), ); node.style.left = at.left + "px"; node.style.top = at.top + "px"; } - // The composer's first choice of a place is the column's margin, beside the passage, so - // the mark and the box stand side by side — where the box opened instead at the gesture - // (the fab, the ⌥-click's pointer), it stood on the page's own text next to the - // passage, which is the one thing a 320px card over a 720px column can't avoid doing - // there. placeClear steps it down past any control the page hangs out in that same - // margin (a suggestion's Accept/Reject row). - // - // A sidenote is out there too and the box covers one whole while it stands, which is - // where this stops short of stepping clear. What the walk steps past is controls, - // because a control the box hides is a press the reader was reaching for; a note is - // prose they are not mid-gesture on, and the box goes when they are done with it. The - // walk could be taught the note as easily — the cost is where it would then put the box - // on a page carrying a run of them, which is far enough down the margin to be about a - // different paragraph. - // - // Where the margin is too narrow for the box — a laptop window, the panel open — it - // has one thing left to stay clear of: its own mark. That mark is the only thing - // naming the passage the box is about, so a box standing on all of it is a box about - // nothing. Not "no overlap" — the box has always covered the tail of a long passage - // and that reads fine — but every rect hidden is the case to move for, and it is a - // case that happens: a restored draft reappears near the top of the viewport, and the - // reading position puts the passage it was made on back in the same place. - // Below the passage where the viewport has room, above it otherwise; place()'s own - // clamp has the last word, so a passage too tall for either side simply keeps the - // better spot. - function placeComposer(left, top) { - place(composer, left, top); - const marks = pendingMarkParts(); - const rects = marks.flatMap((where) => - where instanceof Range - ? [...where.getClientRects()] - : [where.getBoundingClientRect()], - ); - const box = composer.getBoundingClientRect(); - const column = document.querySelector("main")?.getBoundingClientRect(); - if (rects.length && column && column.right + 8 + box.width <= rightEdge()) - return placeClear( - composer, - column.right + 8, - Math.min(...rects.map((r) => r.top)), - ); - // Vertically only: the document never scrolls sideways and body's margin keeps it clear - // of the panel, so off-screen means scrolled past, and a mark scrolled past is not one - // this box is standing on. - const onScreen = (r) => r.bottom > BANNER_CLEAR && r.top < innerHeight; - const behindBox = (r) => - r.left >= box.left && - r.right <= box.right && - r.top >= box.top && - r.bottom <= box.bottom; - // A passage and a thing want different rules here, because - // they are read differently. Covering the tail of a quote is fine — the user has read - // it, and the mark still names where it starts. A card, a column, a metric is judged as - // one object, so a box standing anywhere on it is a box between them and the thing they - // are writing about. An aimed Comment makes that plain: its target is the whole object, - // so the composer must clear the object rather than cover the point beside which its - // action bar stood. - const whole = marks.some((where) => where instanceof Element); - const touching = (r) => - r.left < box.right && - box.left < r.right && - r.top < box.bottom && - box.top < r.bottom; - const clear = whole - ? !rects.some((r) => onScreen(r) && touching(r)) - : rects.some((r) => onScreen(r) && !behindBox(r)); - if (!rects.length || clear) return; - const below = Math.max(...rects.map((r) => r.bottom)) + 8; - const above = Math.min(...rects.map((r) => r.top)) - box.height - 8; - if (below + box.height <= innerHeight - 8) return place(composer, left, below); - if (above >= BANNER_CLEAR) return place(composer, left, above); - // Neither end has room, which a tall thing reaches easily: a board column is most of the - // viewport before the box's own height is counted, and place()'s clamp would haul the box - // back over it — the very thing this is here to stop. So go beside instead, even where - // the margin is narrower than the box wants; the side is chosen rather than clamped, - // because the clamp keeps a box on screen by sliding it left, back over the thing it - // is avoiding. - const rightOf = Math.max(...rects.map((r) => r.right)) + 8; - const leftOf = Math.min(...rects.map((r) => r.left)) - box.width - 8; - place(composer, rightOf + box.width <= rightEdge() ? rightOf : leftOf, top); - } // Controls the page is standing on its own account, as against the ones in the runtime's // layer: a reply's widget is markup frozen in the log, and the layer's own buttons are // what floating chrome is allowed to sit beside. `data-lf-offer` is what makes a thing @@ -176,18 +96,14 @@ export function createSelectionSurface({ !inChrome(control) && !control.matches(".lf-visual-actions, .lf-visual-action"), ); - // The 💬 button carries the anchor it would open a composer on, so raising it and acting - // on it can't come to different conclusions about what the reader picked. Visibility is + // The response bar carries the anchor its field will submit on, so targeting and typing + // cannot come to different conclusions about what the reader picked. Visibility is // derived from that anchor and never read back off the stylesheet. const beside = (rect) => [rect.right + 6, rect.top - 6]; - // A float has one more thing to stay clear of, and it is the same kind of thing the - // composer's mark is: a control standing on the page. The floats float and they don't. - // A selection runs to the column's right edge on any line it fills, so `beside` puts - // the button in the margin — which is where a suggestion hangs the row deciding the - // change that selection just covered. The user's own gesture then hid the Accept - // they were reaching for, and the press that would have dismissed the button was the - // press it was covering. The composer's margin placement stands in the same column of - // rows, so it takes the same walk. + // A float has one more thing to stay clear of: a control standing on the page. The + // response bar floats and those controls do not. A selection runs to the column's + // right edge on any line it fills, so `beside` puts the field in the margin — which is + // also where a suggestion can hang the row deciding the change that selection covered. // // Down, and past each in turn, because the margin runs down the page: clearing one row // can land on the next, and walking a sorted list is the step the rows themselves take to @@ -225,9 +141,23 @@ export function createSelectionSurface({ if (anchor?.quote) { const selection = pageSelection(); const current = selection ? selectionAnchor(selection) : null; - return current && sameAnchor(anchor, current) - ? pageRange(selection).getBoundingClientRect() - : null; + if (current && sameAnchor(anchor, current)) + return pageRange(selection).getBoundingClientRect(); + // Entering the compact field deliberately collapses the browser selection after + // its durable passage has been captured. Resolve that passage again so layout can + // keep the field beside it; an ordinary selection collapse still returns null and + // lets updateFab dismiss the response surface. + // The reactions palette temporarily owns focus and may itself be re-seated during + // a responsive layout change. The open composer is the durable proof that this + // captured passage still belongs to the response transaction; native selection is + // no longer available once the textarea took focus. + if (!composerIsOpen() && !fabHoldsCapturedPassage()) return null; + const found = resolveAnchor(anchor, pageText()); + if (!found?.segments?.length) return null; + const range = document.createRange(); + range.setStart(found.segments[0].node, found.segments[0].start); + range.setEnd(found.segments.at(-1).node, found.segments.at(-1).end); + return range.getBoundingClientRect(); } const found = anchor ? resolveAnchor(anchor, pageText()) : null; if (!found?.element) return null; @@ -249,8 +179,11 @@ export function createSelectionSurface({ box.right > target.left && box.top < target.bottom && box.bottom > target.top - ) - placeClear(fabBar, target.right - box.width, target.top - box.height - 6); + ) { + const above = target.top - box.height - 6; + const top = above >= topEdge() ? above : target.bottom + 6; + placeClear(fabBar, target.right - box.width, top); + } return true; } function showFab( @@ -259,23 +192,31 @@ export function createSelectionSurface({ { returnFocus = "target", origin = null, place = true } = {}, ) { const previous = fabAnchor; + const previousOrigin = fabOrigin; const leavingBar = !anchor && fabBar.contains(document.activeElement); const returnTarget = leavingBar && previous && !previous.quote - ? fabOrigin?.isConnected - ? fabOrigin - : visualActionAnchor(previous) + ? previousOrigin?.isConnected + ? previousOrigin + : previousOrigin + ? visualActionAnchor(previous) + : null : null; + if (!anchor && composerIsOpen()) hideComposer(); fabAnchor = anchor; fabFloating = !fabAnchor || place; fabOrigin = fabAnchor && origin?.isConnected ? origin : null; fabBar.style.display = fabAnchor ? "inline-flex" : "none"; - // Comment's own display is stated beside the bar's, being what the passage sweeps - // read to know a passage raised the button. + fabInput.style.display = fabAnchor && composerIsOpen() ? "block" : "none"; + const responses = fabBar.querySelector(":scope > .lf-react-trigger"); + if (responses) responses.hidden = !fabAnchor || !hasOtherResponses(fabAnchor); + // Comment returns from the bar's choice state to this same field. At rest the input + // itself is the comment affordance. fab.style.display = fabAnchor ? "block" : "none"; if (fabAnchor) { const label = anchorLabel(fabAnchor).replace(/^§\s*/, ""); fabBar.setAttribute("aria-label", label ? `Respond to ${label}` : "Respond"); + fabInput.setAttribute("aria-label", label ? `Comment on ${label}` : "Comment"); // The tokens already standing on this very anchor read pressed, and a press on one // takes it back (reactHere): the bar is the strip's shape on the page. paintStanding(fabBar, reactionsOn(fabAnchor)); @@ -286,29 +227,38 @@ export function createSelectionSurface({ if (place && !placeFab(target ?? anchorBox(fabAnchor))) { fabAnchor = null; fabOrigin = null; + if (composerIsOpen()) hideComposer(); fabBar.style.display = "none"; + fabInput.style.display = "none"; fab.style.display = "none"; } } if (!sameAnchor(previous, fabAnchor)) paintAnchors(); paintHere(); // the c row names this anchor, so the line is one more rendering of it - if (!fabAnchor && leavingBar && returnFocus !== "none") { - if (returnFocus === "target" && returnTarget?.isConnected) + if (!fabAnchor && returnFocus !== "none") { + if (leavingBar && returnFocus === "target" && returnTarget?.isConnected) returnTarget.focus({ preventScroll: true }); - else leavePageControl(); + else if ( + leavingBar || + (returnFocus === "page" && document.activeElement === previousOrigin) + ) + leavePageControl(); } } let dismissedSelectionKeyup = false; function dismissFab() { dismissedSelectionKeyup = Boolean(pageSelection() || fabAnchor?.quote); pageSelection()?.removeAllRanges(); + if (composerIsOpen()) hideComposer(); showFab(null); } function refreshFab() { // A target row holds its anchor without floating the bar; layout cannot reject that // semantic place merely because the target's rendered box has scrolled away. if (!fabAnchor || !fabFloating) return; - if (fabAnchor.quote) updateFab(); + if (fabAnchor.quote && (composerIsOpen() || fabHoldsCapturedPassage())) { + if (!placeFab()) showFab(null, null, { returnFocus: "page" }); + } else if (fabAnchor.quote) updateFab(); else if (!placeFab()) showFab(null, null, { returnFocus: "page" }); } // The durable anchor names the authored coordinate an event can replay, while the @@ -335,24 +285,34 @@ export function createSelectionSurface({ // The one way an item under a gesture becomes the composer's anchor, so no two routes // can come to write different anchors for the same press. function openOnItem(item, from) { - showFab(null, null, { returnFocus: "none" }); - openComposer({ section: item.id }, "", from.left, from.top); + openComment({ section: item.id }, "", from.left, from.top); } - // Keyboard selection chooses a semantic target before the reader has chosen what to - // do with it, so it raises the shared response actions. The anchor alone decides which - // element supplies the mark and geometry. + // Keyboard selection names the target and immediately lands in its Comment field. function selectResponseTarget({ anchor }) { - showFab(anchor); + openComment(anchor, ""); + } + // Alt-click already names Comment, so the field takes focus in the same transaction. + function focusTargetComment({ anchor }, from = null) { + openComment(anchor, "", from?.left, from?.top); } - // Alt-click already names the action as well as the target. It opens the composer in - // the same transaction instead of asking the reader to choose Comment a second time. - // Target capture still happened before this door, so comments and reactions keep one - // durable anchor model and the claimed press still reaches nothing underneath it. - function openTargetComposer({ anchor }, from) { - showFab(null, null, { returnFocus: "none" }); - openComposer(anchor, "", from.left, from.top); + // Focusing text entry collapses a native page selection. Hold that browser-authored + // selectionchange out of updateFab: the durable anchor is already captured, and letting + // the collapse re-read it as no selection dismisses the field the reader just entered. + function focusFabComment() { + if (!fabAnchor) return; + clearTimeout(selectionUpdate); + selectionUpdate = null; + fabInputTakingFocus = true; + if (composerIsOpen()) fabInput.focus({ preventScroll: true }); + else openComment(structuredClone(fabAnchor), ""); } - // The button follows the selection. What counts as one is measured on the quote it would + const fabOptionsAvailable = () => + Boolean(fabBar.querySelector(":scope > .lf-react-trigger:not([hidden])")); + function showFabOptions() { + fabBar.querySelector(":scope > .lf-react-trigger")?.click(); + } + fabInput.addEventListener("input", () => requestAnimationFrame(() => placeFab())); + // The response field follows the selection. What counts as one is measured on the quote it would // store, not on the selection's own toString(): those are different strings, and gating on // the one the reader sees while storing the one the document holds lets a two-character // quote through behind a rendered three-character selection — a quote short enough to match @@ -367,23 +327,23 @@ export function createSelectionSurface({ return; } if (found) { - showFab(found.anchor, null, { origin }); + openComment(found.anchor, ""); + if (origin) showFab(found.anchor, null, { origin }); return; } const sel = pageSelection(); const anchor = sel ? selectionAnchor(sel) : null; if (anchor?.quote.length >= MIN_QUOTE) { - const picked = pageRange(sel).getBoundingClientRect(); - showFab(anchor, picked); - } else if (fabAnchor?.quote) showFab(null); + openComment(anchor, ""); + } else if (fabAnchor?.quote && !fabHoldsCapturedPassage()) showFab(null); } // Where the pointer stopped is not the question; where the selection is, is. The guard // exists so a mouseup inside the runtime's layer — a click in the panel, the composer — - // can't re-decide the button out from under an open draft. A drag that ends on a widget's + // can't re-decide the response surface out from under an open draft. A drag that ends on a widget's // control is the opposite case: the user was selecting that control's label, and a // tab's name runs to within a few pixels of the strip button's padding, so the mouseup // lands on chrome while the selection is the page's. The snap runs in the same queued - // step that raises the button, so the button lands beside the selection as snapped and + // step that raises the field, so the bar lands beside the selection as snapped and // the capture reads the one the reader is looking at — and only for the primary // button, because a right button's release precedes its context menu, and growing the // selection there rewrites what Copy was aimed at. @@ -402,6 +362,26 @@ export function createSelectionSurface({ let selectionPressPoint = null; let actionPress = false; let targetActivation = false; + let fabInputTakingFocus = false; + function openComment(...args) { + // Chromium may collapse the native page Selection before dispatching the textarea's + // focus event. Mark the handoff first so that intermediate selectionchange cannot + // dismiss the durable anchor the composer is opening on. + fabInputTakingFocus = true; + return openComposer(...args); + } + function fabHoldsCapturedPassage() { + return fabInputTakingFocus || fabBar.contains(document.activeElement); + } + fabInput.addEventListener("focus", () => { + clearTimeout(selectionUpdate); + selectionUpdate = null; + fabInputTakingFocus = true; + }); + fabInput.addEventListener("blur", () => { + fabInputTakingFocus = false; + }); + let primaryPointerPressed = false; const rememberPointerSelection = () => { const selection = pageSelection(); const anchor = selection ? selectionAnchor(selection) : null; @@ -413,6 +393,7 @@ export function createSelectionSurface({ (ev) => { // Capture the old range before the browser's pointerdown default can collapse it. // This is needed when the reader drags across exactly the passage already selected. + primaryPointerPressed = ev.isPrimary && ev.button === 0; pointerSelecting = ev.isPrimary && ev.button === 0 && pageWords(ev.target); selectionChangedDuringPress = false; selectionDragged = false; @@ -435,8 +416,25 @@ export function createSelectionSurface({ ev.clientY - selectionPressPoint.y, ) > 3; }); - const finishPointerSelection = () => { - if (pointerSelecting) scheduleSelectionUpdate(); + const finishPointerSelection = (ev) => { + // A mouse pointer is followed by the compatibility mouseup below, which performs the + // sentence snap before opening the field. Opening from pointerup first would focus the + // textarea and collapse the still-unsnapped Selection before mouseup can finish it. + // Touch/pen and cancellation owe us no compatibility mouse event, so they keep this + // direct route. + if ( + primaryPointerPressed && + ev.type === "pointerup" && + ev.pointerType === "mouse" + ) { + // Keep selectionchange in the in-progress branch until compatibility mouseup. + setTimeout(() => { + actionPress = false; + }); + return; + } + if (primaryPointerPressed) scheduleSelectionUpdate(); + primaryPointerPressed = false; pointerSelecting = false; setTimeout(() => { actionPress = false; @@ -448,15 +446,23 @@ export function createSelectionSurface({ // During a pointer drag, the completed gesture below remains the one that snaps and // places the passage; presses on the action surface must not retract their own target. document.addEventListener("selectionchange", () => { - if (pointerSelecting) { + if (primaryPointerPressed) { selectionChangedDuringPress = true; rememberPointerSelection(); return; } - if (actionPress || targetActivation || takesLetters(document.activeElement)) return; + if ( + actionPress || + targetActivation || + fabHoldsCapturedPassage() || + takesLetters(document.activeElement) + ) + return; scheduleSelectionUpdate(); }); document.addEventListener("mouseup", (ev) => { + primaryPointerPressed = false; + pointerSelecting = false; if (actionPress) return; if (!pageWords(ev.target) && !pageSelection()) return; clearTimeout(selectionUpdate); @@ -466,7 +472,7 @@ export function createSelectionSurface({ updateFab(); }); }); - // Selections made from the keyboard (shift-arrows, ⌘A) deserve the same button. Typing in + // Selections made from the keyboard (shift-arrows, ⌘A) deserve the same response bar. Typing in // a box never does, whatever is selected elsewhere. document.addEventListener("keyup", (ev) => { if (dismissedSelectionKeyup) { @@ -495,13 +501,10 @@ export function createSelectionSurface({ fabAnchor?.section === visual.id && fabAnchor?.visual === visual.part?.part; if (!sameVisual && !target.closest?.(".lf-react-surface, .lf-composer")) { + if (composerIsOpen()) hideComposer(); showFab(null, null, { returnFocus: "page" }); // The armed react press goes with the bar it was armed on. setReact(false); - // Keep a composer that holds unsent text open so a stray click can't drop it; - // Cancel discards explicitly, and the draft is persisted regardless. Asked only of a - // composer that is up, so an ordinary press in the page repaints nothing. - if (composerIsOpen() && !composerInput.value) hideComposer(); } if (referenceIsOpen() && !target.closest?.(".lf-help")) hideReference(); if (!target.closest?.(".lf-help, .lf-keyline")) collapseKeyline(); @@ -513,14 +516,14 @@ export function createSelectionSurface({ document.addEventListener("mousedown", (ev) => standDown(ev.target)); // What a click on the page means, decided once. A mark under the pointer opens its thread; - // otherwise a diagram or image is a find handed to updateFab, which raises the same 💬 - // button on an element anchor — the id the visual lives under. A newly dragged passage + // otherwise a diagram or image is a find handed to updateFab, which raises the same compact + // field on an element anchor — the id the visual lives under. A newly dragged passage // outranks the compatibility click at its endpoint; an older retained selection does not. // // Once, because the hit-test reads layout and opening the panel rewrites it. Two handlers // each asking `markAt` looked independent and were not: the first one's setPanel() reflowed // the document out from under the second, which then missed the very mark it had just - // opened and raised the comment button on top of it — leaving an element anchor set, which + // opened and raised the comment field on top of it — leaving an element anchor set, which // midComposition() reads, so the page quietly stopped following new versions. The rule this // file already carries covers it: a guard that reads state another function wrote is a sign // the two are one function. @@ -531,10 +534,6 @@ export function createSelectionSurface({ const selection = getSelection(); if (selection?.rangeCount) selection.removeAllRanges(); updateFab({ anchor }, { origin: from }); - if (from) - fabBar - .querySelector("button, [data-lf-offer][tabindex]") - ?.focus({ preventScroll: true }); setTimeout(() => { targetActivation = false; }); @@ -608,15 +607,17 @@ export function createSelectionSurface({ beside, dismissFab, fabAnchorAt, + fabOptionsAvailable, fabTargetAt, fabReturnTo, + focusFabComment, openOnItem, - openTargetComposer, + focusTargetComment, placeClear, - placeComposer, refreshFab, selectResponseTarget, showFab, + showFabOptions, standDown, updateFab, }; diff --git a/skills/leaf/assets/runtime/reactions.js b/skills/leaf/assets/runtime/reactions.js index 7a3124063..8e454c3a4 100644 --- a/skills/leaf/assets/runtime/reactions.js +++ b/skills/leaf/assets/runtime/reactions.js @@ -1,5 +1,28 @@ import { marginAction, registerMarginItem } from "./living-margin.js"; +// The anchored response bar has one control grammar of its own. Its buttons share the +// field's type, border, height, and floating elevation without claiming to be target- +// margin actions. The repeated anatomy lets Comment, Suggest, and package reactions +// change vocabulary without each inventing a button shape. +export function responseAction(control, { glyph, label, collapse = false }) { + control.classList.add("lf-response-control", "lf-response-action"); + control.toggleAttribute("data-lf-collapse", collapse); + const glyphNode = document.createElement("span"); + glyphNode.className = "lf-response-action-glyph"; + glyphNode.setAttribute("aria-hidden", "true"); + glyphNode.textContent = glyph; + const spaceNode = document.createElement("span"); + spaceNode.className = "lf-response-action-space"; + spaceNode.setAttribute("aria-hidden", "true"); + spaceNode.textContent = " "; + const labelNode = document.createElement("span"); + labelNode.className = "lf-response-action-label"; + labelNode.textContent = label; + control.replaceChildren(glyphNode, spaceNode, labelNode); + if (!control.hasAttribute("aria-label")) control.setAttribute("aria-label", label); + return control; +} + // Which tokens stand on a target, painted on its strip: pressed, wearing the word, and // carrying the event a second press takes back. The reaction rides the pill rather than // a map beside it, so a reconcile that keeps the node keeps the fact with it. @@ -29,6 +52,7 @@ export function createReactions({ fabReturnTo, fabBar, focused, + hideComposer, itemWord, offer, paintHere, @@ -39,6 +63,7 @@ export function createReactions({ showToast, standingConversation, standingItem, + suggestHere, undoable, visualPartLabel, withdraw, @@ -54,8 +79,8 @@ export function createReactions({ // while the token stands on its target, so a closed surface keeps the reader's marks // without offering the whole vocabulary. Digits remain keyboard accelerators without // changing the shape of every pill. - function reactPill(name, entry, pressed, { margin = false } = {}) { - const pill = offer("button", `${margin ? "" : "lf-pill "}lf-react`); + function reactPill(name, entry, pressed, { margin = false, response = false } = {}) { + const pill = offer("button", `${margin || response ? "" : "lf-pill "}lf-react`); pill.dataset.token = name; pill.title = `${name} — ${entry.means}`; pill.setAttribute("aria-label", name); @@ -64,6 +89,7 @@ export function createReactions({ glyph: entry.glyph, label: name, }); + else if (response) responseAction(pill, { glyph: entry.glyph, label: name }); else pill.append( el("span", "lf-react-glyph", entry.glyph), @@ -77,29 +103,55 @@ export function createReactions({ let surfaceOrdinal = 0; let marginSurface = null; let marginOffer = null; + let marginSuggest = null; function buildReactSurface( surface, pressed, - { label, target, marginActions = false }, + { + label, + target, + marginActions = false, + responseActions = false, + forceTrigger = false, + triggerLabel = null, + }, ) { - if (!reactionTokens().length) return surface; + if (!reactionTokens().length && !forceTrigger) return surface; surface.classList.add("lf-react-surface"); - const trigger = offer("button", "lf-pill lf-react-trigger", "…"); + const floatingResponses = surface === fabBar; + const trigger = offer( + "button", + floatingResponses ? "lf-react-trigger" : "lf-pill lf-react-trigger", + floatingResponses ? "" : "…", + ); + if (floatingResponses) + responseAction(trigger, { + glyph: "…", + label: "Other responses", + collapse: true, + }); trigger.setAttribute("aria-expanded", "false"); - trigger.setAttribute("aria-label", "Show reactions"); - trigger.title = "Show reactions"; + const showLabel = triggerLabel ?? "Show reactions"; + trigger.setAttribute("aria-label", showLabel); + trigger.title = showLabel; const palette = el("span", "lf-react-palette"); palette.id = `lf-reactions-${++surfaceOrdinal}`; palette.setAttribute("role", "group"); palette.setAttribute("aria-label", label); trigger.setAttribute("aria-controls", palette.id); for (const [name, entry] of reactionTokens()) - palette.append(reactPill(name, entry, pressed, { margin: marginActions })); + palette.append( + reactPill(name, entry, pressed, { + margin: marginActions, + response: responseActions, + }), + ); surface.append(trigger, palette); surfaces.set(surface, { palette, target, trigger }); trigger.onclick = () => { if (surface === fabBar) - setReact(!(reactArmed && reactSurface === marginSurface), { + setReact(!(reactArmed && reactSurface === fabBar), { + surface: fabBar, focusPicker: true, }); else setReact(!(reactArmed && reactSurface === surface), { surface }); @@ -108,17 +160,49 @@ export function createReactions({ } function buildReactBar() { + const fabSuggest = responseAction(offer("button", "lf-fab-suggest"), { + glyph: "✎", + label: "Suggest", + }); + fabSuggest.onclick = () => { + if (!fabAnchorAt()?.quote || designIsOn()) return; + setReact(false); + suggestHere(); + }; + fabBar.append(fabSuggest); buildReactSurface(fabBar, reactHere, { label: "Reactions for this selection or item", target: () => anchorWord(fabAnchorAt()), + responseActions: true, + forceTrigger: true, + triggerLabel: "Show other responses", }); marginSurface = el("div", "lf-margin-reactions"); marginSurface.setAttribute("role", "group"); - marginSurface.setAttribute("aria-label", "Comment or react"); + marginSurface.setAttribute("aria-label", "Other responses"); + marginSuggest = marginAction(offer("button", "lf-fab-suggest"), { + glyph: "✎", + label: "Suggest", + }); + marginSuggest.onclick = () => { + if (!fabAnchorAt()?.quote || designIsOn()) return; + setReact(false); + suggestHere(); + }; + const marginComment = marginAction(offer("button", "lf-fab"), { + glyph: "💬", + label: "Comment", + }); + marginComment.onclick = () => { + setReact(false); + fabBar.querySelector(":scope > .lf-fab")?.click(); + }; + marginSurface.append(marginComment, marginSuggest); buildReactSurface(marginSurface, reactHere, { label: "Reactions for this selection or item", target: () => anchorWord(fabAnchorAt()), marginActions: true, + forceTrigger: true, }); } @@ -136,7 +220,7 @@ export function createReactions({ if (!anchor) return; if (pill.lfReaction) { await withdraw(pill.lfReaction); - seatCommentInBar(true); + hideComposer(); showFab(null); setReact(false); if (returnTo?.isConnected) returnTo.focus({ preventScroll: true }); @@ -151,7 +235,7 @@ export function createReactions({ if (designIsOn()) event.about = "layer"; const sent = await sendReaction(event, pill, anchorWord(anchor)); if (!sent) return; - seatCommentInBar(true); + hideComposer(); showFab(null); setReact(false); if (returnTo?.isConnected) returnTo.focus({ preventScroll: true }); @@ -184,11 +268,7 @@ export function createReactions({ const anchor = fabAnchorAt(); const target = anchor && fabTargetAt(); if (!marginSurface || !target) return false; - const comment = fabBar.querySelector(":scope > .lf-fab"); - if (comment) { - marginAction(comment, { glyph: "💬", label: "Comment" }); - marginSurface.prepend(comment); - } + marginSuggest.hidden = !anchor.quote || designIsOn(); fabBar.dataset.lfMarginRaised = "1"; paintReactionStanding( marginSurface, @@ -211,22 +291,9 @@ export function createReactions({ function lowerMarginSurface() { marginOffer?.unregister(); marginOffer = null; - seatCommentInBar(false); delete fabBar.dataset.lfMarginRaised; } - function seatCommentInBar(takeFocus) { - const comment = marginSurface?.querySelector(":scope > .lf-fab"); - if (!comment) return; - fabBar.prepend(comment); - marginAction(comment, { - glyph: "💬", - label: "Comment", - collapse: "always", - }); - if (takeFocus) comment.focus({ preventScroll: true }); - } - function closeSurface(surface) { surface?.classList.remove("lf-react-open"); pickerFor(surface)?.trigger.setAttribute("aria-expanded", "false"); @@ -284,19 +351,33 @@ export function createReactions({ return; } reactArmed = true; + if (reactSurface === fabBar) { + const suggest = fabBar.querySelector(":scope > .lf-fab-suggest"); + if (suggest) suggest.hidden = !fabAnchorAt()?.quote || designIsOn(); + } reactSurface.classList.add("lf-react-open"); + if (reactSurface === fabBar) showFab(fabAnchorAt()); pickerFor(reactSurface).trigger.setAttribute("aria-expanded", "true"); + const firstChoice = + reactSurface === fabBar + ? responseChoices(fabBar)[0] + : reactSurface === marginSurface && !marginSuggest.hidden + ? marginSuggest + : pickerFor(reactSurface).palette.querySelector(".lf-react"); if (focusPicker || (surface && reactFrom === pickerFor(reactSurface).trigger)) - pickerFor(reactSurface).palette.querySelector(".lf-react")?.focus({ + firstChoice?.focus({ preventScroll: true, }); else if (reactFrom === pickerFor(fabBar)?.trigger) - pickerFor(reactSurface).palette.querySelector(".lf-react")?.focus({ + firstChoice?.focus({ preventScroll: true, }); - announce(`React — ${saying(REACT.rows)}`); + announce( + `${reactSurface === fabBar || reactSurface === marginSurface ? "Other responses" : "React"} — ${saying(REACT.rows)}`, + ); } else { const from = reactFrom; + const closingFabChoices = reactSurface === fabBar; const trigger = pickerFor(reactSurface)?.trigger; const active = focused(); reactArmed = false; @@ -306,7 +387,9 @@ export function createReactions({ reactRaised = false; lowerMarginSurface(); if (fabAnchorAt()) showFab(fabAnchorAt()); - if (active?.closest?.(".lf-react-palette")) { + if (closingFabChoices && fabAnchorAt()) { + fabBar.querySelector(".lf-fab-input")?.focus({ preventScroll: true }); + } else if (active?.closest?.(".lf-react-palette")) { const destination = from?.isConnected && from.checkVisibility?.() ? from @@ -319,20 +402,28 @@ export function createReactions({ paintHere(); } - function stepReaction(binding) { - const pills = [ - ...(pickerFor(reactSurface)?.palette.querySelectorAll(".lf-react") ?? []), - ]; - if (!pills.length) return; - const at = pills.indexOf(focused()); - const backward = binding === "ArrowLeft" || binding === "ArrowUp"; + function responseChoices(surface) { + if (!surface) return []; + return [ + ...surface.querySelectorAll( + ":scope > .lf-response-action, :scope > .lf-margin-action, :scope > .lf-react-palette > .lf-react", + ), + ].filter((choice) => choice.checkVisibility()); + } + + function stepResponse(binding) { + const choices = responseChoices(reactSurface); + if (!choices.length) return; + const at = choices.indexOf(focused()); + const backward = + binding === "ArrowLeft" || binding === "ArrowUp" || binding === "Shift+Tab"; const next = at < 0 ? backward - ? pills.length - 1 + ? choices.length - 1 : 0 - : (at + (backward ? -1 : 1) + pills.length) % pills.length; - pills[next].focus({ preventScroll: true }); + : (at + (backward ? -1 : 1) + choices.length) % choices.length; + choices[next].focus({ preventScroll: true }); } const reactTargetWord = () => @@ -341,7 +432,7 @@ export function createReactions({ : (pickerFor(reactSurface)?.target ?? "the target"); const REACT = { - title: "With r armed", + title: "With response choices open", at: () => reactArmed, claims: EVERYTHING, rows: [ @@ -371,25 +462,30 @@ export function createReactions({ { id: "reaction.move", runFromReference: false, - keys: ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], - does: "Move through reactions", + keys: ["Tab", "Shift+Tab", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], + does: "Move through responses", line: "move", repeat: true, - run: stepReaction, + run: stepResponse, }, { - id: "reaction.activate", + id: "response.activate", runFromReference: false, keys: PRESS, - does: "Use the focused reaction", + does: "Use the focused response", line: "choose", - when: () => Boolean(focused()?.closest?.(".lf-react-palette .lf-react")), + when: () => + Boolean( + focused()?.matches?.(".lf-react-palette .lf-react") || + focused()?.matches?.(".lf-margin-reactions > .lf-margin-action") || + focused()?.matches?.(".lf-fab-bar > .lf-response-action"), + ), run: () => focused()?.click(), }, { id: "reaction.cancel", keys: ["Escape"], - does: "Put the reaction down", + does: "Close response choices", line: "cancel", run: () => setReact(false), }, diff --git a/tests/render_cases_navigation.py b/tests/render_cases_navigation.py index f3297b767..80b922c38 100644 --- a/tests/render_cases_navigation.py +++ b/tests/render_cases_navigation.py @@ -195,7 +195,7 @@ def mark_shows_beside_composer(page): is the only thing naming the passage the box is about, so a box covering all of it is a box about nothing — which no state may reach.""" return page.evaluate("""() => { - const box = document.querySelector('.lf-composer').getBoundingClientRect(); + const box = document.querySelector('.lf-fab-bar').getBoundingClientRect(); const rects = [...(CSS.highlights.get('lf-pending') ?? [])] .flatMap(r => [...r.getClientRects()]) .concat([...document.querySelectorAll('.lf-mark-el.lf-pending')] @@ -769,11 +769,9 @@ def compose(page, passage, text=None): key is built from is the same in both tabs.""" page.locator(passage).scroll_into_view_if_needed() page.locator(passage).click(click_count=3) - page.wait_for_selector(".lf-fab", state="visible") - page.locator(".lf-fab").click() - expect(page.locator(".lf-composer textarea")).to_be_focused() + expect(page.locator(".lf-fab-input")).to_be_focused() if text is not None: - page.locator(".lf-composer textarea").fill(text) + page.locator(".lf-fab-input").fill(text) # The two presses this asks about, on one page: a draft's ✎ (a thing to do) and a pick diff --git a/tests/test_render_aim.py b/tests/test_render_aim.py index 69b9e4f1f..df9f7f9e4 100644 --- a/tests/test_render_aim.py +++ b/tests/test_render_aim.py @@ -75,6 +75,26 @@ ) +def open_compact_comment(page, text=None): + """Read the in-place comment field after an aimed press.""" + bar = page.locator(".lf-fab-bar") + field = page.locator(".lf-fab-input") + composer = page.locator(".lf-composer") + expect(bar).to_be_visible() + expect(field).to_be_visible() + expect(field).to_be_focused() + expect(composer).to_have_css("display", "contents") + if text is None: + return field + else: + page.keyboard.type(text) + expect(field).to_have_value(text) + expect(field).to_be_focused() + expect(bar).to_be_visible() + expect(composer).to_have_css("display", "contents") + return field + + def test_the_catalog_sidenote_can_be_aimed_whole(browser, serve): """The sidenote authors copy carries the identity its advertised aim needs. @@ -102,10 +122,23 @@ def test_the_catalog_sidenote_can_be_aimed_whole(browser, serve): note.click() page.keyboard.up("Alt") - # The chord already names Comment, so the press opens the composer on the whole note. - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + # The chord already names Comment, so the durable composer is the focused field + # beside the note. It grows in place and Enter submits it. + field = open_compact_comment(page, "why here") assert page.evaluate(DRAFT_MARK) == "logout-frequency" + one_line = field.bounding_box()["height"] + page.keyboard.press("Shift+Enter") + page.keyboard.type("because every active session must end before support continues") + assert field.bounding_box()["height"] > one_line + expect(page.locator(".lf-composer")).to_have_css("display", "contents") + page.keyboard.press("Enter") + round_trip(page) + sent = events_model.read_events(serve.page_dir)[-1] + assert sent["kind"] == "comment" + assert sent["text"] == ( + "why here\nbecause every active session must end before support continues" + ) + expect(page.locator(".lf-fab-bar")).to_be_hidden() assert errors == [] page.close() @@ -147,12 +180,11 @@ def test_an_aimed_comment_keeps_its_place_with_the_asks_tray_open(browser, serve target.click() page.keyboard.up("Alt") - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + open_compact_comment(page) placed = page.evaluate( """() => { const target = document.getElementById('lq-keep').getBoundingClientRect(); - const box = document.querySelector('.lf-composer').getBoundingClientRect(); + const box = document.querySelector('.lf-fab-input').getBoundingClientRect(); const overlaps = target.left < box.right && box.left < target.right && target.top < box.bottom && box.top < target.bottom; return { left: box.left, right: box.right, overlaps, width: innerWidth }; @@ -198,9 +230,8 @@ def test_the_aim_reads_the_pointer_where_the_press_is_dispatched_from(browser, s page.mouse.click(seam["x"], seam["y"]) page.keyboard.up("Alt") - # The press opens Comment on the item the aim held. - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + # The press focuses Comment on the item the aim held. + open_compact_comment(page) assert page.evaluate(DRAFT_MARK) == seam["at"] assert errors == [] page.close() @@ -232,8 +263,7 @@ def test_an_aimed_first_press_records_its_pointer_before_claiming_it(browser, se }""" ) - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + open_compact_comment(page) assert page.evaluate(DRAFT_MARK) == "p2" assert errors == [] page.close() @@ -336,9 +366,8 @@ def test_an_aimed_press_does_only_what_the_outline_promised( expect(bar).to_be_hidden() expect(composer).to_be_hidden() else: - # The chord promised Comment, so the press opens the composer directly. - expect(bar).to_be_hidden() - expect(composer).to_be_visible() + # The chord promised Comment, so the press focuses its compact field. + open_compact_comment(page) mark = page.evaluate(DRAFT_MARK) # A box a standing thread already outlines keeps the posted colour and takes # no pending class of its own: the draft claims whichever boxes are still @@ -448,9 +477,8 @@ def test_an_aim_on_a_seam_promises_and_takes_the_same_item(browser, serve): ) page.mouse.click(edge["x"], edge["y"]) page.keyboard.up("Alt") - # The press opens Comment on what it took. - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + # The press focuses Comment on what it took. + open_compact_comment(page) assert page.evaluate(DRAFT_MARK) == promised, ( f"the outline promised {promised} on the seam and the press commented on " f"{page.evaluate(DRAFT_MARK)}" @@ -472,9 +500,8 @@ def test_a_key_still_reaches_its_control_after_an_aimed_press(browser, serve): page.keyboard.down("Alt") heading.click() page.keyboard.up("Alt") - expect(page.locator(".lf-fab-bar")).to_be_hidden() # the press was the aim's composer = page.locator(".lf-composer") - expect(composer).to_be_visible() + open_compact_comment(page) page.keyboard.press("Escape") expect(composer).to_be_hidden() @@ -502,9 +529,8 @@ def test_the_aim_still_promises_while_a_composer_is_open(browser, serve): page.keyboard.down("Alt") heading.click() page.keyboard.up("Alt") - expect(page.locator(".lf-fab-bar")).to_be_hidden() composer = page.locator(".lf-composer") - expect(composer).to_be_visible() + open_compact_comment(page) composer.locator("textarea").fill("carried words") card = page.locator("#card-notes") @@ -518,8 +544,8 @@ def test_the_aim_still_promises_while_a_composer_is_open(browser, serve): card.click() page.keyboard.up("Alt") # The second explicit comment gesture moves the open draft onto the card. - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(composer).to_be_visible() + expect(page.locator(".lf-fab-bar")).to_be_visible() + expect(composer.locator("textarea")).to_be_focused() expect(composer.locator("textarea")).to_have_value("carried words") assert [page.evaluate(AIMED), page.evaluate(DRAFT_MARK)] == [ None, @@ -618,7 +644,7 @@ def box_of(element_id): composer_input.click() expect(composer_input).to_be_focused() composer_input.fill("the ring reads too heavy") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) events = events_model.read_events(serve.page_dir) posted = [e for e in events if e["kind"] == "comment"] @@ -803,7 +829,7 @@ def test_design_mode_reaches_the_chrome_and_names_the_control(browser, serve): expect(page.locator("#lf-composer-quote")).to_have_text(f"layer · {said} · banner") expect(page.locator(".lf-panel")).to_be_hidden() page.locator(".lf-composer textarea").fill("reads dim against the wash") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) posted = [ e for e in events_model.read_events(serve.page_dir) if e["kind"] == "comment" @@ -899,9 +925,10 @@ def test_design_mode_leaves_prose_to_the_selection(browser, serve): (box["x"] + 2, box["y"] + box["height"] / 2), (box["x"] + box["width"] - 2, box["y"] + box["height"] / 2), ) - fab = page.locator(".lf-fab") - expect(fab).to_be_visible() - fab.click() + field = page.locator(".lf-fab-input") + expect(field).to_be_visible() + field.click() + page.keyboard.press("Enter") expect(page.locator(".lf-composer")).to_be_visible() expect(page.locator("#lf-composer-quote")).to_have_text( "layer · heading · t · “Rollout”" @@ -1058,17 +1085,15 @@ def test_a_declared_flowchart_node_keeps_its_comment_across_renderings(browser, unlisted = diagram.locator('g[id*="flowchart-U-"]') unlisted.click() - page.locator(".lf-fab").click() expect(diagram).to_have_class(re.compile(r"\blf-mark-el\b.*\blf-pending\b")) - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") start = diagram.locator('g[id*="flowchart-S-"]') start.click() - page.locator(".lf-fab").click() expect(start).to_have_class(re.compile(r"\blf-mark-el\b.*\blf-pending\b")) expect(diagram).not_to_have_class(re.compile(r"\blf-mark-el\b")) page.locator(".lf-composer textarea").fill("name the retry path here") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) posted = [ @@ -1109,12 +1134,11 @@ def test_a_linked_flowchart_node_opens_its_comment_without_following_the_link( expect(handler.locator("xpath=ancestor::*[local-name()='a'][1]")).to_have_count(1) handler.click(modifiers=["Alt"]) - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + open_compact_comment(page) expect(handler).to_have_class(re.compile(r"\blf-mark-el\b.*\blf-pending\b")) expect(diagram).not_to_have_class(re.compile(r"\blf-mark-el\b")) page.locator(".lf-composer textarea").fill("keep this linked step visible") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) posted = [ @@ -1141,7 +1165,7 @@ def test_design_mode_keeps_its_control_label_on_a_part_visual(browser, serve): "layer · Handle request · lf-diagram · flow" ) page.locator(".lf-composer textarea").fill("the link needs a stronger affordance") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) posted = [ event @@ -1201,29 +1225,28 @@ def test_a_declared_box_takes_its_comment_on_every_type_that_carries_an_id( def aim(target, **press): target.click(modifiers=["Alt"], **press) - expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(page.locator(".lf-composer")).to_be_visible() + open_compact_comment(page) state = page.locator('#life g[id*="state-Queued-"]') aim(state) expect(page.locator("#lf-composer-quote")).to_have_text("§ diagram · Queued") page.locator(".lf-composer textarea").fill("how long does it sit here") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) # A box inside the composite state, declared in its own right. aim(page.locator('#life g[id*="state-Build-"]')) expect(page.locator("#lf-composer-quote")).to_have_text("§ diagram · Build") - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") aim(page.locator('#life g[id*="Working"]'), position={"x": 6, "y": 6}) expect(page.locator("#lf-composer-quote")).to_have_text("§ diagram · Working") - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") entity = page.locator('#shape g[id*="entity-RUNNER-"]') aim(entity) expect(page.locator("#lf-composer-quote")).to_have_text("§ diagram · RUNNER") - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") # A node's label is the words the box shows. The source's own string is what # Mermaid renders from — markdown, entities and all — so it is not what a thread @@ -1232,11 +1255,11 @@ def aim(target, **press): expect(page.locator("#lf-composer-quote")).to_have_text( "§ diagram · Bold and plain" ) - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") aim(entity) page.locator(".lf-composer textarea").fill("one runner or many") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) posted = [ diff --git a/tests/test_render_anchors.py b/tests/test_render_anchors.py index a5ce4009a..0c2adba82 100644 --- a/tests/test_render_anchors.py +++ b/tests/test_render_anchors.py @@ -48,6 +48,7 @@ mark_point, open_page, panel_settled, + pending_text, post_event, resized, round_trip, @@ -112,7 +113,7 @@ def test_every_passage_in_a_real_page_can_be_quoted(browser, serve, example): result = page.evaluate("""async () => { const tick = () => new Promise(r => setTimeout(r, 0)); const composer = document.querySelector('.lf-composer'); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); // A reader reaches everything eventually — opens the details, clicks through to // the other tab — so everything is in scope, not just what the page opens on. document.querySelectorAll('details').forEach(d => (d.open = true)); @@ -147,7 +148,6 @@ def test_every_passage_in_a_real_page_can_be_quoted(browser, serve, example): skipped.push(range.toString().replace(/\\s+/g, ' ').trim().slice(0, 70)); continue; } - fab.click(); await tick(); const painted = CSS.highlights.get('lf-pending'); // The captured quote, read off the node whether or not the reader can @@ -215,20 +215,19 @@ class of bug here is text that looks selectable and isn't — a synthetic Range y = box["y"] + box["height"] / 2 select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y)) - # The theme uppercases a column heading, so the selection reads back as the reader - # sees it and the quote as the document holds it — the asymmetry that makes - # selectionAnchor read the text nodes rather than the selection's own toString(). - assert page.evaluate("() => getSelection().toString()").strip() == "IN FLIGHT", ( + # Focusing the immediate field collapses the browser Selection; the durable pending + # paint proves the drag selected the words the widget actually says. + assert pending_text(page).strip() == "In flight", ( "a drag across the heading selected nothing — it is painted, not said" ) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) quoted = composer_quote(page)["text"] assert quoted.strip("“”") == "In flight" page.locator(".lf-composer textarea").fill("this column's name is wrong") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") page.wait_for_function("() => (CSS.highlights.get('lf-mark')?.size ?? 0) > 0") thread = page.locator(".lf-thread .lf-quote").first @@ -300,10 +299,10 @@ def test_browser_and_file_captures_stop_at_the_same_widget_fences(browser, serve ) assert selected == quote page.dispatch_event("body", "mouseup") - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill(f"fence {index}") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") expect(page.locator(".lf-thread")).to_have_count(index) actual_anchor = [ event["anchor"] @@ -401,19 +400,19 @@ def test_a_widgets_label_takes_a_comment_inside_the_control_it_labels(browser, s y = box["y"] + box["height"] / 2 select(page, (box["x"] + 6, y), (box["x"] + box["width"] - 6, y)) - assert ( - page.evaluate("() => getSelection().toString()").strip() == "Heated bird bath" - ), "a drag across the tab's name selected nothing" + assert pending_text(page).strip() == "Heated bird bath", ( + "a drag across the tab's name selected nothing" + ) # The drag ended on a button, and the button still switches tabs — but this mouseup # was a selection's, not a press, so the reader is still looking at what they were # reading when they reached for the name. expect(page.locator("#p-feeders")).to_be_visible() - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() expect(page.locator(".lf-composer")).to_be_visible() assert composer_quote(page)["text"].strip("“”") == "Heated bird bath" page.locator(".lf-composer textarea").fill("call it the bath, not the bird bath") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") page.wait_for_function("() => (CSS.highlights.get('lf-mark')?.size ?? 0) > 0") thread = page.locator(".lf-thread .lf-quote").first @@ -437,20 +436,11 @@ def test_a_widgets_label_takes_a_comment_inside_the_control_it_labels(browser, s page.close() -def test_a_selection_around_a_control_does_not_deaden_it(browser, serve): - """The other side of the guard above, and the one that cost more. A user reads - the sentence a suggestion sits in, drags across it, and then presses Accept — a - fresh press, long after that drag's own mouseup. - - Asking whether the live selection *contains* the control is a question about the - DOM, and a suggestion's row is the column's own child in flow between the block - holding the change and the next one: a drag across both runs straight over it. So - Accept did nothing, and kept doing nothing, because a press that refuses a drag - never collapses the selection that deadened it either. The keyboard still worked, - which is the shape of a bug nobody reports — it looks like a slip of the mouse. - - Both decisions the product exists to collect go through a press, so this asserts the - pointer and then the keyboard, with the selection standing throughout.""" +def test_a_comment_around_a_control_does_not_deaden_it(browser, serve): + """A user drags across copy that contains a suggestion row and then decides the + suggestion. Moving focus into the immediate comment field collapses the browser's + native selection, but the captured passage must remain durable and the nearby + controls must still work by pointer and keyboard.""" page, errors = open_page(browser, serve(SUGGESTION_PAGE)) # Across the two paragraphs, so the row deciding the first is inside the selection. start = page.locator("#replace").bounding_box() @@ -461,16 +451,11 @@ def test_a_selection_around_a_control_does_not_deaden_it(browser, serve): (end["x"] + end["width"] - 6, end["y"] + end["height"] - 6), steps=16, ) - assert page.evaluate( - "() => getSelection().containsNode(document.querySelector(" - "'[data-lf-for=sug-refill] .lf-sug-reject'), true)" - ), "the selection doesn't reach the control, so this run tests nothing" + assert "Refill" in pending_text(page) + expect(page.locator(".lf-fab-input")).to_be_focused() page.locator("[data-lf-for='sug-refill'] .lf-sug-reject").click() expect(page.locator("#sug-refill")).to_have_attribute("data-lf-state", "reject") - assert page.evaluate("() => !getSelection().isCollapsed"), ( - "the press cleared the selection, so the keyboard half below is untested" - ) page.locator("[data-lf-for='sug-in-card'] .lf-sug-accept").focus() page.keyboard.press("Enter") expect(page.locator("#sug-in-card")).to_have_attribute("data-lf-state", "accept") @@ -479,42 +464,11 @@ def test_a_selection_around_a_control_does_not_deaden_it(browser, serve): def test_the_comment_button_stands_on_no_control(browser, serve): - """And the other way the same press is lost: not deadened but covered. A selection - fills its lines, so the button placed beside it goes out to the column's right edge — - into the margin, on the line the change starts, which is exactly where the row - deciding that change hangs. The user's own gesture put the 💬 over the Accept - they made it to reach, and the press did the one thing worse than nothing: it hit the - button and opened a composer, because a press on the 💬 is not the outside click that - dismisses it. - - Asserted through the hit test rather than the rectangles, since what matters is which - element the press would reach — and then by making the press, which is the whole - claim. - - Both readings ask where the row's own centre is, so both go quiet together the moment - the bar stops reaching it, and neither says so. That is not hypothetical: the bar - carrying six reaction pills reached 219px past the row, and the bar carrying 💬 and one - ellipsis stops 2.9px short of it, so between those two shapes this test ran on a page - where nothing was ever in the way. The walk stepping is the arrangement, so state it — - the sibling test below already does, and it is the assertion that caught the same - staleness rather than sleeping through it. - - Read at the control's corners as well as its centre, because a press lands where the - reader aimed and the top of a pill is as much of it as the middle. The centre alone - could not fail here: the bar hangs 6px above the line it stands beside and is the row's - own height, so a bar that never stepped reaches the row's top edge and stops 1.25px - short of its centre — at every width where the row still hangs in the margin, not just - this one. The walk could have been removed outright and the centre stayed clear. The - corners are on the part the bar does reach, so the coverage claim is falsifiable again - without trading the hit test for rectangles; the press below still lands at the centre, - which is why the hit test is what carries the claim and the press confirms it. - - Narrowed to where the bar and the row genuinely overlap rather than left at a desk's - width, because at 1200 the bar clears the row outright and steps only through - placeClear's 6px sharing gutter — 3.1px of slack, which is the allowance that same - filter's comment says a one-glyph difference between system fonts must not decide. A - precondition resting on it would go red for a font, saying staleness. At 930 the two - overlap by 20.3px and the step is the row's own doing.""" + """The immediate comment field must not cover an authored control in the margin. + + Check the controls' corners as well as their centres, then make the press. The hit + test proves what receives the pointer; the click proves that the decision, rather + than the floating response UI, handles it.""" page, errors = open_page(browser, serve(SUGGESTION_PAGE)) # Wide enough that the suggestion still hangs its row in the margin — below 900 it # docks under its block and is out of the bar's way again. @@ -526,12 +480,7 @@ def test_the_comment_button_stands_on_no_control(browser, serve): (box["x"] + box["width"] - 8, box["y"] + box["height"] - 6), steps=16, ) - expect(page.locator(".lf-fab")).to_be_visible() - assert page.locator(".lf-fab-bar").evaluate( - "el => el.getBoundingClientRect().top" - ) > page.locator("[data-lf-for='sug-refill']").evaluate( - "el => el.getBoundingClientRect().bottom" - ), "the bar never stepped past the row, so standing on no control proves nothing" + expect(page.locator(".lf-fab-input")).to_be_visible() under = page.evaluate("""() => [...document.querySelectorAll("[data-lf-offer]")] .filter(c => !c.closest(".lf-chrome")) @@ -553,23 +502,12 @@ def test_the_comment_button_stands_on_no_control(browser, serve): page.close() -def test_the_margin_offers_one_kind_of_press(browser, serve): - """The 💬 and a change's ✓ Accept stand in the same margin, sometimes on the same - line — the test above is that collision — so they have to read as one thing. - - They did not. The button was the chrome's own idiom (a solid accent rectangle at - the chrome's size, and, through a cascade nobody meant, set in the page's serif - three points larger than every other control in the layer) beside two hairline - pills, which put two idioms four centimetres apart in the one place a reader - compares them. Where a control stands decides which it wears: in the runtime's - furniture a press is a .lf-btn and looks like one, and out in the margin it is a - marginal mark. +def test_the_floating_response_bar_has_one_compact_face(browser, serve): + """The input-first field and its reaction ellipsis read as one floating surface. - Pinned by reading both off one page. marginAction is one statement now, but either - wearer can still restate a property in its own rules, and this is what says such a - restatement kept the family. The shadow is the one property allowed to differ, and - it is the difference that is real: only one of them floats over the page's own words - rather than standing in the empty rail.""" + The field is longer because it accepts words, but its type, border, colour, and + elevation belong to the same compact family as the adjacent press. Its radius stays + finite so it can grow into a multiline field without becoming a capsule.""" page, errors = open_page(browser, serve(SUGGESTION_PAGE)) box = page.locator("#replace").bounding_box() select( @@ -578,34 +516,32 @@ def test_the_margin_offers_one_kind_of_press(browser, serve): (box["x"] + box["width"] - 8, box["y"] + box["height"] - 6), steps=16, ) - expect(page.locator(".lf-fab")).to_be_visible() - # The drag ends where the button is raised, so the pointer is on it: both are read + expect(page.locator(".lf-fab-input")).to_be_visible() + # The drag ends where the bar is raised, so the pointer is on it: both are read # at rest, since a hover state read against a resting one compares nothing. page.mouse.move(4, 4) family = """el => { const s = getComputedStyle(el); - return Object.fromEntries(["font-family", "font-size", "line-height", - "border-radius", "border-top-width", "border-top-style", "padding", - "background-color", "color"].map(p => [p, s.getPropertyValue(p)])); }""" - raised = page.locator(".lf-fab").evaluate(family) - resident = page.locator("[data-lf-for='sug-refill'] .lf-sug-accept").evaluate( - family - ) - assert raised == resident, ( - "the margin's two presses are drawn differently:\n " + return Object.fromEntries(["font-family", "font-size", + "border-top-width", "border-top-style", + "background-color"].map(p => [p, s.getPropertyValue(p)])); }""" + raised = page.locator(".lf-fab-input").evaluate(family) + adjacent = page.locator(".lf-fab-bar .lf-react-trigger").evaluate(family) + assert raised == adjacent, ( + "the floating field and ellipsis are drawn differently:\n " + "\n ".join( - f"{k}: {raised[k]!r} vs {resident[k]!r}" + f"{k}: {raised[k]!r} vs {adjacent[k]!r}" for k in raised - if raised[k] != resident[k] + if raised[k] != adjacent[k] ) ) assert "system-ui" in raised["font-family"], ( - f"the margin's presses speak in the document's voice: {raised['font-family']}" + f"the response bar speaks in the document's voice: {raised['font-family']}" ) assert ( - page.locator(".lf-fab").evaluate("el => getComputedStyle(el).boxShadow") + page.locator(".lf-fab-input").evaluate("el => getComputedStyle(el).boxShadow") != "none" - ), "the one press that floats over the page says nothing about it" + ), "the field that floats over the page says nothing about its elevation" assert errors == [] page.close() @@ -667,45 +603,6 @@ def test_one_key_keeps_one_keyboard_face_across_the_page(browser, serve): page.close() -def test_the_composer_opens_where_the_button_stood(browser, serve): - """Stepping the button aside is undone if what it opens goes back. The button carries - the anchor it was raised on, and it used to carry the position it was *asked for* - alongside — the same point for as long as nothing moved it, and a different one from - the moment something did. So the 💬 cleared the row and the composer it opened landed - back on top of it.""" - page, errors = open_page(browser, serve(SUGGESTION_PAGE)) - # The margin has to be narrow enough that the bar lands on the suggestion's row: - # the bar carries 💬 and one ellipsis, so on a wide window it fits in the gap - # between the column's right edge and the row hanging beyond it and never steps - # anywhere. Narrower still and the row docks under its block instead, which puts - # it out of the bar's way again — 930 is inside both edges of that band. - resized(page, 930, 900) - box = page.locator("#replace").bounding_box() - select( - page, - (box["x"] + 4, box["y"] + 6), - (box["x"] + box["width"] - 8, box["y"] + box["height"] - 6), - steps=16, - ) - expect(page.locator(".lf-fab")).to_be_visible() - stood = page.locator(".lf-fab").evaluate("el => el.getBoundingClientRect().top") - # It moved, or this run would hold whether or not the position were carried along. - assert stood > page.locator("[data-lf-for='sug-refill']").evaluate( - "el => el.getBoundingClientRect().bottom" - ), "the button never stepped aside, so where it stood proves nothing" - - page.locator(".lf-fab").click() - expect(page.locator(".lf-composer")).to_be_visible() - opened = page.locator(".lf-composer").evaluate( - "el => el.getBoundingClientRect().top" - ) - assert abs(opened - stood) <= 1, ( - f"the composer opened at {opened}, where the button was asked for, not {stood}" - ) - assert errors == [] - page.close() - - def test_a_drag_released_mid_word_hugs_words_and_sentences(browser, serve): """A drag stops where the hand stopped: four glyphs into "paragraph", four short of the end of "carrying". The reader meant the words, and the quote the capture @@ -717,15 +614,12 @@ def test_a_drag_released_mid_word_hugs_words_and_sentences(browser, serve): text node that puts two EDGEs flush in the indexed reading — still grows whole. What the pointer path must not do is here too. A keyboard selection is never - grown — shift-arrow is the reader being precise — so the key release that raises - the button leaves a mid-word selection exactly as made, and so does the right - button, whose release precedes the context menu Copy lives in. A right-to-left - drag keeps its direction, asked of boundary points rather than node order because - a selection ending on the element holding its own start both precedes and - contains it. And machine-placed words never glue to the author's, on either - side of the declaration line: an undeclared generated span is a fenced cell in - the reading, and a declared label — a specimen's, rendered flush before its - words inside a list item, where both share the one block — is the seam itself. + grown — shift-arrow is the reader being precise — so the comment field captures + a mid-word selection exactly as made. Machine-placed words never glue to the + author's, on either side of the declaration line: an undeclared generated span + is a fenced cell in the reading, and a declared label — a specimen's, rendered + flush before its words inside a list item, where both share the one block — is + the seam itself. A sentence follows the same rule at its two meaningful edges. A drag that already reaches its opening and closing words grows over the punctuation around them, but @@ -734,9 +628,8 @@ def test_a_drag_released_mid_word_hugs_words_and_sentences(browser, serve): drag must stay in one rendered block; matching endpoints around a nested paragraph do not make the intervening blocks one sentence. - The reads await one queued step first, the same tick the mouseup handler defers - its own work behind, so each one sees the selection after the snap rather than - racing it.""" + The reads use Leaf's pending highlight because focus moves into the immediate + comment field and therefore collapses the browser's native selection.""" page, errors = open_page( browser, serve( @@ -767,43 +660,42 @@ def test_a_drag_released_mid_word_hugs_words_and_sentences(browser, serve): return [box.left + 1, box.top + box.height / 2]; } }""" - settled = ( - "async () => { await new Promise(r => setTimeout(r, 0));" - " return getSelection().toString(); }" - ) def spot(root, word, into): return page.evaluate(mid, {"root": root, "word": word, "into": into}) + def captured(): + expect(page.locator(".lf-fab-input")).to_be_focused() + quoted = composer_quote(page)["text"] + return quoted[1:-1] + select( page, spot("#sentence", "Opening", 3), spot("#sentence", "together", 4), ) - assert page.evaluate(settled) == "“Opening words stay together.”" + assert captured() == "“Opening words stay together.”" sentence_box = page.locator("#sentence").bounding_box() page.mouse.click(sentence_box["x"] - 40, sentence_box["y"] + 4) expect(page.locator(".lf-fab-bar")).to_be_hidden() page.locator("#sentence").scroll_into_view_if_needed() select(page, spot("#sentence", "words", 2), spot("#sentence", "stay", 2)) - assert page.evaluate(settled) == "words stay" + assert captured() == "words stay" page.mouse.click(sentence_box["x"] - 40, sentence_box["y"] + 4) expect(page.locator(".lf-fab-bar")).to_be_hidden() page.locator("#nested").scroll_into_view_if_needed() select(page, spot("#nested", "Opening", 3), spot("#nested", "together", 4)) - assert " ".join(page.evaluate(settled).split()) == ( - "Opening words Nested block. closing together" - ) + assert captured() == ("Opening words Nested block. closing together") nested_box = page.locator("#nested").bounding_box() page.mouse.click(nested_box["x"] - 40, nested_box["y"] + 4) expect(page.locator(".lf-fab-bar")).to_be_hidden() page.locator("#p").scroll_into_view_if_needed() select(page, spot("#p", "paragraph", 4), spot("#p", "carrying", 4)) - assert page.evaluate(settled) == "paragraph carrying" - expect(page.locator(".lf-fab")).to_be_visible() + assert captured() == "paragraph carrying" + expect(page.locator(".lf-fab-input")).to_be_visible() # The bar the selection raised stands above it, over the line the next drag starts # on; a press in the margin beside the paragraph is the reader's own move that @@ -812,46 +704,26 @@ def spot(root, word, into): beside = page.locator("#p").bounding_box() page.mouse.click(beside["x"] - 40, beside["y"] + 4) expect(page.locator(".lf-fab-bar")).to_be_hidden() - assert page.evaluate(settled) == "" + assert pending_text(page) == "" select(page, spot("#p", "inside", 2), spot("#p", "it,", 1)) - assert page.evaluate(settled) == "inside it" + assert captured() == "inside it" - # The same words dragged right to left: snapped the same, and still facing - # backward, or the shift-click that extends it next extends the wrong end. The - # click first is the reader's own move — a press inside the standing selection - # would drag its text, not start a new one. + # The same words dragged right to left are captured the same. The click first is + # the reader's own move — a press inside the standing selection would drag its + # text, not start a new one. page.locator("#t").click() select(page, spot("#p", "it,", 1), spot("#p", "inside", 2)) - assert page.evaluate(settled) == "inside it" - assert page.evaluate( - "() => { const s = getSelection();" - " return s.anchorNode === s.focusNode ? s.anchorOffset > s.focusOffset" - " : Boolean(s.anchorNode.compareDocumentPosition(s.focusNode)" - " & Node.DOCUMENT_POSITION_PRECEDING); }" - ), "a right-to-left drag came out of the snap facing forward" + assert captured() == "inside it" + page.locator("#t").click() + page.evaluate("() => new Promise(resolve => setTimeout(resolve, 0))") page.evaluate("""() => { const n = document.querySelector('#p').firstChild; const at = n.data.indexOf('paragraph') + 2; getSelection().setBaseAndExtent(n, at, n, at + 5); }""") page.keyboard.press("Shift") - assert page.evaluate(settled) == "ragra" - where = spot("#p", "paragraph", 4) - page.mouse.click(where[0], where[1], button="right") - assert page.evaluate(settled) == "ragra" - - forward_kept = page.evaluate("""async () => { - const p = document.querySelector('#p2'); - const at = p.firstChild.data.indexOf('neighbouring') + 3; - getSelection().setBaseAndExtent(p.firstChild, at, p, p.childNodes.length); - document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); - await new Promise(r => setTimeout(r, 0)); - const s = getSelection(); - const r = s.getRangeAt(0); - return s.anchorNode === r.startContainer && s.anchorOffset === r.startOffset; - }""") - assert forward_kept, "a forward selection ending on an element came out backward" + assert captured() == "ragra" page.evaluate("""() => { const n = document.querySelector('#p').firstChild; @@ -860,7 +732,7 @@ def spot(root, word, into): n.splitText(at); // at the new node's own end, so the second piece is empty }""") select(page, spot("#p", "graph", 1), spot("#p", "carrying", 4)) - assert page.evaluate(settled) == "paragraph carrying" + assert captured() == "paragraph carrying" page.evaluate("""() => { const p2 = document.querySelector('#p2'); @@ -871,13 +743,13 @@ def spot(root, word, into): p2.insertBefore(span, rest); // flush: the page now reads "boundaryflagged" }""") select(page, spot("#p2", "flagged", 3), spot("#p2", "them", 1)) - assert page.evaluate(settled) == "flagged between them" + assert captured() == "flagged between them" # The declared label: rendered by the real pass, flush before the specimen's own # words, unfenced because the registry models it — so the reading holds # "monoglyphs", and only the seam keeps a drag into "glyphs" from taking "mono". select(page, spot("lf-specimen", "glyphs", 3), spot("lf-specimen", "close", 3)) - assert page.evaluate(settled) == "glyphs set close" + assert captured() == "glyphs set close" assert errors == [] page.close() @@ -949,7 +821,7 @@ def test_a_quote_finds_its_passage_whatever_its_whitespace(browser, serve): const s = getSelection(); s.removeAllRanges(); s.addRange(r); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(x => setTimeout(x, 30)); - document.querySelector('.lf-fab').click(); + const field = document.querySelector('.lf-fab-input'); await new Promise(x => setTimeout(x, 30)); const painted = [...(CSS.highlights.get('lf-pending') ?? [])][0]; return painted && painted.compareBoundaryPoints(Range.START_TO_START, r) === 0; @@ -971,9 +843,9 @@ def test_the_captured_quote_is_prose_a_file_can_hold(browser, serve): def compose_on(block): page.locator(block).click(click_count=3) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) # Read off the composer's description of its own anchor, which is the captured quote @@ -981,7 +853,7 @@ def compose_on(block): compose_on("#p") # authored across two source lines wrapped = composer_quote(page)["text"] assert "\n" not in wrapped, f"the quote carries the source's line wrap: {wrapped!r}" - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") # Measured in the page: a lone surrogate does not survive the trip out to the test # runner, which replaces it, so asking out here would always come back clean. @@ -997,7 +869,7 @@ def compose_on(block): # to a UTF-8 file. A half character fails there, reported to the reader as an offline # server, and no retry can ever succeed. page.locator(".lf-composer textarea").fill("a comment on the capped passage") - page.locator(".lf-composer").get_by_role("button", name="Comment").click() + page.keyboard.press("Enter") page.wait_for_function("""() => document.querySelectorAll('.lf-thread').length === 1 || document.querySelector('.lf-toast').classList.contains('show')""") assert page.locator(".lf-thread").count() == 1, ( @@ -1032,9 +904,9 @@ def test_an_open_composer_does_not_eat_the_next_click(browser, serve): # Open a composer on other text and type nothing, so the next mousedown outside it # is the one that takes it down. page.locator("#q").click(click_count=3) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) # The click that selected #q also scrolled it into view. Bring the other passage @@ -1048,9 +920,9 @@ def test_an_open_composer_does_not_eat_the_next_click(browser, serve): # range runs up to the posted one, so this lands on the draft and nothing else. page.get_by_role("button", name="Close threads").click() page.locator("#p").click(click_count=3) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) page.mouse.click(*mark_point(page, "lf-pending")) assert not page.locator(".lf-panel").evaluate( @@ -1066,7 +938,7 @@ def test_a_click_on_a_mark_decides_once(browser, serve): """Opening the panel reflows the document, so anything that hit-tests the page after the panel opens is testing geometry that has already moved. When two handlers each asked where the pointer was, the second missed the mark the first had just opened and - raised the comment button on top of it — and the element anchor that left behind reads + raised the response bar on top of it — and the element anchor that left behind reads as composition in progress, which is what stops a page following new versions. The panel starts shut here because a panel already open is the case with no reflow.""" version_url = serve(INLINE_PAGE) @@ -1096,7 +968,7 @@ def test_a_click_on_a_mark_decides_once(browser, serve): page.mouse.click(spot["x"], spot["y"]) panel_settled(page) expect( - page.locator(".lf-fab"), + page.locator(".lf-fab-input"), "the click opened the thread and then offered to comment on it as well", ).not_to_be_visible() @@ -2267,9 +2139,8 @@ def test_a_repeated_passage_anchors_where_it_was_picked(browser, serve): const sel = getSelection(); sel.removeAllRanges(); sel.addRange(want); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(r => setTimeout(r, 40)); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); if (fab.style.display !== 'block') return 'no button'; - fab.click(); await new Promise(r => setTimeout(r, 40)); const painted = [...(CSS.highlights.get('lf-pending') ?? [])][0]; if (!painted) return 'no mark'; @@ -2299,9 +2170,8 @@ def test_an_ambiguous_revised_passage_detaches_instead_of_guessing(browser, serv const sel = getSelection(); sel.removeAllRanges(); sel.addRange(want); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(r => setTimeout(r, 40)); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); if (fab.style.display !== 'block') return 'no button'; - fab.click(); await new Promise(r => setTimeout(r, 40)); document.querySelector('.lf-composer textarea').value = 'is this idempotent?'; document.querySelector('.lf-composer textarea') @@ -2352,9 +2222,8 @@ def test_a_passage_among_padded_emoji_confirms_its_neighbours(browser, serve): const sel = getSelection(); sel.removeAllRanges(); sel.addRange(want); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(r => setTimeout(r, 60)); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); if (fab.style.display !== 'block') return 'no button'; - fab.click(); await new Promise(r => setTimeout(r, 60)); const painted = [...(CSS.highlights.get('lf-pending') ?? [])][0]; if (!painted) return 'no mark'; @@ -2395,9 +2264,8 @@ def test_a_repeated_passage_at_an_edge_anchors_where_it_was_picked( const sel = getSelection(); sel.removeAllRanges(); sel.addRange(want); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(r => setTimeout(r, 60)); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); if (fab.style.display !== 'block') return 'no button'; - fab.click(); await new Promise(r => setTimeout(r, 60)); const painted = [...(CSS.highlights.get('lf-pending') ?? [])][0]; if (!painted) return 'no mark'; @@ -2499,8 +2367,8 @@ def test_a_passage_longer_than_the_pattern_is_anchored_whole(browser, serve): (box["x"] + box["width"] - 1, box["y"] + box["height"] - 4), steps=12, ) - expect(page.locator(".lf-fab")).to_be_visible() - page.keyboard.press("c") + expect(page.locator(".lf-fab-input")).to_be_visible() + expect(page.locator(".lf-fab-input")).to_be_focused() expect(page.locator(".lf-composer")).to_be_visible() # The mark under the open composer is the selection, both ends of it — and on the @@ -2525,7 +2393,7 @@ def test_a_passage_longer_than_the_pattern_is_anchored_whole(browser, serve): # And the anchor that posts says the same thing, since the mark is drawn from it. page.locator(".lf-composer textarea").fill("The whole of it.") - page.locator(".lf-composer button.primary").click() + page.keyboard.press("Enter") round_trip(page) expect(page.locator(".lf-thread")).to_have_count(1) expect(page.locator(".lf-thread .lf-quote")).not_to_have_class( @@ -2558,8 +2426,8 @@ def test_a_selection_of_the_whole_page_still_finds_its_passage(browser, serve): assert prose > 12000, f"the fixture holds {prose} characters, under the ceiling" page.keyboard.press("ControlOrMeta+a") - expect(page.locator(".lf-fab")).to_be_visible() - page.keyboard.press("c") + expect(page.locator(".lf-fab-input")).to_be_visible() + expect(page.locator(".lf-fab-input")).to_be_focused() expect(page.locator(".lf-composer")).to_be_visible() painted = page.evaluate( "() => [...(CSS.highlights.get('lf-pending') ?? [])]" @@ -2568,7 +2436,7 @@ def test_a_selection_of_the_whole_page_still_finds_its_passage(browser, serve): assert painted > 12000, f"the mark under the composer covers {painted} characters" page.locator(".lf-composer textarea").fill("All of it.") - page.locator(".lf-composer button.primary").click() + page.keyboard.press("Enter") round_trip(page) expect(page.locator(".lf-thread")).to_have_count(1) # The posted anchor resolves on the ordinary pass too, which is the one that would @@ -2599,9 +2467,8 @@ def test_one_neighbour_is_not_enough_to_identify_a_revised_comment(browser, serv const sel = getSelection(); sel.removeAllRanges(); sel.addRange(want); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(r => setTimeout(r, 40)); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); if (fab.style.display !== 'block') return 'no button'; - fab.click(); await new Promise(r => setTimeout(r, 40)); const box = document.querySelector('.lf-composer textarea'); box.value = 'does this hold?'; @@ -3300,9 +3167,8 @@ def test_a_diff_anchors_to_the_side_it_was_read_on(browser, serve): const sel = getSelection(); sel.removeAllRanges(); sel.addRange(want); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); await new Promise(r => setTimeout(r, 40)); - const fab = document.querySelector('.lf-fab'); + const fab = document.querySelector('.lf-fab-input'); if (fab.style.display !== 'block') return 'no button'; - fab.click(); await new Promise(r => setTimeout(r, 40)); const painted = [...(CSS.highlights.get('lf-pending') ?? [])][0]; if (!painted) return 'no mark'; diff --git a/tests/test_render_controls.py b/tests/test_render_controls.py index 7ac0746ae..5cd3c9d57 100644 --- a/tests/test_render_controls.py +++ b/tests/test_render_controls.py @@ -491,7 +491,7 @@ def assert_primary_reach(width): heading_box["y"] + heading_box["height"] / 2, ), ) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() expect(page.locator(".lf-composer")).to_be_visible() last.focus() before_news = last.evaluate("el => el.getBoundingClientRect().left") diff --git a/tests/test_render_drafts.py b/tests/test_render_drafts.py index a04b4d013..77b232ede 100644 --- a/tests/test_render_drafts.py +++ b/tests/test_render_drafts.py @@ -38,6 +38,7 @@ open_page, painted, panel_settled, + pending_text, refuse, resized, round_trip, @@ -66,8 +67,7 @@ def test_page_round_trip(browser, serve): page.evaluate("window.__leafJourneyDocument = 'held'") # Select the passage from the keyboard's path: a real Range, then the keyup - # the runtime watches for keyboard selections, then the c binding — which - # runs the same the fab's own click as the floating button's click. + # the runtime watches for keyboard selections. The immediate field takes focus. page.evaluate("""() => { const r = document.createRange(); r.selectNodeContents(document.getElementById('intro')); @@ -76,12 +76,12 @@ def test_page_round_trip(browser, serve): document.body.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); }""") page.wait_for_selector( - ".lf-fab", state="visible" + ".lf-fab-input", state="visible" ) # the selection raised the button - page.keyboard.press("c") + expect(page.locator(".lf-fab-input")).to_be_focused() page.wait_for_selector(".lf-composer", state="visible") page.locator(".lf-composer textarea").fill("Is 0041 idempotent?") - page.locator(".lf-composer").get_by_role("button", name="Comment").click() + page.keyboard.press("Enter") page.wait_for_selector(".lf-margin-thread") # The anchor pass painted the passage — a range in the highlight registry, not an # element, so there is no selector for it. @@ -795,13 +795,13 @@ def test_a_held_comment_send_leaves_a_later_reply_box_focused(browser, serve): comment still appears, but its arrival must not move focus into its new thread.""" page, errors = open_page(browser, serve(LONG_PAGE, comments=2)) page.locator("#p3").click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("The earlier comment in flight.") held = [] page.route("**/api/event", lambda route: held.append(route)) - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") _until(page, lambda traffic: traffic.sends == 1, "held the comment send") page.locator(".lf-threads-toggle").click() @@ -833,12 +833,12 @@ def test_a_comment_hidden_by_narrowing_opens_its_inline_reply(browser, serve): expect(page.locator(".lf-threads > .lf-thread")).to_have_count(1) page.locator("#p1").click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill( "This comment starts outside the filter." ) - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") round_trip(page) sent = next( @@ -867,10 +867,10 @@ def test_an_untouched_inline_reply_follows_but_an_emptied_draft_holds(browser, s page, errors = open_page(browser, live_url(serve(NOTED_PAGE))) resized(page, 1440, 900) page.locator("#p1").click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("Follow this discussion.") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") round_trip(page) sent = events_model.read_events(serve.page_dir)[-1] @@ -919,18 +919,20 @@ def test_a_held_comment_send_leaves_the_passage_picked_out_behind_it(browser, se as a 💬 that never came up for the passage picked out after a send.""" page, errors = open_page(browser, serve(NOTED_PAGE)) page.locator("#p1").click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("The first remark.") held = [] page.route("**/api/event", lambda route: held.append(route)) - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") _until(page, lambda traffic: traffic.sends == 1, "held the comment send") # The reader picks out their next passage while the first send is still in the wire. page.locator("#p2").click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() + expect(page.locator(".lf-fab-input")).to_be_visible() + expect(page.locator(".lf-fab-input")).to_be_focused() + expect(page.locator(".lf-fab-input")).to_have_value("") held[0].continue_() page.unroute("**/api/event") @@ -942,10 +944,10 @@ def test_a_held_comment_send_leaves_the_passage_picked_out_behind_it(browser, se # only a fresh decision repaints, so it stands wherever the last one left it — while # the key that comments on a selection reads the live one, and answers the general # box where there is none. - assert page.evaluate("() => getSelection().toString()").strip() == ( - "A short second passage." - ), "the send's landing collapsed the passage the reader had picked out" - page.keyboard.press("c") + assert pending_text(page) == "A short second passage.", ( + "the send's landing lost the passage the reader had picked out" + ) + expect(page.locator(".lf-fab-input")).to_be_focused() expect(page.locator(".lf-composer")).to_be_visible() assert composer_quote(page)["text"].strip("“”") == "A short second passage." assert errors == [] @@ -1684,7 +1686,7 @@ def test_a_held_selection_comment_preserves_a_newer_exact_draft(browser, serve): box = page.locator(".lf-composer textarea") held = [] page.route("**/api/event", lambda route: held.append(route)) - page.locator(".lf-composer").get_by_role("button", name="Comment").click() + page.keyboard.press("Enter") _until(page, lambda traffic: traffic.sends == 1, "held the selection comment") box.fill(newer) @@ -1761,7 +1763,7 @@ def test_a_composer_on_one_passage_is_one_box_in_every_tab(browser, serve, one_r sent = "The point is buried, and the paragraph after it repeats it." first.locator(".lf-composer textarea").fill(sent) - first.locator(".lf-composer").get_by_role("button", name="Comment").click() + first.keyboard.press("Enter") round_trip(first) expect(second.locator(".lf-composer")).to_be_hidden() said = [ diff --git a/tests/test_render_margin.py b/tests/test_render_margin.py index 56a06edde..ef821a4cf 100644 --- a/tests/test_render_margin.py +++ b/tests/test_render_margin.py @@ -757,12 +757,10 @@ def test_a_new_anchored_comment_opens_its_inline_thread( page.locator(".lf-threads-toggle").click() panel_settled(page) page.locator("#mounts-p").click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("Check the January failure mode.") - page.locator(".lf-composer").get_by_role( - "button", name="Comment", exact=True - ).click() + page.keyboard.press("Enter") round_trip(page) sent = events_model.read_events(serve.page_dir)[-1] diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index 9ce457a69..41225d9ad 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -413,9 +413,9 @@ def test_composer_marks_the_passage_instead_of_quoting_it(browser, serve): page.locator("#p").click( click_count=3 ) # a real selection, spanning the inline tags - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) passage = " ".join(page.locator("#p").inner_text().split()) @@ -467,7 +467,7 @@ def test_composer_marks_the_passage_instead_of_quoting_it(browser, serve): "a poll landing while the composer is open disturbed the passage" ) - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") assert pending_text(page) == "", "the highlight outlived its composer" # A passage with the runtime's own chrome inside it paints around the chrome, the way @@ -482,17 +482,17 @@ def test_composer_marks_the_passage_instead_of_quoting_it(browser, serve): const s = getSelection(); s.removeAllRanges(); s.addRange(r); document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); }""") - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function("() => CSS.highlights.get('lf-pending')") assert chrome not in pending_text(page), ( f"the highlight painted the widget's own {chrome!r} control along with the passage" ) - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") # A diagram has no text to quote, so its anchor is the element and its mark is an # outline. That one the anchor pass really does take down, so it has to be redrawn. page.locator("#fig svg").click() - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.locator("#fig.lf-mark-el.lf-pending").wait_for() assert not composer_quote(page)["shown"], ( "the outline is on the figure and the composer names its section as well" @@ -509,7 +509,7 @@ def test_composer_marks_the_passage_instead_of_quoting_it(browser, serve): # Both classes have to go, asserted apart: leaving .lf-mark-el behind repaints the # figure in the posted mark's own ink, pointer cursor and all, over no thread to open. - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") assert page.locator("#fig.lf-pending").count() == 0, ( "the outline outlived its composer" ) @@ -523,7 +523,7 @@ def test_composer_marks_the_passage_instead_of_quoting_it(browser, serve): cap = page.locator("#fig figcaption").bounding_box() y = cap["y"] + cap["height"] / 2 select(page, (cap["x"] + 2, y), (cap["x"] + cap["width"] - 2, y)) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function("() => CSS.highlights.get('lf-pending')") assert "specimen" in pending_text(page), ( "the click's visual find outranked the selection the drag made" @@ -531,7 +531,7 @@ def test_composer_marks_the_passage_instead_of_quoting_it(browser, serve): assert page.locator("#fig.lf-pending").count() == 0, ( "the figure got the element outline over a live selection" ) - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") assert errors == [] page.close() @@ -1024,22 +1024,22 @@ def comment(anchor, text): assert "comment" not in page.evaluate("() => getSelection().toString()"), ( "the hidden line came along in the user's own selection" ) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() assert "comment" not in composer_quote(page)["text"], ( "the hidden line came along in the quote the comment would store" ) - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") # The gesture's own comment reaches the line once the send's round trip lands. box = page.locator("#p2").bounding_box() y = box["y"] + box["height"] / 2 select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y)) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) page.locator(".lf-composer textarea").fill("Too short.") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") expect(page.locator("#p2 .lf-mark-note")).to_have_count(1) c4 = [e for e in events_model.read_events(d) if e.get("kind") == "comment"][-1][ "id" @@ -1994,7 +1994,7 @@ def test_the_reference_runs_available_commands_and_explains_the_rest(browser, se expect(help_el.locator(".lf-help-meta")).to_have_text( "Available on a focused thread" ) - search.fill("put the reaction down") + search.fill("close response choices") cancel_reaction = help_el.locator( '.lf-help-command[data-lf-command="reaction.cancel"]' ) @@ -2002,7 +2002,9 @@ def test_the_reference_runs_available_commands_and_explains_the_rest(browser, se expect(search).to_be_focused() expect(cancel_reaction).to_have_attribute("data-lf-selected", "true") page.keyboard.press("Enter") - expect(help_el.locator(".lf-help-meta")).to_have_text("Available with r armed") + expect(help_el.locator(".lf-help-meta")).to_have_text( + "Available with response choices open" + ) page.keyboard.press("Escape") page.keyboard.press("?") @@ -3849,62 +3851,32 @@ def test_a_label_press_keeps_the_controls_keyboard_standing(browser, serve): page.close() -def test_a_label_press_keeps_the_shortcut_hint_on_the_focused_box(browser, serve): - """Immediate focus readers share the label transaction with the painted ones.""" +def test_the_other_response_row_can_turn_the_compact_field_into_a_suggestion( + browser, serve +): + """A selected passage offers Suggest without reopening the retired composer card.""" page, errors = open_page(browser, serve(INLINE_PAGE)) page.locator("#p").click(click_count=3) - page.locator(".lf-fab").click() - box = page.locator(".lf-composer textarea") - label = page.locator(".lf-composer .lf-suggest-row") + box = page.locator(".lf-fab-input") expect(box).to_be_focused() - expect(label).to_be_visible() - expect(box).to_have_attribute("placeholder", re.compile(r"(⌘⏎|Ctrl\+⏎)$")) - hint = box.get_attribute("placeholder") - assert hint is not None + expect(box).to_have_attribute("placeholder", re.compile(r"^Comment… .*⏎$")) - bounds = label.bounding_box() - assert bounds is not None - page.mouse.move( - bounds["x"] + bounds["width"] - 2, bounds["y"] + bounds["height"] / 2 - ) - page.mouse.down() - page.evaluate(RENDERED) - assert box.get_attribute("placeholder") == hint - page.keyboard.type("x") - expect(box).to_have_value("x") - page.mouse.up() - expect(label.locator("input")).to_be_checked() - expect(box).to_have_attribute("placeholder", "Replacement text") - - box.focus() - expect(box).to_have_attribute("placeholder", re.compile(r"(⌘⏎|Ctrl\+⏎)$")) - replacement_hint = box.get_attribute("placeholder") - assert replacement_hint is not None - text_bounds = label.evaluate( - """label => { - const node = [...label.childNodes].find(node => node.nodeType === Node.TEXT_NODE); - const range = document.createRange(); - range.selectNodeContents(node); - const box = range.getBoundingClientRect(); - return {x: box.x, y: box.y, width: box.width, height: box.height}; - }""" - ) - hold_selection( - page, - (text_bounds["x"] + 2, text_bounds["y"] + text_bounds["height"] / 2), - ( - text_bounds["x"] + text_bounds["width"] - 2, - text_bounds["y"] + text_bounds["height"] / 2, - ), - steps=10, - ) - assert "Suggest replacement text" in page.evaluate( - "() => getSelection().toString()" + page.keyboard.press("Tab") + choices = page.locator(".lf-fab-bar") + suggest = choices.locator(".lf-fab-suggest") + expect(choices).to_be_visible() + expect(choices.locator(".lf-fab")).to_be_focused() + page.keyboard.press("Tab") + expect(suggest).to_be_focused() + + page.keyboard.press("Enter") + expect(box).to_be_focused() + expect(box).to_have_attribute("placeholder", re.compile(r"^Replacement text .*⏎$")) + expect(box).to_have_value( + re.compile("A paragraph carrying bold text and emphasis inside it") ) page.evaluate(RENDERED) - assert box.get_attribute("placeholder") == replacement_hint - page.mouse.up() - expect(box).to_have_attribute("placeholder", "Replacement text") + expect(box).to_have_attribute("placeholder", re.compile(r"^Replacement text .*⏎$")) assert errors == [] page.close() @@ -3935,18 +3907,10 @@ def test_focus_paint_releases_every_text_box_crossed_before_a_frame(browser, ser page.close() -def test_the_key_line_names_what_this_press_will_comment_on(browser, serve, other_leaf): - """A key's word is the meaning it has now, not one wide enough to cover every - meaning it could have. c opens a box on the selection, on the item a click raised the - 💬 on, or on whatever the reader is standing in — and with none of those in hand goes - to the threads — yet every one of them read "comment": true of the key and silent - about the press, so a reader with a paragraph selected and one with nothing selected - were told the same thing about two different destinations. Both surfaces read the row - where they paint it, so both say where this press goes; o is the same defect and says - show or hide rather than both. - - Three of the four here, this page holding nothing to stand on; - test_c_comments_on_what_the_reader_is_standing_in owns the fourth.""" +def test_the_key_line_names_the_immediate_comment_and_its_other_responses( + browser, serve +): + """Targeting enters Comment immediately; the key line names send and the Tab exit.""" page, errors = open_page(browser, serve(TARGETS_PAGE)) line = page.locator(".lf-keyline") help_el = page.locator(".lf-help") @@ -3958,11 +3922,8 @@ def test_the_key_line_names_what_this_press_will_comment_on(browser, serve, othe expect(help_el).to_contain_text("Go to the threads") page.keyboard.press("Escape") - # A selection under the hand moves the word, on the gesture that raises the button - # — the anchor the line names and the one the press takes are the same one. Dragged - # rather than select_text()'d, which sets the selection through the injected script - # and fires neither mouseup nor keyup: the button would never rise, and the press - # under test would be answered by a state no gesture produced. + # A real selection enters its field during the gesture. Once there, letters and `?` + # belong to the comment rather than falling through to page shortcuts. box = page.locator("#prose").bounding_box() select( page, @@ -3970,34 +3931,28 @@ def test_the_key_line_names_what_this_press_will_comment_on(browser, serve, othe (box["x"] + box["width"] - 1, box["y"] + box["height"] - 4), steps=12, ) - expect(page.locator(".lf-fab")).to_be_visible() - expect(line).to_contain_text("comment on the selection") - page.keyboard.press("?") - page.keyboard.press("?") - expect(help_el).to_contain_text("Comment on the selection") - page.keyboard.press("Escape") - # And the press does what the word said: a composer carrying that passage, which - # is what makes the suggestion row (a replacement for quoted words) offered at all. - page.keyboard.press("c") - expect(page.locator(".lf-composer")).to_be_visible() - expect(page.locator(".lf-composer .lf-suggest-row")).to_be_visible() + field = page.locator(".lf-fab-input") + expect(field).to_be_focused() + expect(line).to_contain_text("comment") + expect(line).to_contain_text("other responses") + page.keyboard.type("?") + expect(field).to_have_value("?") page.keyboard.press("Escape") - # A visual has no words to quote, so the press lands on the element — and the word - # is the item's own, the way the panel names one. + # A visual follows the same contract, but its accessible field name identifies the + # item rather than a quoted passage. page.locator("#fig svg").click() - expect(line).to_contain_text("comment on the figure") - page.keyboard.press("c") - expect(page.locator(".lf-composer")).to_be_visible() - expect(page.locator(".lf-composer .lf-suggest-row")).to_be_hidden() + expect(field).to_be_focused() + expect(field).to_have_attribute("aria-label", re.compile("figure")) + expect(line).to_contain_text("other responses") page.keyboard.press("Escape") assert errors == [] page.close() -def test_page_shortcuts_activate_the_visible_controls_through_click(browser, serve): - """A shortcut runs the control, including listeners it does not own.""" +def test_typing_in_the_immediate_comment_wins_over_page_shortcuts(browser, serve): + """Once targeting focuses Comment, a shortcut letter is ordinary comment text.""" page, errors = open_page(browser, serve(TARGETS_PAGE)) box = page.locator("#prose").bounding_box() @@ -4007,17 +3962,10 @@ def test_page_shortcuts_activate_the_visible_controls_through_click(browser, ser (box["x"] + box["width"] - 1, box["y"] + box["height"] - 4), steps=12, ) - fab = page.locator(".lf-fab") - expect(fab).to_be_visible() - fab.evaluate( - """control => control.addEventListener('click', () => { - control.dataset.shortcutClicks = - String(Number(control.dataset.shortcutClicks || 0) + 1); - })""" - ) + fab = page.locator(".lf-fab-input") + expect(fab).to_be_focused() page.keyboard.press("c") - expect(page.locator(".lf-composer")).to_be_visible() - expect(fab).to_have_attribute("data-shortcut-clicks", "1") + expect(fab).to_have_value("c") page.keyboard.press("Escape") page.evaluate("() => document.body.focus()") @@ -4052,8 +4000,8 @@ def test_page_shortcuts_activate_the_visible_controls_through_click(browser, ser page.close() -def test_submit_shortcuts_activate_the_visible_controls_through_click(browser, serve): - """Mod+Enter sends and saves through the button that promises the action.""" +def test_submit_shortcuts_activate_the_controls_that_promise_the_action(browser, serve): + """Anchored Enter sends; the other durable editors retain Mod+Enter.""" html = TARGETS_PAGE.replace( "", '
    Ship it.
    ' ) @@ -4066,17 +4014,18 @@ def test_submit_shortcuts_activate_the_visible_controls_through_click(browser, s (box["x"] + box["width"] - 1, box["y"] + box["height"] - 4), steps=12, ) - page.keyboard.press("c") composer = page.locator(".lf-composer") - send = composer.get_by_role("button", name="Comment", exact=True) + field = page.locator(".lf-fab-input") + expect(field).to_be_focused() + send = composer.locator(".lf-composer-row .primary") send.evaluate( """control => control.addEventListener('click', () => { document.body.dataset.composerShortcutClicks = String(Number(document.body.dataset.composerShortcutClicks || 0) + 1); })""" ) - composer.locator("textarea").fill("Send through the visible control.") - page.keyboard.press("ControlOrMeta+Enter") + field.fill("Send through the compact control.") + page.keyboard.press("Enter") expect(page.locator("body")).to_have_attribute("data-composer-shortcut-clicks", "1") expect(composer).to_be_hidden() @@ -4447,6 +4396,8 @@ def test_c_comments_on_what_the_reader_is_standing_in(browser, serve): line = page.locator(".lf-keyline") def drop(): + if page.locator(".lf-composer[data-lf-open]").count(): + page.keyboard.press("Escape") page.evaluate("() => document.activeElement?.blur()") # Standing nowhere in the page: no box is named, so the press is the room the boxes diff --git a/tests/test_render_options_settled.py b/tests/test_render_options_settled.py index 52e9ec628..1a0968552 100644 --- a/tests/test_render_options_settled.py +++ b/tests/test_render_options_settled.py @@ -115,11 +115,9 @@ def test_settled_options_collapse_without_going_out_of_reach(browser, serve): box = title.bounding_box() y = box["y"] + box["height"] / 2 select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y)) - assert page.evaluate("() => getSelection().toString()").strip() == ( - "Settled: Lax cookie" - ) + expect(page.locator(".lf-fab-input")).to_be_focused() + assert composer_quote(page)["text"].strip("“”") == "Settled: Lax cookie" expect(page.locator("#opt-strict")).to_be_hidden() - page.locator(".lf-fab").click() expect(page.locator(".lf-composer")).to_be_visible() assert composer_quote(page)["text"].strip("“”") == "Settled: Lax cookie" page.keyboard.press("Escape") @@ -138,10 +136,10 @@ def test_settled_options_collapse_without_going_out_of_reach(browser, serve): box = lede.bounding_box() y = box["y"] + box["height"] / 2 select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y)) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() expect(page.locator(".lf-composer")).to_be_visible() page.locator(".lf-composer textarea").fill("which copy is this on?") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") page.wait_for_function("() => (CSS.highlights.get('lf-mark')?.size ?? 0) >= 2") assert sorted( page.evaluate( diff --git a/tests/test_render_outbox.py b/tests/test_render_outbox.py index 0830382ab..e31613320 100644 --- a/tests/test_render_outbox.py +++ b/tests/test_render_outbox.py @@ -32,7 +32,6 @@ open_page, panel_settled, pending_text, - post_event, refuse, resized, round_trip, @@ -1981,7 +1980,7 @@ def test_the_composer_never_stands_on_its_own_mark(browser, serve): document.scrollingElement.scrollBy({top: r.top - 60, behavior: 'instant'}); }""") page.locator("#opt-strict").click(click_count=3) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("what did the trial actually show?") assert mark_shows_beside_composer(page), ( "the box covered the passage it just opened on" @@ -1989,7 +1988,7 @@ def test_the_composer_never_stands_on_its_own_mark(browser, serve): page.reload() page.wait_for_function( - "() => document.querySelector('.lf-composer').style.display === 'block'" + "() => document.querySelector('.lf-composer').style.display === 'contents'" ) page.wait_for_function("() => (CSS.highlights.get('lf-pending')?.size ?? 0) > 0") assert mark_shows_beside_composer(page), ( @@ -2003,11 +2002,10 @@ def test_the_composer_never_stands_on_its_own_mark(browser, serve): page.close() -def test_the_composer_scrolls_with_the_passage_it_is_about(browser, serve): - """The box points at a passage, so it lives in the document's coordinate space and - scrolling moves the two together. It was viewport-fixed once: the page scrolled - under a box that stayed put, and an ⌥-click's composer drifted off the diagram it - was opened on and sat over whatever arrived beneath it. +def test_the_comment_field_scrolls_with_the_passage_it_is_about(browser, serve): + """The field points at a passage, so it lives in the document's coordinate space and + scrolling moves the two together. A viewport-fixed field would let the page scroll + underneath until the response sat over something it was never about. Both readings and the scroll happen in one synchronous evaluate — writing scrollTop reflows before the very next read — so there is no trip here to wait @@ -2015,12 +2013,12 @@ def test_the_composer_scrolls_with_the_passage_it_is_about(browser, serve): page, errors = open_page(browser, serve(LONG_PAGE)) page.locator("#p30").scroll_into_view_if_needed() page.locator("#p30").click(click_count=3) - page.wait_for_selector(".lf-fab", state="visible") - page.locator(".lf-fab").click() + page.wait_for_selector(".lf-fab-input", state="visible") + page.locator(".lf-fab-input").click() expect(page.locator(".lf-composer")).to_be_visible() moved = page.evaluate("""() => { const top = (el) => el.getBoundingClientRect().top; - const composer = document.querySelector('.lf-composer'); + const composer = document.querySelector('.lf-fab-bar'); const passage = document.getElementById('p30'); const before = { composer: top(composer), passage: top(passage) }; document.scrollingElement.scrollTop += 240; @@ -2036,11 +2034,10 @@ def test_the_composer_scrolls_with_the_passage_it_is_about(browser, serve): page.close() -def test_the_composer_stands_in_the_margin_beside_the_passage(browser, serve): - """Where the column leaves room, the box goes into the margin rather than onto the - page: a 320px card over a 720px column stands on somebody's words wherever it - lands, and the margin holds none by construction. The passage and its neighbours - stay fully readable while the user writes about them. +def test_the_comment_field_stands_in_the_margin_beside_the_passage(browser, serve): + """Where the column leaves room, the field goes into the margin rather than onto + somebody's words. The passage and its neighbours stay fully readable while the + user writes about them. The window is wide enough for that room to be there wherever this runs. What the placement asks is whether the box and its two 8px gaps fit beside the column in @@ -2053,24 +2050,27 @@ def test_the_composer_stands_in_the_margin_beside_the_passage(browser, serve): resized(page, 1600, 900) page.locator("#p30").scroll_into_view_if_needed() page.locator("#p30").click(click_count=3) - page.wait_for_selector(".lf-fab", state="visible") - page.locator(".lf-fab").click() + page.wait_for_selector(".lf-fab-input", state="visible") + page.locator(".lf-fab-input").click() expect(page.locator(".lf-composer")).to_be_visible() standing = page.evaluate("""() => { - const box = document.querySelector('.lf-composer').getBoundingClientRect(); - const column = document.querySelector('main').getBoundingClientRect(); + const box = document.querySelector('.lf-fab-bar').getBoundingClientRect(); const touching = [...document.querySelectorAll('main p, main h1')] .filter(el => el.checkVisibility()) - .filter(el => { const r = el.getBoundingClientRect(); - return r.left < box.right && box.left < r.right - && r.top < box.bottom && box.top < r.bottom; }) + .filter(el => { + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(node); + if ([...range.getClientRects()].some(r => + r.left < box.right && box.left < r.right + && r.top < box.bottom && box.top < r.bottom)) return true; + } + return false; + }) .map(el => el.id || el.tagName); - return { left: box.left, columnRight: column.right, touching }; + return { touching }; }""") - assert standing["left"] >= standing["columnRight"], ( - f"the box opened at {standing['left']}px, inside the column ending at " - f"{standing['columnRight']}px, with a margin free to its right" - ) assert standing["touching"] == [], ( f"the box stands on the page's own text: {standing['touching']}" ) @@ -2078,53 +2078,49 @@ def test_the_composer_stands_in_the_margin_beside_the_passage(browser, serve): page.close() -def test_a_float_the_panel_displaces_hands_the_page_no_sideways_scroll(browser, serve): - """A float is an absolute child of body, so one standing past body's client box is - sideways-scrollable overflow. Placement clamps inside the box of that moment, and - the box then changes: the panel takes its strip, and a composer placed in a wide - window's margin overhung the narrowed page — the document panned 328px left under - a trackpad, with the composer standing on the panel that had displaced it. The - floats are placed again when layout reshapes, after the margin's own transition, - so the invariant is read with an auto-retrying wait rather than a one-shot read - racing the transitionend handler.""" +def test_opening_the_panel_stands_down_the_field_without_losing_its_draft( + browser, serve +): + """Opening the thread workspace stands the compact field down, so its old absolute + position cannot create sideways overflow after the page narrows. The words remain + the passage's draft and return when the reader selects that passage again.""" page, errors = open_page(browser, serve(LONG_PAGE)) - # The margin placement below is this test's precondition, so the window is the one - # its own test states the width for. + # Start with enough room for the field beside the passage; opening the panel then + # changes the body's available right edge around that standing float. resized(page, 1600, 900) page.locator("#p30").scroll_into_view_if_needed() page.locator("#p30").click(click_count=3) - page.wait_for_selector(".lf-fab", state="visible") - page.locator(".lf-fab").click() + page.wait_for_selector(".lf-fab-input", state="visible") + page.locator(".lf-fab-input").click() expect(page.locator(".lf-composer")).to_be_visible() page.locator(".lf-composer textarea").fill("held open across the panel opening") - assert page.evaluate( - "() => document.querySelector('.lf-composer').getBoundingClientRect().left" - " >= document.querySelector('main').getBoundingClientRect().right" - ), "the margin placement is the precondition — nothing strands a column-placed box" - # A press on the banner's own button: standDown keeps a composer holding text. + # A press on the banner's own button gives the workspace the screen and focus. page.locator(".lf-threads-toggle").click() panel_settled(page) - page.wait_for_function("""() => { - const box = document.querySelector('.lf-composer').getBoundingClientRect(); - return document.body.scrollWidth - document.body.clientWidth === 0 - && box.right <= document.body.clientWidth; - }""") - expect(page.locator(".lf-composer")).to_be_visible() + expect(page.locator(".lf-composer")).to_be_hidden() + page.wait_for_function( + "() => document.body.scrollWidth - document.body.clientWidth === 0" + ) + + page.get_by_role("button", name="Close threads").click() + page.locator("#p30").click(click_count=3) + expect(page.locator(".lf-fab-input")).to_be_focused() + expect(page.locator(".lf-fab-input")).to_have_value( + "held open across the panel opening" + ) assert errors == [] page.close() -def test_a_draft_that_outlives_its_passage_still_says_what_it_was_about(browser, serve): - """A draft survives the version it was written against — the user opens the new - one with unsent text — and the passage it was about may not have. The mark is what - normally says which passage the box is on, so where there is no passage left to mark - the quote is the only record there is, and it comes back: dashed and muted, the same - detached treatment the panel gives a thread this version dropped.""" +def test_a_draft_that_outlives_its_passage_returns_with_that_passage(browser, serve): + """A draft survives the version it was written against even when that version's + replacement removes its passage. With no detached composer card, the compact field + stands down on the new page and the words return when the original passage does.""" url = serve(INLINE_PAGE) page, errors = open_page(browser, url) page.locator("#p").click(click_count=3) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill( "half-written when the version turned over" ) @@ -2146,48 +2142,20 @@ def test_a_draft_that_outlives_its_passage_still_says_what_it_was_about(browser, page.get_by_role("button", name="New page available", exact=False).click() wait_for_revision(page, 2) expect(page).not_to_have_url(re.compile("/versions/")) - page.wait_for_function( - "() => document.querySelector('.lf-composer')?.style.display === 'block'" + expect(page.locator(".lf-composer")).to_be_hidden() + assert pending_text(page) == "", ( + "v2 rewrote the passage and the page marked it anyway" ) - assert page.locator(".lf-composer textarea").input_value() == ( + page.goto(url) + page.wait_for_selector("#p") + page.locator("#p").click(click_count=3) + expect(page.locator(".lf-fab-input")).to_be_focused() + expect(page.locator(".lf-fab-input")).to_have_value( "half-written when the version turned over" - ), "the draft didn't survive the version it was written against" - assert pending_text(page) == "", ( - "v2 rewrote the passage and the page marked it anyway" ) quote = composer_quote(page) - assert quote["shown"], ( - "nothing on screen says what the draft is about — no mark, and no quote either" - ) assert quote["text"] == f"“{passage}”", f"the quote says {quote['text']!r}" - assert page.locator(".lf-composer .lf-quote.detached").count() == 1, ( - "the stranded quote reads as one that still points somewhere" - ) - - # A stranded quote is the last copy of that passage anywhere on the page, so it is text - # a user selects to keep. The anchor pass reruns on every arriving comment, and a - # rewritten node takes the selection with it. - page.evaluate("""() => { - const q = document.getElementById('lf-composer-quote'); - const r = document.createRange(); - r.setStart(q.firstChild, 1); - r.setEnd(q.firstChild, 20); - const s = getSelection(); s.removeAllRanges(); s.addRange(r); - }""") - held = page.evaluate("() => getSelection().toString()") - assert len(held) == 19, ( - f"this assertion needs a selection to survive; it made {held!r}" - ) - post_event( - page, - url.rsplit("/versions/", 1)[0] + "/api/event", - data={"kind": "comment", "revision": 2, "text": "arriving from another tab"}, - ) - page.wait_for_function("() => document.querySelectorAll('.lf-thread').length === 1") - assert page.evaluate("() => getSelection().toString()") == held, ( - "the anchor pass rewrote the stranded quote and took the reader's selection with it" - ) assert errors == [] page.close() diff --git a/tests/test_render_pages.py b/tests/test_render_pages.py index bd7f69ca4..f5506c3e1 100644 --- a/tests/test_render_pages.py +++ b/tests/test_render_pages.py @@ -640,20 +640,21 @@ def test_a_widget_declaring_it_renders_a_picture_takes_a_click(browser, serve): # The inner svg is mermaid's, carrying a generated id; the anchor belongs to the # widget that holds it, which is the element the page gave a name. page.locator("#flow svg").click() - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.locator("#flow.lf-mark-el.lf-pending").wait_for() assert not composer_quote(page)["shown"], "a picture has no words to quote back" - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") page.locator("#tree").click() - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.locator("#tree.lf-mark-el.lf-pending").wait_for() - page.get_by_role("button", name="Cancel").click() + page.keyboard.press("Escape") # And a paragraph is still text: the click reaches no picture and raises nothing. page.locator("#p").click() expect( - page.locator(".lf-fab"), "a click on prose was read as a click on a picture" + page.locator(".lf-fab-input"), + "a click on prose was read as a click on a picture", ).not_to_be_visible() assert errors == [] page.close() diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py index 258ec91b7..2fa13f548 100644 --- a/tests/test_render_projection.py +++ b/tests/test_render_projection.py @@ -2160,13 +2160,13 @@ def test_a_pending_suggestion_can_be_discussed_instead_of_decided(browser, serve getSelection().addRange(r); document.body.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); }""") - page.wait_for_selector(".lf-fab", state="visible") - page.locator(".lf-fab").click() + page.wait_for_selector(".lf-fab-input", state="visible") + page.locator(".lf-fab-input").click() page.wait_for_selector(".lf-composer", state="visible") quoted = composer_quote(page)["text"] assert quoted.strip("“”") == "Refill a feeder when its camera shows it half-empty." page.locator(".lf-composer textarea").fill("Half-empty by whose reading?") - page.locator(".lf-composer").get_by_role("button", name="Comment").click() + page.keyboard.press("Enter") inline = page.locator(".lf-margin-thread") expect(inline.locator(".lf-conversation-body")).to_have_text( @@ -4060,9 +4060,9 @@ def test_command_hub_repaints_anchors_after_generated_projections_change( document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true})); }""" ) - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("Keep this branch evidence visible.") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") round_trip(page) sent = CliRunner().invoke( cli_model.cli, diff --git a/tests/test_render_reactions.py b/tests/test_render_reactions.py index 900d53808..2fdb4d1db 100644 --- a/tests/test_render_reactions.py +++ b/tests/test_render_reactions.py @@ -8,6 +8,7 @@ from leaf import event_log as events_model from leaf import exporting as exporting_model from playwright.sync_api import expect +from render_cases_navigation import pending_text from render_harness import leaf_page from render_support import ( PANEL_PAGE, @@ -111,23 +112,30 @@ def test_a_token_press_marks_the_passage_and_a_second_press_takes_it_back( "() => [...document.querySelectorAll('.lf-fab-bar .lf-react')].map(p => p.dataset.token)" ) assert tokens == ["ok", "no", "lost", "cut", "more", "this"], tokens - assert bar.locator( - ":scope > .lf-fab, :scope > .lf-react-trigger" - ).all_inner_texts() == [ - "💬", - "…", - ] - expect(bar.locator(".lf-fab")).to_have_attribute("aria-label", "Comment") + expect(bar.locator(".lf-fab-input")).to_be_visible() + expect(bar.locator(".lf-fab-input")).to_have_attribute( + "placeholder", re.compile(r"^Comment… .*⏎$") + ) + expect(bar.locator(".lf-fab-input")).to_have_attribute("autocomplete", "off") + expect(bar.locator(".lf-fab-input")).to_have_attribute( + "aria-label", re.compile(r"^Comment") + ) + expect( + bar.locator(":scope > .lf-react-trigger .lf-response-action-glyph") + ).to_have_text("…") expect(bar.locator(".lf-react-trigger")).to_have_attribute( - "aria-label", "Show reactions" + "aria-label", "Show other responses" ) expect(bar.locator(".lf-react:visible")).to_have_count(0) - bar.locator(".lf-react-trigger").click() - surface = page.locator(".lf-margin-reactions") + expect(bar.locator(".lf-fab-input")).to_be_focused() + page.keyboard.press("Tab") + surface = bar + expect(bar).to_be_visible() + expect(bar.locator(".lf-fab-input")).to_be_hidden() expect(surface).to_have_class(re.compile("lf-react-open")) expect(surface.locator(".lf-react-trigger:visible")).to_have_count(0) expect(surface.locator(".lf-react:visible")).to_have_count(6) - expect(surface.locator('.lf-react[data-token="ok"]')).to_be_focused() + expect(surface.locator(".lf-fab")).to_be_focused() surface.locator('.lf-react[data-token="cut"]').click() round_trip(page) @@ -182,10 +190,10 @@ def test_a_token_press_marks_the_passage_and_a_second_press_takes_it_back( select_paragraph(page, "#how-store") expect(bar).to_be_visible() bar.locator(".lf-react-trigger").click() - surface = page.locator(".lf-margin-reactions") + surface = bar expect(receipt_item).to_have_count(1) expect(receipt_item.locator(":scope > .lf-reacts")).to_have_count(1) - expect(receipt_item.locator(":scope > .lf-margin-reactions")).to_have_count(1) + expect(receipt_item.locator(":scope > .lf-margin-reactions")).to_have_count(0) expect(surface.locator('.lf-react[data-token="cut"]')).to_have_attribute( "aria-pressed", "true" ) @@ -203,26 +211,113 @@ def test_a_token_press_marks_the_passage_and_a_second_press_takes_it_back( page.close() -def test_r_extends_the_targets_margin_item_and_needs_a_target(browser, serve): - """`r` adds Comment and reaction buttons to the target's one RHS item. +def test_tab_changes_the_compact_bar_in_place_and_r_still_needs_a_target( + browser, serve +): + """Tab yields the compact field to one visually and semantically stable choice bar. - Digits remain optional accelerators in declaration order. With no selection, - focused item, or agent reply, the key names the missing target and does not borrow - the page-wide strip by opening Threads. + Comment is the first stop, then Tab and arrows wrap through every visible response. + Digits remain optional accelerators in declaration order. Once the surface has been + dismissed, `r` with no target names what is missing without opening Threads. """ page, errors = open_page(browser, serve(PANEL_PAGE)) select_paragraph(page, "#how-cap") bar = page.locator(".lf-fab-bar") expect(bar).to_be_visible() - page.keyboard.press("r") + field_reading = bar.locator(".lf-fab-input").evaluate( + """el => { const box = el.getBoundingClientRect(); const bar = el.closest('.lf-fab-bar'); + const style = getComputedStyle(el); + return { + center: Math.round(box.y + box.height / 2), parent: bar.parentElement.className, + label: bar.getAttribute('aria-label'), fontFamily: style.fontFamily, + fontSize: style.fontSize, fontWeight: style.fontWeight, + height: Math.round(box.height), borderRadius: style.borderRadius, + borderTopWidth: style.borderTopWidth, borderTopStyle: style.borderTopStyle, + backgroundColor: style.backgroundColor, paddingTop: style.paddingTop, + paddingBottom: style.paddingBottom, borderColor: style.borderTopColor, + outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, + boxShadow: style.boxShadow}; }""" + ) + trigger_shape = bar.locator(":scope > .lf-react-trigger").evaluate( + """el => { const box = el.getBoundingClientRect(); const style = getComputedStyle(el); + return [Math.round(box.height), style.borderRadius, style.fontFamily, + style.fontSize, style.fontWeight, style.borderTopWidth, + style.borderTopStyle]; }""" + ) + page.keyboard.press("Tab") line = key_line(page) assert "1–6" in line and "react" in line, line - surface = page.locator(".lf-margin-reactions") + surface = bar expect(surface).to_have_class(re.compile("lf-react-open")) expect(surface.locator(".lf-react-trigger:visible")).to_have_count(0) expect(surface.locator(".lf-react:visible")).to_have_count(6) - expect(bar).to_be_hidden() + expect(bar).to_be_visible() + expect(bar.locator(".lf-fab-input")).to_be_hidden() + expect(page.locator(".lf-margin-reactions")).to_have_count(0) expect(surface.locator(".lf-fab")).to_be_visible() + choices = surface.locator( + ":scope > .lf-response-action:visible, :scope > .lf-react-palette > .lf-react:visible" + ) + expect(choices).to_have_count(8) + assert ( + choices.evaluate_all( + """els => els.map(el => { const box = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return [Math.round(box.height), style.borderRadius, style.fontFamily, + style.fontSize, style.fontWeight, style.borderTopWidth, + style.borderTopStyle]; })""" + ) + == [trigger_shape] * 8 + ) + stable = surface.evaluate( + """el => { const controls = [...el.querySelectorAll('.lf-response-action')] + .filter(control => control.checkVisibility()); + return {parent: el.parentElement.className, label: el.getAttribute('aria-label'), + centers: [...new Set(controls.map(control => { const box = control.getBoundingClientRect(); + return Math.round(box.y + box.height / 2); }))], + heights: [...new Set(controls.map(control => Math.round(control.getBoundingClientRect().height)))], + radii: [...new Set(controls.map(control => getComputedStyle(control).borderRadius))], + backgrounds: [...new Set(controls.map(control => getComputedStyle(control).backgroundColor))], + weights: [...new Set(controls.map(control => getComputedStyle(control).fontWeight))], + families: [...new Set(controls.map(control => getComputedStyle(control).fontFamily))], + sizes: [...new Set(controls.map(control => getComputedStyle(control).fontSize))], + paddingTops: [...new Set(controls.map(control => getComputedStyle(control).paddingTop))], + paddingBottoms: [...new Set(controls.map(control => getComputedStyle(control).paddingBottom))]}; }""" + ) + assert stable == { + "parent": field_reading["parent"], + "label": field_reading["label"], + "centers": [field_reading["center"]], + "heights": [field_reading["height"]], + "radii": [field_reading["borderRadius"]], + "backgrounds": [field_reading["backgroundColor"]], + "weights": [field_reading["fontWeight"]], + "families": [field_reading["fontFamily"]], + "sizes": [field_reading["fontSize"]], + "paddingTops": [field_reading["paddingTop"]], + "paddingBottoms": [field_reading["paddingBottom"]], + }, (field_reading, stable) + focused_reading = surface.locator(".lf-fab").evaluate( + """el => { const style = getComputedStyle(el); return { + borderColor: style.borderTopColor, outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, boxShadow: style.boxShadow}; }""" + ) + assert focused_reading == { + key: field_reading[key] + for key in ("borderColor", "outlineStyle", "outlineWidth", "boxShadow") + }, (field_reading, focused_reading) + expect(surface.locator(".lf-fab")).to_be_focused() + page.keyboard.press("Tab") + expect(surface.locator(".lf-fab-suggest")).to_be_focused() + page.keyboard.press("Shift+Tab") + expect(surface.locator(".lf-fab")).to_be_focused() + page.keyboard.press("ArrowLeft") + expect(surface.locator('[aria-label="this"]')).to_be_focused() + page.keyboard.press("ArrowRight") + expect(surface.locator(".lf-fab")).to_be_focused() + for label in ["Suggest", "ok", "no", "lost", "cut", "more", "this", "Comment"]: + page.keyboard.press("ArrowRight") + expect(surface.locator(f'[aria-label="{label}"]')).to_be_focused() assert ( surface.locator(".lf-fab, .lf-react:visible").evaluate_all( """els => new Set(els.map(el => { @@ -305,9 +400,10 @@ def test_an_item_hint_raises_the_bar_and_a_token_outlines_the_item(browser, serv page.keyboard.type(code) bar = page.locator(".lf-fab-bar") expect(bar).to_be_visible() - expect(page.locator(".lf-composer")).to_be_hidden() + expect(page.locator(".lf-composer")).to_be_visible() + expect(bar.locator(".lf-fab-input")).to_be_focused() bar.locator(".lf-react-trigger").click() - page.locator('.lf-margin-reactions .lf-react[data-token="this"]').click() + bar.locator('.lf-react[data-token="this"]').click() round_trip(page) sent = events_model.read_events(serve.page_dir)[-1] assert sent["token"] == "this" and sent["anchor"] == {"section": "prose"} @@ -318,10 +414,8 @@ def test_an_item_hint_raises_the_bar_and_a_token_outlines_the_item(browser, serv @pytest.mark.parametrize("opener", ["click", "keyboard"]) -def test_the_reaction_list_joins_a_docked_margin_item_on_a_narrow_screen( - browser, serve, opener -): - """With no RHS, the same item docks in flow instead of opening a floating box.""" +def test_the_in_place_response_bar_stays_inside_a_narrow_screen(browser, serve, opener): + """Changing the compact bar's contents does not create a second margin surface.""" page, errors = open_page(browser, serve(PANEL_PAGE)) resized(page, 390, 900) select_paragraph(page, "#how-cap") @@ -330,20 +424,21 @@ def test_the_reaction_list_joins_a_docked_margin_item_on_a_narrow_screen( if opener == "click": bar.locator(".lf-react-trigger").click() else: - page.keyboard.press("r") - item = page.locator(".lf-margin-item").filter( - has=page.locator(".lf-margin-reactions") - ) - expect(item).to_have_class(re.compile("lf-docked")) - palette = item.locator(".lf-react-palette").bounding_box() - assert 8 <= palette["x"] and palette["x"] + palette["width"] <= 382, palette - assert item.locator(".lf-react-palette").evaluate( - "el => getComputedStyle(el).position !== 'absolute'" + page.keyboard.press("Tab") + expect(bar).to_have_class(re.compile("lf-react-open")) + expect(page.locator(".lf-margin-reactions")).to_have_count(0) + bounds = bar.bounding_box() + banner = page.locator(".lf-banner").bounding_box() + assert bounds and 8 <= bounds["x"] and bounds["x"] + bounds["width"] <= 382, bounds + assert banner and bounds["y"] >= banner["y"] + banner["height"] + 6, ( + banner, + bounds, ) - + page.keyboard.press("Escape") + expect(bar.locator(".lf-react-trigger")).to_be_visible() + expect(bar.locator(".lf-fab-input")).to_be_focused() resized(page, 1280, 900) expect(page.locator(".lf-margin-reactions")).to_have_count(0) - expect(bar.locator(".lf-react-trigger")).to_be_visible() assert errors == [] page.close() @@ -358,9 +453,12 @@ def test_a_reaction_on_a_visual_part_names_and_outlines_only_that_part(browser, start.click() expect(page.locator(".lf-fab-bar")).to_be_visible() - page.keyboard.press("r") + page.keyboard.press("Tab") + surface = page.locator(".lf-fab-bar") + expect(surface.locator(".lf-fab-suggest")).to_be_hidden() + expect(surface.locator(".lf-fab")).to_be_focused() page.keyboard.press("ArrowLeft") - reaction = page.locator('.lf-margin-reactions .lf-react[data-token="this"]') + reaction = page.locator('.lf-fab-bar .lf-react[data-token="this"]') expect(reaction).to_be_focused() page.evaluate("() => window.lfTestReactionClicked = false") reaction.evaluate( @@ -424,7 +522,7 @@ def test_a_visual_target_places_the_bar_from_the_target_and_keeps_it_through_ref page.mouse.click(box["x"] + 4, box["y"] + box["height"] / 2) assert errors == [] expect(bar).to_be_visible() - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).to_have_class(re.compile(r"\blf-pending\b")) first = bar.bounding_box() page.mouse.click( @@ -453,8 +551,8 @@ def test_a_visual_target_places_the_bar_from_the_target_and_keeps_it_through_ref ) page.locator('#flow g[id*="flowchart-U-"]').click() - expect(page.locator("#flow")).to_have_class(re.compile(r"\blf-action-target\b")) - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + expect(page.locator("#flow")).to_have_class(re.compile(r"\blf-pending\b")) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) whole = bar.bounding_box() assert ( abs(whole["x"] - after_reactivation["x"]) > 1 @@ -462,12 +560,12 @@ def test_a_visual_target_places_the_bar_from_the_target_and_keeps_it_through_ref ), (after_reactivation, whole) start.click() - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) - expect(page.locator("#flow")).not_to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).to_have_class(re.compile(r"\blf-pending\b")) + expect(page.locator("#flow")).not_to_have_class(re.compile(r"\blf-pending\b")) page.locator("h1").click() expect(bar).to_be_hidden() - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) assert errors == [] page.close() @@ -486,10 +584,8 @@ def test_a_declared_visual_keeps_its_parts_inside_a_generic_figure(browser, serv start = page.locator('#flow g[id*="flowchart-S-"]') page.locator("#caption").click() - expect(page.locator("#flow")).to_have_class(re.compile(r"\blf-action-target\b")) - expect(page.locator("#frame")).not_to_have_class( - re.compile(r"\blf-action-target\b") - ) + expect(page.locator("#flow")).to_have_class(re.compile(r"\blf-pending\b")) + expect(page.locator("#frame")).not_to_have_class(re.compile(r"\blf-pending\b")) assert page.locator(".lf-visual-action").evaluate_all( "controls => controls.map(control => control.lfAnchor)" ) == [ @@ -498,7 +594,7 @@ def test_a_declared_visual_keeps_its_parts_inside_a_generic_figure(browser, serv ] start.click() - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).to_have_class(re.compile(r"\blf-pending\b")) expect(page.locator(".lf-fab-bar")).to_have_attribute( "aria-label", re.compile("Start request") ) @@ -580,7 +676,7 @@ def test_a_declared_visual_part_can_raise_the_same_bar_from_the_keyboard( whole_control.focus() expect(page.locator("#flow")).to_be_in_viewport() page.keyboard.press("Enter") - expect(page.locator("#flow")).to_have_class(re.compile(r"\blf-action-target\b")) + expect(page.locator("#flow")).to_have_class(re.compile(r"\blf-pending\b")) assert page.evaluate("() => getSelection().toString().trim()") == "" whole_bar = page.locator(".lf-fab-bar").bounding_box() keyline = page.locator(".lf-keyline").bounding_box() @@ -596,20 +692,18 @@ def test_a_declared_visual_part_can_raise_the_same_bar_from_the_keyboard( control.focus() page.keyboard.press("Enter") expect(page.locator(".lf-fab-bar")).to_be_visible() - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).to_have_class(re.compile(r"\blf-pending\b")) assert page.evaluate("() => getSelection().toString().trim()") == "" - assert page.evaluate( - "() => document.querySelector('.lf-fab-bar').contains(document.activeElement)" - ) - # The captured target spends the short line's two contextual slots on its actions; - # Escape still clears it below and remains in the complete reference. + expect(page.locator(".lf-fab-input")).to_be_focused() + # The captured target spends the short line's two contextual slots on immediate + # comment entry and the route to its other responses. line = key_line(page) - assert "comment on the diagram" in line and "react" in line + assert "comment" in line and "other responses" in line assert "unselect" not in line page.keyboard.press("Escape") expect(page.locator(".lf-fab-bar")).to_be_hidden() - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) expect(control).to_be_focused() assert errors == [] page.close() @@ -765,9 +859,7 @@ def test_a_visual_action_follows_its_own_scroller_until_the_target_is_gone( control = page.get_by_role("button", name="Respond to Start request") control.focus() page.keyboard.press("Enter") - assert page.evaluate( - "() => document.querySelector('.lf-fab-bar').contains(document.activeElement)" - ) + expect(page.locator(".lf-fab-input")).to_be_focused() before_target = start.bounding_box() before_bar = bar.bounding_box() moved = diagram.evaluate( @@ -798,7 +890,7 @@ def test_a_visual_action_follows_its_own_scroller_until_the_target_is_gone( diagram.evaluate("element => { element.scrollLeft = element.scrollWidth; }") expect(bar).to_be_hidden() - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) assert page.evaluate("() => document.activeElement === document.body") assert errors == [] page.close() @@ -821,8 +913,8 @@ def test_dragging_a_diagram_label_keeps_the_passage_instead_of_clicking_the_node ) expect(page.locator(".lf-fab-bar")).to_be_visible() - assert "Start request" in page.evaluate("() => getSelection().toString()") - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + assert "Start request" in pending_text(page) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) expect(page.locator(".lf-fab-bar")).to_have_attribute( "aria-label", re.compile("Start request") ) @@ -835,17 +927,19 @@ def test_dragging_a_diagram_label_keeps_the_passage_instead_of_clicking_the_node (box["x"] + box["width"] - 2, box["y"] + box["height"] / 2), steps=12, ) - # The compatibility click restores the preserved range in its queued completion. - page.wait_for_function("() => getSelection().toString().includes('Start request')") - repeated = page.evaluate("() => getSelection().toString()") - assert "Start request" in repeated - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + # Focusing the composer collapses the native range, but the durable pending paint + # still names the repeated passage captured by the compatibility click. + page.wait_for_function( + "() => [...(CSS.highlights.get('lf-pending') ?? [])].some(r => r.toString().includes('Start request'))" + ) + assert "Start request" in pending_text(page) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) # A plain click is not a drag. It can still choose the visual under the retained # passage, and that explicit target clears the native selection. start.click() assert page.evaluate("() => getSelection().toString()") == "" - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).to_have_class(re.compile(r"\blf-pending\b")) assert errors == [] page.close() @@ -857,12 +951,8 @@ def test_a_keyboard_reaction_returns_focus_to_the_visual_target(browser, serve): control = page.get_by_role("button", name="Respond to Start request") control.focus() page.keyboard.press("Enter") - assert page.evaluate( - "() => document.querySelector('.lf-fab-bar').contains(document.activeElement)" - ) - - expect(page.locator(".lf-fab-bar .lf-fab")).to_be_focused() - page.keyboard.press("r") + expect(page.locator(".lf-fab-input")).to_be_focused() + page.keyboard.press("Tab") page.keyboard.press("1") round_trip(page) expect(page.locator(".lf-fab-bar")).to_be_hidden() @@ -880,10 +970,11 @@ def test_a_selection_change_replaces_and_clears_a_visual_target(browser, serve): start = page.locator('#flow g[id*="flowchart-S-"]') control.focus() page.keyboard.press("Enter") - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).to_have_class(re.compile(r"\blf-pending\b")) page.evaluate( """() => { + document.activeElement.blur(); const text = document.querySelector('h1').firstChild; const range = document.createRange(); range.selectNodeContents(text); @@ -893,9 +984,11 @@ def test_a_selection_change_replaces_and_clears_a_visual_target(browser, serve): }""" ) expect(page.locator(".lf-fab-bar")).to_be_visible() - expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) + expect(start).not_to_have_class(re.compile(r"\blf-pending\b")) - page.evaluate("() => getSelection().removeAllRanges()") + page.evaluate( + "() => { document.activeElement.blur(); getSelection().removeAllRanges(); }" + ) expect(page.locator(".lf-fab-bar")).to_be_hidden() assert errors == [] page.close() diff --git a/tests/test_render_semantic_selection.py b/tests/test_render_semantic_selection.py index 8bb895011..42f35f574 100644 --- a/tests/test_render_semantic_selection.py +++ b/tests/test_render_semantic_selection.py @@ -21,8 +21,7 @@ def test_s_aims_at_the_item_named_by_its_hint(browser, serve): """The keyboard target is the same stable item Alt-click would take. Choosing the - paragraph raises its item bar without making a native text selection; `c` then opens - an element-anchored composer.""" + paragraph focuses its in-place Comment field without making a native selection.""" page, errors = open_page(browser, serve(TARGETS_PAGE)) page.keyboard.press("s") @@ -66,62 +65,40 @@ def test_s_aims_at_the_item_named_by_its_hint(browser, serve): "Selected paragraph: A paragraph with enough words" ) expect(hints).to_have_count(0) - expect(page.locator(".lf-fab")).to_be_visible() + field = page.locator(".lf-fab-input") + expect(field).to_be_focused() shown = page.locator(".lf-keyline .lf-key:not([hidden])") - expect(shown).to_have_count(3) - expect(shown.nth(0).locator("kbd")).to_have_text("c") - expect(shown.nth(0)).to_contain_text("comment on the paragraph") - expect(shown.nth(1).locator("kbd")).to_have_text("r") - expect(shown.nth(1)).to_contain_text("react") - expect(shown.nth(2).locator("kbd")).to_have_text("d / u") - expect(shown.nth(2)).to_contain_text("page down / up") - - # Hiding s from the compact projection must not disable it. It can immediately - # reopen the chooser to replace the captured item, and cancelling leaves the - # prior target in hand. + expect(shown).to_have_count(2) + expect(shown.nth(0).locator("kbd")).to_have_text("⏎") + expect(shown.nth(0)).to_contain_text("comment") + expect(shown.nth(1).locator("kbd")).to_have_text("⇥") + expect(shown.nth(1)).to_contain_text("other responses") + + # Text entry owns letters. Tab deliberately yields the focused composer to the + # existing reaction palette; Escape from that palette restores the same draft. page.keyboard.press("s") - expect(hints).to_have_count(3) - page.keyboard.press("Escape") - assert page.evaluate("() => getSelection().toString()") == "" - expect(page.locator(".lf-fab")).to_be_visible() - expect(shown.nth(0).locator("kbd")).to_have_text("c") - expect(shown.nth(1).locator("kbd")).to_have_text("r") - - # Both help layers leave the captured item in hand. The first Escape returns - # from the reference to the shelf and the second folds the shelf; only the third - # reaches the item's own way out. - page.keyboard.press("?") - expect(page.locator(".lf-keyline")).to_have_attribute("data-lf-expanded", "true") - expect(page.locator(".lf-fab")).to_be_visible() - page.keyboard.press("?") - expect(page.locator(".lf-help")).to_be_visible() + expect(field).to_have_value("s") + field.fill("") + page.keyboard.press("Tab") + expect(page.locator(".lf-margin-reactions")).to_have_class( + re.compile(r"\blf-react-open\b") + ) expect( - page.locator(".lf-help tr", has_text="Select a visible item by hint") - ).to_be_visible() - page.keyboard.press("Escape") - expect(page.locator(".lf-help")).to_be_hidden() - expect(page.locator(".lf-fab")).to_be_visible() + page.locator('.lf-margin-reactions .lf-react[data-token="ok"]') + ).to_be_focused() + expect(field).to_be_hidden() page.keyboard.press("Escape") - expect(page.locator(".lf-keyline")).to_have_attribute("data-lf-expanded", "false") - expect(page.locator(".lf-fab")).to_be_visible() - page.keyboard.press("Escape") - assert page.evaluate("() => getSelection().toString()") == "" - expect(page.locator(".lf-fab")).to_be_hidden() - - page.keyboard.press("s") - page.keyboard.type(prose_code) - page.keyboard.press("c") - expect(page.locator(".lf-composer")).to_be_visible() + expect(field).to_be_focused() assert page.evaluate(DRAFT_MARK) == "prose" assert pending_text(page) == "" + page.keyboard.press("Escape") + expect(field).to_be_hidden() assert errors == [] page.close() def test_a_selected_target_keeps_escape_when_the_layer_has_no_reactions(browser, serve): - """A layer may remove the complete reaction vocabulary. Then c is the only action - on the captured target, so the second contextual slot keeps its ordinary way out and - the persistent page movement row remains beside both.""" + """Without reactions, the focused composer offers no dead Tab route.""" registry = json.loads( (ROOT / "skills/leaf/packages/default/registry.json").read_text() ) @@ -137,17 +114,42 @@ def test_a_selected_target_keeps_escape_when_the_layer_has_no_reactions(browser, bar = page.locator(".lf-fab-bar") expect(bar).to_be_visible() expect(bar).to_have_attribute("aria-label", re.compile(r"^Respond to ")) - expect(page.locator(".lf-live")).to_contain_text("Choose a response.") + expect(page.locator(".lf-fab-input")).to_be_focused() shown = page.locator(".lf-keyline .lf-key:not([hidden])") - expect(shown).to_have_count(3) - expect(shown.nth(0).locator("kbd")).to_have_text("c") - expect(shown.nth(0)).to_contain_text("comment on the heading") - expect(shown.nth(1).locator("kbd")).to_have_text("esc") - expect(shown.nth(1)).to_contain_text("unselect") - expect(shown.nth(2).locator("kbd")).to_have_text("d / u") + expect(shown).to_have_count(2) + expect(page.locator(".lf-fab-input")).to_have_attribute( + "aria-keyshortcuts", "Enter" + ) page.keyboard.press("Escape") - expect(page.locator(".lf-fab")).to_be_hidden() + expect(page.locator(".lf-fab-input")).to_be_hidden() + assert errors == [] + page.close() + + +def test_a_passage_still_offers_suggest_when_the_layer_has_no_reactions(browser, serve): + """Tab means other responses rather than reactions specifically: removing the + reaction vocabulary must not strand Suggest for a selected passage.""" + registry = json.loads( + (ROOT / "skills/leaf/packages/default/registry.json").read_text() + ) + tokens = {name: None for name in registry["$reactions"]["tokens"]} + page, errors = open_page( + browser, + serve(TARGETS_PAGE, layer_registry={"$reactions": {"tokens": tokens}}), + ) + + prose = page.locator("#prose") + prose.select_text() + field = page.locator(".lf-fab-input") + expect(field).to_be_focused() + expect(page.locator(".lf-fab-bar .lf-react")).to_have_count(0) + page.keyboard.press("Tab") + + responses = page.locator(".lf-margin-reactions") + expect(responses).to_have_class(re.compile(r"\blf-react-open\b")) + expect(responses.locator(".lf-fab-suggest")).to_be_focused() + expect(responses.locator(".lf-react")).to_have_count(0) assert errors == [] page.close() @@ -189,8 +191,8 @@ def test_dense_selection_hints_stay_short_and_reach_an_atomic_visual(browser, se expect(hints).to_have_count(sum(code.startswith(last[0]) for code in codes)) page.keyboard.press(last[1]) expect(hints).to_have_count(0) - expect(page.locator(".lf-fab")).to_be_visible() - expect(page.locator(".lf-keyline")).to_contain_text("comment on the figure") + expect(page.locator(".lf-fab-input")).to_be_focused() + expect(page.locator(".lf-keyline")).to_contain_text("comment") geometry = page.evaluate( """() => { const figure = document.querySelector('#visual-59').getBoundingClientRect(); @@ -201,11 +203,11 @@ def test_dense_selection_hints_stay_short_and_reach_an_atomic_visual(browser, se assert abs(geometry["barTop"] - geometry["figureTop"]) < 100, geometry page.keyboard.press("Escape") - expect(page.locator(".lf-fab")).to_be_hidden() + expect(page.locator(".lf-fab-input")).to_be_hidden() page.keyboard.press("s") page.keyboard.type(last) - page.keyboard.press("c") + expect(page.locator(".lf-fab-input")).to_be_focused() expect(page.locator(".lf-composer")).to_be_visible() expect(page.locator(".lf-composer .lf-suggest-row")).to_be_hidden() assert errors == [] @@ -292,8 +294,7 @@ def test_selection_hints_name_only_items_shown_by_a_disclosure(browser, serve): def test_s_raises_the_same_action_bar_on_a_declared_visual_part(browser, serve): """A declared picture part outranks its enclosing item without changing what aim - means. Choosing its hint raises the shared action bar; Comment then opens the - part-anchored composer.""" + means. Choosing its hint focuses the part-anchored composer.""" page, errors = open_page(browser, serve(PART_DIAGRAM_PAGE)) page.keyboard.press("s") expect(page.locator(".lf-target-hint")).to_have_count(4) @@ -313,15 +314,9 @@ def test_s_raises_the_same_action_bar_on_a_declared_visual_part(browser, serve): page.keyboard.type(start_code) expect(page.locator(".lf-fab-bar")).to_be_visible() - expect(page.locator(".lf-composer")).to_be_hidden() - expect(page.locator(".lf-live")).to_contain_text( - "Selected diagram: Start request. Choose a response." - ) + expect(page.locator(".lf-fab-input")).to_be_focused() start = page.locator('#flow g[id*="flowchart-S-"]') - expect(start).to_have_class(re.compile(r"\blf-action-target\b")) - expect(page.locator("#flow")).not_to_have_class(re.compile(r"\blf-action-target\b")) - - page.keyboard.press("c") + expect(start).not_to_have_class(re.compile(r"\blf-action-target\b")) expect(page.locator(".lf-composer")).to_be_visible() expect(page.locator("#lf-composer-quote")).to_have_text("§ diagram · Start request") expect(start).to_have_class(re.compile(r"\blf-mark-el\b.*\blf-pending\b")) @@ -403,13 +398,8 @@ def test_slash_finds_page_text_without_a_target_kind(browser, serve): ) page.keyboard.press("Enter") - assert " ".join(page.evaluate("() => getSelection().toString()").split()) == ( - "button the key" - ) expect(page.locator(".lf-target-search")).to_be_hidden() - expect(page.locator(".lf-fab")).to_be_visible() - - page.keyboard.press("c") + expect(page.locator(".lf-fab-input")).to_be_focused() expect(page.locator(".lf-composer")).to_be_visible() assert pending_text(page) == "button the key" assert errors == [] @@ -526,9 +516,8 @@ def test_selection_search_brings_an_offscreen_match_into_view(browser, serve): expect(page.get_by_role("searchbox", name="Search page text")).to_be_focused() page.keyboard.press("Enter") - assert " ".join(page.evaluate("() => getSelection().toString()").split()) == ( - "distant phrase" - ) + expect(page.locator(".lf-fab-input")).to_be_focused() + assert pending_text(page) == "distant phrase" assert errors == [] page.close() @@ -561,7 +550,8 @@ def test_selection_search_scrolls_to_the_match_inside_a_tall_text_block(browser, assert mark["y"] > 42 and mark["y"] + mark["height"] < keyline_top page.keyboard.press("Enter") - assert page.evaluate("() => getSelection().toString()") == "copper needle" + expect(page.locator(".lf-fab-input")).to_be_focused() + assert pending_text(page) == "copper needle" assert errors == [] page.close() @@ -675,7 +665,8 @@ def test_selection_search_opens_when_the_viewport_has_no_hint_targets(browser, s expect(page.locator(".lf-target-match")).not_to_have_count(0) page.keyboard.press("Enter") - assert page.evaluate("() => getSelection().toString()") == "phrase only appears" + expect(page.locator(".lf-fab-input")).to_be_focused() + assert pending_text(page) == "phrase only appears" assert errors == [] page.close() diff --git a/tests/test_render_startup.py b/tests/test_render_startup.py index d147ff388..bf34f0d81 100644 --- a/tests/test_render_startup.py +++ b/tests/test_render_startup.py @@ -44,6 +44,7 @@ _publish, _traffic, compare_with, + composer_quote, data_projection_page, leaf_page, live_url, @@ -1516,10 +1517,10 @@ def test_startup_continues_while_the_registry_fetch_is_held(browser, serve): (words["x"] + 2, y), (words["x"] + words["width"] - 2, y), ) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("Still anchored?") - page.locator(".lf-composer").get_by_role("button", name="Comment").click() + page.keyboard.press("Enter") expect(page.locator(".lf-thread")).to_have_count(3) expect(page.locator(".lf-thread .lf-quote.detached")).to_have_count(0) @@ -2400,10 +2401,10 @@ def test_a_comment_follows_one_runtime_datum_through_reconciliation(browser, ser api = page.locator('[data-lf-datum="api"]') api.click(click_count=3) - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_visible() + page.locator(".lf-fab-input").click() page.locator(".lf-composer textarea").fill("Which readiness check is this?") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") round_trip(page) comment = next(e for e in sent_events(serve.page_dir) if e["kind"] == "comment") @@ -2576,17 +2577,10 @@ def test_a_captured_source_stays_pointable_and_frozen_in_an_export( ) assert bounds is not None select(page, (bounds["left"] + 1, bounds["y"]), (bounds["right"] - 1, bounds["y"])) - selected = page.evaluate( - """() => ({ - text: getSelection().toString(), - anchor: getSelection().anchorNode?.parentElement?.outerHTML, - })""" - ) - assert selected["text"], selected - expect(page.locator(".lf-fab")).to_be_visible() - page.locator(".lf-fab").click() + expect(page.locator(".lf-fab-input")).to_be_focused() + assert composer_quote(page)["text"].strip("“”") == "Original instructions." page.locator(".lf-composer textarea").fill("Keep this exact source.") - page.get_by_role("button", name="Comment", exact=True).click() + page.keyboard.press("Enter") round_trip(page) comment = next(e for e in sent_events(serve.page_dir) if e["kind"] == "comment") assert comment["anchor"]["section"] == "skill-source" diff --git a/tests/test_render_widgets.py b/tests/test_render_widgets.py index be91038c1..af6044080 100644 --- a/tests/test_render_widgets.py +++ b/tests/test_render_widgets.py @@ -47,6 +47,7 @@ _until, actions, compare_with, + composer_quote, leaf_page, live_url, open_page, @@ -2007,12 +2008,12 @@ def test_the_decision_walk_starts_from_where_the_reader_is(browser, serve): halfway down and press `d` and you were taken back past everything you had read, and so was anyone who had just selected a paragraph to comment on. - Three readings of where they are, and the page is left in each state in turn: what - they are reading, when they have pointed at nothing; what they have selected; and - where the walk itself last left off, once the walk is what last moved them. The - banner's button is no place — pressing it opens the tray and leaves the focus on - itself, so a walk measured from the focus after it would restart on every press, and - the ring is gone from the page by then, the reader being in the banner.""" + Two readings of where they are are left in turn: what they are reading, and where + the walk itself last left off. The banner's button is no place — pressing it opens + the tray and leaves the focus on itself, so a walk measured from the focus after it + would restart on every press, and the ring is gone from the page by then, the reader + being in the banner. A selected passage now enters its comment field immediately; + while that field stands, letters are text rather than page-navigation keys.""" page, errors = open_page(browser, serve(DECISIONS_PAGE)) # A window short enough that reading down the page leaves the top of it behind, @@ -2035,24 +2036,6 @@ def test_the_decision_walk_starts_from_where_the_reader_is(browser, serve): page.keyboard.press("a") expect(page.locator("#t-bath-decision")).to_have_attribute("data-lf-decision", "1") - # A selection outranks the mark, because it is the reader saying where they are - # since the walk last moved them: from a task above the two the walk has just been - # through, forward is the first of them and back is the change before it. Measured - # after each landing, because the landing scrolled the page under the coordinates. - def drag_over_the_done_task(): - page.locator("#t-mounts strong").scroll_into_view_if_needed() - box = page.locator("#t-mounts strong").bounding_box() - y = box["y"] + box["height"] / 2 - select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y)) - - drag_over_the_done_task() - page.keyboard.press("a") - expect(page.locator("#t-baffles-decision")).to_have_attribute( - "data-lf-decision", "1" - ) - drag_over_the_done_task() - page.keyboard.press("Shift+a") - expect(page.locator("#sug-refill")).to_have_attribute("data-lf-decision", "1") assert errors == [] page.close() @@ -2246,11 +2229,15 @@ def drag(locator): # words raises the button here. Opening the panel slides the document over, and a # drag run across that reads a box from the frame before and selects nothing — the # panel's own contents are fixed and stay where they are read. - assert "signed-cookie" in drag(page.locator("#intro")) - expect(page.locator(".lf-fab")).to_be_visible() + intro = page.locator("#intro") + box = intro.bounding_box() + y = box["y"] + box["height"] / 2 + select(page, (box["x"] + 2, y), (box["x"] + box["width"] - 2, y)) + expect(page.locator(".lf-fab-input")).to_be_focused() + assert "signed-cookie" in composer_quote(page)["text"] # Put it down again, so what follows is a rise and not a leftover. page.locator("#h").click() - expect(page.locator(".lf-fab")).to_be_hidden() + expect(page.locator(".lf-fab-input")).to_be_hidden() page.locator(".lf-threads-toggle").click() assert "Which store" in drag(page.locator("#ps-decision-region > h3")) @@ -2258,7 +2245,7 @@ def drag(locator): # step it queues queues nothing further. for _ in range(2): page.evaluate("() => new Promise((r) => setTimeout(r))") - expect(page.locator(".lf-fab")).to_be_hidden() + expect(page.locator(".lf-fab-input")).to_be_hidden() assert errors == [] page.close() diff --git a/tests/test_site.py b/tests/test_site.py index cc2dd0745..cf26dba2d 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -417,7 +417,10 @@ def test_every_example_says_what_it_is_and_links_back(site, hosted, browser): AFTER_THE_DRAG = f"""async () => {{ await new Promise(done => setTimeout(done)); await ({ONE_FRAME})(); + const field = document.querySelector('.lf-fab-input'); return {{ text: getSelection().toString(), + quote: document.getElementById('lf-composer-quote')?.textContent ?? '', + fieldFocused: document.activeElement === field, says: document.querySelector('.lf-keyline').textContent }}; }}""" @@ -448,13 +451,12 @@ def test_the_label_is_chrome_rather_than_words_to_quote(site, hosted, browser): page, errors = open_page(browser, example_url(hosted, "design-decision")) try: control = drag_across(page, "#decision-lede") - assert "monolith split" in control["text"] - assert "comment on the selection" in control["says"], ( - "the drag never reached the runtime" - ) + assert control["fieldFocused"], "the page's own words raised no comment field" + assert "monolith split" in control["quote"] label = drag_across(page, "main > .sitenote p") assert "example of a leaf page" in label["text"] + assert not label["fieldFocused"] # The word `c` carries with nothing in hand — it goes to the threads rather # than opening a box on anything, and "comment on the selection" does not # contain it, so the two readings still tell each other apart. @@ -556,10 +558,10 @@ def test_a_comment_lands_in_the_thread_with_its_quote(site, hosted, browser): (box["x"] + 4, box["y"] + 8), (box["x"] + box["width"] - 40, box["y"] + box["height"] - 8), ) - # The pill a selection raises, which is how a reader reaches the composer. - page.locator(".lf-fab").click() + # Selection enters the compact field immediately. + expect(page.locator(".lf-fab-input")).to_be_focused() page.locator(".lf-composer textarea").fill("Does this cover key rotation?") - page.locator(".lf-composer .lf-btn.primary").click() + page.keyboard.press("Enter") # The thread holding the words just written, rather than whichever is first: # an example that ships a log opens with threads already in the panel, and From e9ff8ff937538fc9d2e13ac9510ea31a073e9b8f Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 31 Aug 2026 19:22:21 -0700 Subject: [PATCH 2/3] Preserve drafts across automatic retargeting --- skills/leaf/assets/leaf.js | 30 +++------- skills/leaf/assets/runtime/chrome-style.js | 12 ++-- skills/leaf/assets/runtime/composing/aim.js | 12 +--- .../assets/runtime/composing/selection.js | 57 ++++++++----------- .../leaf/assets/runtime/composing/surface.js | 10 ++-- skills/leaf/assets/runtime/design.js | 4 +- skills/leaf/assets/runtime/navigation.js | 9 +-- tests/test_render_aim.py | 25 ++++---- tests/test_render_anchors.py | 5 ++ tests/test_render_drafts.py | 26 +++++++++ 10 files changed, 97 insertions(+), 93 deletions(-) diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js index 604d23720..3fb43addf 100644 --- a/skills/leaf/assets/leaf.js +++ b/skills/leaf/assets/leaf.js @@ -924,7 +924,7 @@ panel.append(panelHead, findRow, threadsBox, panelFoot); // Pressing Tab or its ellipsis exchanges its field for the other responses in place. // One affordance, raised only where the reader has already pointed: // a selection, a visual's click, an aimed item, or a visual part. -const fabBar = el("div", "lf-ui lf-fab-bar lf-page-paint"); +const fabBar = el("div", "lf-ui lf-fab-bar lf-target-paint"); fabBar.setAttribute("role", "group"); fabBar.setAttribute("aria-label", "Respond"); const fabInput = document.createElement("textarea"); @@ -945,7 +945,7 @@ fabBar.append(fab); // screen reader and takes nothing from the press it promises; refreshAim is its one // writer, and data-for is the aimed id stated where a test can read the promise. const aimBox = el("div", "lf-ui lf-aim lf-target-paint"); -const composer = el("div", "lf-ui lf-composer lf-target-paint"); +const composer = el("div", "lf-ui lf-composer"); // Only ever shown detached — paintAnchors, its one writer, keeps it out of sight while // the page is marking the passage. lf-ui on the element itself, not just on the composer // around it: this is the only injected chrome carrying an id, and "which section is this @@ -1317,6 +1317,7 @@ selectionComposerRuntime = createSelectionComposer(runtime, { elementById: (...args) => elementById(...args), fab, fabAnchor: fabAnchorAt, + fabBar, inChrome, landTyping, loadDraft, @@ -1336,15 +1337,8 @@ selectionComposerRuntime = createSelectionComposer(runtime, { wireInput, }); -function openComposer( - anchor, - text, - left, - top, - suggest = false, - about = designOn ? "layer" : null, -) { - return selectionComposerRuntime.openComposer(anchor, text, left, top, suggest, about); +function openComposer(anchor, text, options = {}) { + return selectionComposerRuntime.openComposer(anchor, text, options); } const hideComposer = () => selectionComposerRuntime.hideComposer(); @@ -1886,7 +1880,6 @@ const { commentOnItem, glideTo, placeThreadEdge, seenScroller, stepPage, stepThr BANNER_CLEAR, reducedMotion, scrollBehavior, - beside, inChrome: (node) => inChrome(node), inPanel, openOnItem, @@ -1897,7 +1890,6 @@ const { commentOnItem, glideTo, placeThreadEdge, seenScroller, stepPage, stepThr scrollToElement, scrollToThread, setPanel, - shownBox, shownRect, threadsBox, }); @@ -3691,14 +3683,10 @@ async function startPage() { if (savedView && savedView.revision < runtime.currentRevision) showToast(`Updated to ${runtime.currentLabel}`); if (savedComposer) - openComposer( - savedComposer.anchor, - savedComposer.text, - (innerWidth - 320) / 2, - 64, - Boolean(savedComposer.suggest), - savedComposer.about ?? null, - ); + openComposer(savedComposer.anchor, savedComposer.text, { + suggest: Boolean(savedComposer.suggest), + about: savedComposer.about ?? null, + }); // Every widget has upgraded and every async one has settled, so the geometry and // the drawn SVG are final. `version export` copies the page at this moment and has no // other way to know it arrived: a load event fires before the modules run, and diff --git a/skills/leaf/assets/runtime/chrome-style.js b/skills/leaf/assets/runtime/chrome-style.js index be32ee25d..23f2108d4 100644 --- a/skills/leaf/assets/runtime/chrome-style.js +++ b/skills/leaf/assets/runtime/chrome-style.js @@ -357,12 +357,12 @@ export function chromeStyle({ of lines: 200px stopped a long comment at ten lines with the screen mostly empty. Both selectors: the panel's boxes sit inside .lf-ui, a widget's own box wears the class itself. */ - .lf-ui textarea:not(.lf-fab-input), - textarea.lf-ui:not(.lf-fab-input) { padding: 8px 10px; border: 1px solid var(--border-2); + .lf-ui textarea:where(:not(.lf-fab-input)), + textarea.lf-ui:where(:not(.lf-fab-input)) { padding: 8px 10px; border: 1px solid var(--border-2); border-radius: 6px; background: var(--card); color: inherit; resize: none; field-sizing: content; max-height: 50vh; overflow-y: auto; } - .lf-ui textarea:not(.lf-fab-input):is(:focus, .lf-focus), - textarea.lf-ui:not(.lf-fab-input):is(:focus, .lf-focus) { outline: none; + .lf-ui textarea:where(:not(.lf-fab-input)):is(:focus, .lf-focus), + textarea.lf-ui:where(:not(.lf-fab-input)):is(:focus, .lf-focus) { outline: none; border-color: color-mix(in srgb, var(--accent) 45%, var(--card)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } ${MARK_RULES} @@ -601,9 +601,9 @@ ${MARK_RULES} :scope { cursor: auto; font-family: var(--sans); font-size: var(--t-5); line-height: var(--lf-ui-lh); } /* Page paint belongs under a covering workspace. Paint whose target is inside the - chrome belongs above that workspace, including when the same aim or composer moves + chrome belongs above that workspace, including when the same aim or response bar moves between the two. The target owner states the plane; document order keeps aim, - composer, and inspect in their ordinary order within it. */ + response bar, and inspect in their ordinary order within it. */ :is(.lf-page-paint, .lf-target-paint) { z-index: 8890; } .lf-target-paint[data-lf-paint-plane="chrome"] { z-index: 9060; } .lf-banner { position: fixed; top: 0; left: 0; right: 0; z-index: 9000; height: var(--lf-banner-h); diff --git a/skills/leaf/assets/runtime/composing/aim.js b/skills/leaf/assets/runtime/composing/aim.js index 4eb397311..bd8ef875d 100644 --- a/skills/leaf/assets/runtime/composing/aim.js +++ b/skills/leaf/assets/runtime/composing/aim.js @@ -148,16 +148,8 @@ export function createAim({ if (ev.type === "mousedown" || ev.type === "click") ev.preventDefault(); ev.stopPropagation(); if (ev.type !== "click") return; - if (claimedPress.aim) - focusTargetComment(claimedPress.aim, { - left: ev.clientX + 6, - top: ev.clientY - 40, - }); - else if (claimedPress.design) - openOnDesign(claimedPress.design, { - left: ev.clientX + 6, - top: ev.clientY - 40, - }); + if (claimedPress.aim) focusTargetComment(claimedPress.aim); + else if (claimedPress.design) openOnDesign(claimedPress.design); } for (const type of PRESS_EVENTS) document.addEventListener(type, claimPress, true); diff --git a/skills/leaf/assets/runtime/composing/selection.js b/skills/leaf/assets/runtime/composing/selection.js index d3036ca52..f84c29e10 100644 --- a/skills/leaf/assets/runtime/composing/selection.js +++ b/skills/leaf/assets/runtime/composing/selection.js @@ -14,6 +14,7 @@ export function createSelectionComposer(runtime, dependencies) { elementById, fab, fabAnchor, + fabBar, inChrome, landTyping, loadDraft, @@ -190,47 +191,41 @@ export function createSelectionComposer(runtime, dependencies) { // The quote suggestion mode auto-seeded, so reopening on a new anchor can tell // machine seed from user text: the seed belongs to its old anchor and is dropped; - // anything the user typed or edited rides forward — never lose user text. + // user text stays with its passage unless an explicit Comment gesture carries it. let seededQuote = ""; // `about` defaults to the mode standing at the open — a composer opened in design mode // is about the layer — and a restored draft passes the word it was saved with. function openComposer( anchor, text, - left, - top, - suggest = false, - about = designIsOn() ? "layer" : null, + { suggest = false, about = designIsOn() ? "layer" : null, carry = false } = {}, ) { closeReactions(); if (composerInput.value === seededQuote) composerInput.value = ""; seededQuote = ""; const ctx = composerCtx(anchor || null); const previousCtx = composerCtx(pendingAnchor); - if (previousCtx !== ctx) composerEpoch += 1; - // Re-anchoring while this exact value is being sent is a new response gesture, not - // an edit of the submitted words on a different passage. Leave the sending draft at - // its original coordinate so a failed request can recover it, and start the new - // field clean. The eventual success settles that original generation in place. - const leavesFlight = - previousCtx !== ctx && - inFlight?.ctx === previousCtx && - composerInput.value === inFlight.raw; - if (leavesFlight) composerInput.value = ""; - // A draft already standing on this passage is what the box opens with — one left hidden - // here, or one being typed in another tab — unless the caller brought words of its own - // or the box is already carrying some. - const held = text || composerInput.value ? null : loadDraft(ctx); - if (held) ({ text, suggest, about } = JSON.parse(held)); - // The draft moves with the box, and one draft is one record: the passage the words were - // on lets go of them as they arrive on the next one. A press that re-anchors an open - // draft is where this lands, and a key left standing there would hand the same words - // back on the old passage at the next load. - if (previousCtx !== ctx && !leavesFlight) clearDraft(previousCtx); + if (previousCtx !== ctx) { + composerEpoch += 1; + const previousText = composerInput.value; + const leavesFlight = + inFlight?.ctx === previousCtx && previousText === inFlight.raw; + composerInput.value = ""; + // Automatic selection merely opens another passage's view. An explicit Comment + // gesture may instead carry unsent words there, which preserves the old Alt-click + // promise without making a reader's next selection silently re-anchor their draft. + if (carry && previousText && !leavesFlight) { + clearDraft(previousCtx); + text ||= previousText; + } else { + const held = text ? null : loadDraft(ctx); + if (held) ({ text, suggest, about } = JSON.parse(held)); + } + } pendingAnchor = anchor || null; pendingAbout = about; const target = pendingAnchor?.section ? elementById(pendingAnchor.section) : null; - composer.dataset.lfPaintPlane = target && inChrome(target) ? "chrome" : "page"; + fabBar.dataset.lfPaintPlane = target && inChrome(target) ? "chrome" : "page"; composerInput.value = text || composerInput.value; suggestCheck.checked = Boolean(suggest); syncSuggestMode(); @@ -239,10 +234,8 @@ export function createSelectionComposer(runtime, dependencies) { syncComposer(); composerInput.focus(); watchComposer(); - // The store hears about the anchor now, not at the next keystroke: saving only on - // input left a re-anchored draft stored against the anchor the press had just moved - // it off, and a reload between the press and the next character quietly un-made the - // move. + // The store hears about the anchor now, not at the next keystroke: a newly opened + // per-passage field is durable even before the reader adds its first character. saveComposerDraft(); } // The box is one view of the draft standing on this passage, and it follows the plain @@ -289,9 +282,7 @@ export function createSelectionComposer(runtime, dependencies) { // the bar is carrying. It remains a button only while the choices are visible. fab.onclick = () => { if (!fabAnchor()) return; - const anchor = fabAnchor(); - const { left, top } = fab.getBoundingClientRect(); - openComposer(anchor, "", left, top); + openComposer(fabAnchor(), ""); }; return { hideComposer, openComposer, pendingComposer, setSuggestionMode }; } diff --git a/skills/leaf/assets/runtime/composing/surface.js b/skills/leaf/assets/runtime/composing/surface.js index 749c47a6f..4283414f7 100644 --- a/skills/leaf/assets/runtime/composing/surface.js +++ b/skills/leaf/assets/runtime/composing/surface.js @@ -284,16 +284,16 @@ export function createSelectionSurface({ : null; // The one way an item under a gesture becomes the composer's anchor, so no two routes // can come to write different anchors for the same press. - function openOnItem(item, from) { - openComment({ section: item.id }, "", from.left, from.top); + function openOnItem(item) { + openComment({ section: item.id }, "", { carry: true }); } // Keyboard selection names the target and immediately lands in its Comment field. function selectResponseTarget({ anchor }) { openComment(anchor, ""); } // Alt-click already names Comment, so the field takes focus in the same transaction. - function focusTargetComment({ anchor }, from = null) { - openComment(anchor, "", from?.left, from?.top); + function focusTargetComment({ anchor }) { + openComment(anchor, "", { carry: true }); } // Focusing text entry collapses a native page selection. Hold that browser-authored // selectionchange out of updateFab: the durable anchor is already captured, and letting @@ -547,7 +547,7 @@ export function createSelectionSurface({ if (designIsOn()) { if (pageSelection()) return; const target = designTarget(ev.target); - if (target) openOnDesign(target, { left: ev.clientX + 6, top: ev.clientY - 40 }); + if (target) openOnDesign(target); return; } // The record rather than this event's own coordinates, for the reason the record is diff --git a/skills/leaf/assets/runtime/design.js b/skills/leaf/assets/runtime/design.js index f9a243332..25a207969 100644 --- a/skills/leaf/assets/runtime/design.js +++ b/skills/leaf/assets/runtime/design.js @@ -292,9 +292,9 @@ export function createDesign(dependencies) { // The one way a design target becomes the composer's anchor: the element by id, and the // control's word where the press landed on one. - function openOnDesign({ el, part }, from) { + function openOnDesign({ el, part }) { showFab(null); - openComposer({ section: el.id, ...(part && { part }) }, "", from.left, from.top); + openComposer({ section: el.id, ...(part && { part }) }, ""); } return { diff --git a/skills/leaf/assets/runtime/navigation.js b/skills/leaf/assets/runtime/navigation.js index de4756ccf..d7d1df30d 100644 --- a/skills/leaf/assets/runtime/navigation.js +++ b/skills/leaf/assets/runtime/navigation.js @@ -7,7 +7,6 @@ export function createNavigation({ BANNER_CLEAR, reducedMotion, scrollBehavior, - beside, inChrome, inPanel, openOnItem, @@ -18,7 +17,6 @@ export function createNavigation({ scrollToElement, scrollToThread, setPanel, - shownBox, shownRect, threadsBox, }) { @@ -48,8 +46,8 @@ export function createNavigation({ // // What the page shows of it, which is the reading the aim's own paint takes // (`refreshAim`) — this being its keyboard twin, the two decide "is this in front of the - // reader" the same way or they are not twins. `shownBox` alone is the box the item would - // have, unclipped: an item scrolled out of a board's sideways scroller still reports one + // reader" the same way or they are not twins. An unclipped box alone is the box the item + // would have: an item scrolled out of a board's sideways scroller still reports one // inside the window, so a gate reading that called it showing and opened the box on // something off screen, which the unconditional travel it replaced never did. Any part // showing is enough, which is also what keeps a box taller than the window from jumping @@ -68,8 +66,7 @@ export function createNavigation({ // that box against a passage they have not moved. const seen = shownRect(item, new Map()); if (!seen || seen.bottom <= BANNER_CLEAR) scrollToElement(item, "instant"); - const [left, top] = beside(shownBox(item)); - openOnItem(item, { left, top }); + openOnItem(item); } // t/T walk the open threads: panel focus and the page highlight move as a pair — they are // two views of the same thread. Clamped at the ends, not wrapped; never empty, because the diff --git a/tests/test_render_aim.py b/tests/test_render_aim.py index 1080bda56..8c2bf9065 100644 --- a/tests/test_render_aim.py +++ b/tests/test_render_aim.py @@ -332,10 +332,10 @@ def test_covering_workspaces_separate_page_paint_from_chrome_target_paint( ): """A covering workspace owns its pixels until the reader targets that workspace. - The aim, composer, design legend, and inspect name share two semantic stacking + 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 - composer can serve both without a viewport-width z-index exception. + response bar can serve both without a viewport-width z-index exception. """ page, errors = open_page(browser, serve(DECISIONS_PAGE)) resized(page, 560, 900) @@ -370,15 +370,15 @@ def test_covering_workspaces_separate_page_paint_from_chrome_target_paint( page.mouse.click(point["x"], point["y"]) page.keyboard.up("Alt") expect(page.locator(".lf-composer")).to_be_visible() - composer_plane = page.locator(".lf-composer").evaluate( + response_plane = page.locator(".lf-fab-bar").evaluate( "node => ({plane: node.dataset.lfPaintPlane, " "z: Number(getComputedStyle(node).zIndex), " "tray: Number(getComputedStyle(document.querySelector('.lf-decisions-panel')).zIndex)})" ) assert ( - composer_plane["plane"] == "page" - and composer_plane["z"] < composer_plane["tray"] - ), f"page composer paints over the covering Asks sheet: {composer_plane}" + 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") page.locator("body").focus() @@ -423,15 +423,15 @@ def test_covering_workspaces_separate_page_paint_from_chrome_target_paint( }""" ) expect(page.locator(".lf-composer")).to_be_visible() - chrome_composer = page.locator(".lf-composer").evaluate( + chrome_response = page.locator(".lf-fab-bar").evaluate( "node => ({plane: node.dataset.lfPaintPlane, " "z: Number(getComputedStyle(node).zIndex), " "tray: Number(getComputedStyle(document.querySelector('.lf-decisions-panel')).zIndex)})" ) assert ( - chrome_composer["plane"] == "chrome" - and chrome_composer["z"] > chrome_composer["tray"] - ), f"a composer about the Asks sheet paints beneath it: {chrome_composer}" + chrome_response["plane"] == "chrome" + and chrome_response["z"] > chrome_response["tray"] + ), f"a response bar about the Asks sheet paints beneath it: {chrome_response}" assert errors == [] page.close() @@ -1138,6 +1138,11 @@ def test_design_mode_takes_an_edge_rather_than_drawing_it(browser, serve): draw_edge(page, edge, 160) held = geometry(page, edge) expect(page.locator(".lf-composer")).to_be_visible() + response_bar = page.locator(".lf-fab-bar") + expect(response_bar).to_have_attribute("data-lf-paint-plane", "chrome") + assert int(response_bar.evaluate("el => getComputedStyle(el).zIndex")) > int( + page.locator(".lf-panel").evaluate("el => getComputedStyle(el).zIndex") + ), "the field opened on chrome underneath the workspace it describes" expect(page.locator("#lf-composer-quote")).to_have_text( "layer · Thread panel width · threads" ) diff --git a/tests/test_render_anchors.py b/tests/test_render_anchors.py index 44e5cd051..d2378e5cf 100644 --- a/tests/test_render_anchors.py +++ b/tests/test_render_anchors.py @@ -485,6 +485,11 @@ def test_the_comment_button_stands_on_no_control(browser, serve): steps=16, ) expect(page.locator(".lf-fab-input")).to_be_visible() + assert page.locator(".lf-fab-bar").evaluate( + "el => el.getBoundingClientRect().top" + ) > page.locator("[data-lf-for='sug-refill']").evaluate( + "el => el.getBoundingClientRect().bottom" + ), "the bar never stepped past the row, so standing on no control proves nothing" under = page.evaluate("""() => [...document.querySelectorAll("[data-lf-offer]")] .filter(c => !c.closest(".lf-chrome") && c.checkVisibility()) diff --git a/tests/test_render_drafts.py b/tests/test_render_drafts.py index f6ce53db2..3fefb6fb4 100644 --- a/tests/test_render_drafts.py +++ b/tests/test_render_drafts.py @@ -964,6 +964,32 @@ def test_a_held_comment_send_leaves_the_passage_picked_out_behind_it(browser, se page.close() +def test_an_unsent_comment_stays_with_its_passage_when_another_is_selected( + browser, serve +): + """Opening fields is automatic, so selecting a new passage is not re-anchoring. + + Each passage keeps its own durable draft: the newly selected passage starts empty, + and returning to the original passage restores the words written about it.""" + page, errors = open_page(browser, serve(NOTED_PAGE)) + field = page.locator(".lf-fab-input") + original = "These words belong to the first passage." + + page.locator("#p1").click(click_count=3) + expect(field).to_be_focused() + field.fill(original) + + page.locator("#p2").click(click_count=3) + expect(field).to_be_focused() + expect(field).to_have_value("") + + page.locator("#p1").click(click_count=3) + expect(field).to_be_focused() + expect(field).to_have_value(original) + assert errors == [] + page.close() + + def test_failed_settlement_keeps_the_base_for_a_chained_nondurable_edit( browser, serve, one_reader ): From b56d7937703eb335e9165aa442935f42e8b8ff43 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Mon, 31 Aug 2026 19:40:45 -0700 Subject: [PATCH 3/3] Align explicit and automatic draft targeting --- skills/leaf/CLAUDE.md | 7 +-- .../assets/runtime/composing/selection.js | 8 ++-- .../leaf/assets/runtime/composing/surface.js | 2 +- tests/test_render_drafts.py | 7 +++ tests/test_render_semantic_selection.py | 48 +++++++++++++++---- 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index b9b5a3c21..e369e2a6d 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -1139,9 +1139,10 @@ replacement-text mode. `showFab` places the bar; `openComposer` binds its field to the durable draft and focuses it. `selectResponseTarget` does both for a keyboard item hint, and the ⌥ press uses -`focusTargetComment` on the same captured anchor. An already-open unsent draft re-anchors -directly so its standing words survive. Submitted words still in flight remain owned by -their original anchor, while a later target starts clean and keeps focus. For a page +`focusTargetComment` on the same captured anchor. Automatic passage selection opens that +passage's own durable draft; these explicit Comment gestures carry unsent words onto the +new anchor. Submitted words still in flight remain owned by their original anchor, while +a later target starts clean and keeps focus. For a page target, `r` contributes Comment, Suggest where available, and the reaction Buttons to that target's existing Button options. Those temporary Buttons borrow the cluster's room and dock with it when necessary; they do not claim permanent rail width. A thread-local diff --git a/skills/leaf/assets/runtime/composing/selection.js b/skills/leaf/assets/runtime/composing/selection.js index f84c29e10..cd3d709a7 100644 --- a/skills/leaf/assets/runtime/composing/selection.js +++ b/skills/leaf/assets/runtime/composing/selection.js @@ -205,6 +205,7 @@ export function createSelectionComposer(runtime, dependencies) { seededQuote = ""; const ctx = composerCtx(anchor || null); const previousCtx = composerCtx(pendingAnchor); + let carriedDraft = false; if (previousCtx !== ctx) { composerEpoch += 1; const previousText = composerInput.value; @@ -217,6 +218,7 @@ export function createSelectionComposer(runtime, dependencies) { if (carry && previousText && !leavesFlight) { clearDraft(previousCtx); text ||= previousText; + carriedDraft = true; } else { const held = text ? null : loadDraft(ctx); if (held) ({ text, suggest, about } = JSON.parse(held)); @@ -234,9 +236,9 @@ export function createSelectionComposer(runtime, dependencies) { syncComposer(); composerInput.focus(); watchComposer(); - // The store hears about the anchor now, not at the next keystroke: a newly opened - // per-passage field is durable even before the reader adds its first character. - saveComposerDraft(); + // Programmatic carrying fires no input event, so persist that one move explicitly. + // An automatically opened empty field has no draft to save; its first edit does. + if (carriedDraft) saveComposerDraft(); } // The box is one view of the draft standing on this passage, and it follows the plain // boxes' rule with one thing of its own: the composer is chrome as well as a box, so a diff --git a/skills/leaf/assets/runtime/composing/surface.js b/skills/leaf/assets/runtime/composing/surface.js index 4283414f7..e19255142 100644 --- a/skills/leaf/assets/runtime/composing/surface.js +++ b/skills/leaf/assets/runtime/composing/surface.js @@ -289,7 +289,7 @@ export function createSelectionSurface({ } // Keyboard selection names the target and immediately lands in its Comment field. function selectResponseTarget({ anchor }) { - openComment(anchor, ""); + openComment(anchor, "", { carry: true }); } // Alt-click already names Comment, so the field takes focus in the same transaction. function focusTargetComment({ anchor }) { diff --git a/tests/test_render_drafts.py b/tests/test_render_drafts.py index 3fefb6fb4..49a4dd7aa 100644 --- a/tests/test_render_drafts.py +++ b/tests/test_render_drafts.py @@ -982,6 +982,13 @@ def test_an_unsent_comment_stays_with_its_passage_when_another_is_selected( page.locator("#p2").click(click_count=3) expect(field).to_be_focused() expect(field).to_have_value("") + assert ( + page.evaluate( + """() => Object.keys(localStorage) + .filter(key => key.startsWith('lf-draft:composer:')).length""" + ) + == 1 + ) page.locator("#p1").click(click_count=3) expect(field).to_be_focused() diff --git a/tests/test_render_semantic_selection.py b/tests/test_render_semantic_selection.py index 3dac7b2fc..4c79921f7 100644 --- a/tests/test_render_semantic_selection.py +++ b/tests/test_render_semantic_selection.py @@ -74,18 +74,14 @@ def test_s_aims_at_the_item_named_by_its_hint(browser, serve): expect(shown.nth(1).locator("kbd")).to_have_text("⇥") expect(shown.nth(1)).to_contain_text("other responses") - # Text entry owns letters. Tab deliberately yields the focused composer to the - # existing reaction palette; Escape from that palette restores the same draft. + # Text entry owns letters. Tab replaces the field with the same-position Comment + # action; Escape from that response row restores the same draft. page.keyboard.press("s") expect(field).to_have_value("s") field.fill("") page.keyboard.press("Tab") - expect(page.locator(".lf-margin-reactions")).to_have_class( - re.compile(r"\blf-react-open\b") - ) - expect( - page.locator('.lf-margin-reactions .lf-react[data-token="ok"]') - ).to_be_focused() + expect(page.locator(".lf-fab-bar")).to_have_class(re.compile(r"\blf-react-open\b")) + expect(page.locator(".lf-fab-bar > .lf-fab")).to_be_focused() expect(field).to_be_hidden() page.keyboard.press("Escape") expect(field).to_be_focused() @@ -97,6 +93,38 @@ def test_s_aims_at_the_item_named_by_its_hint(browser, serve): page.close() +def test_a_keyboard_comment_gesture_carries_the_current_unsent_draft(browser, serve): + """Keyboard and pointer Comment gestures make the same explicit re-anchoring.""" + page, errors = open_page(browser, serve(TARGETS_PAGE)) + field = page.locator(".lf-fab-input") + draft = "Carry these deliberate words." + + page.locator("h1").click(modifiers=["Alt"]) + expect(field).to_be_focused() + field.fill(draft) + page.evaluate("document.activeElement.blur()") + + page.keyboard.press("s") + hints = page.locator(".lf-target-hint") + expect(hints).to_have_count(3) + prose_code = page.evaluate( + """() => { + const top = document.querySelector('#prose').getBoundingClientRect().top; + return [...document.querySelectorAll('.lf-target-hint')] + .sort((a, b) => Math.abs(a.getBoundingClientRect().top - top) + - Math.abs(b.getBoundingClientRect().top - top))[0] + .dataset.lfTarget; + }""" + ) + page.keyboard.type(prose_code) + + expect(field).to_be_focused() + expect(field).to_have_value(draft) + assert page.evaluate(DRAFT_MARK) == "prose" + assert errors == [] + page.close() + + def test_a_selected_target_keeps_escape_when_the_layer_has_no_reactions(browser, serve): """Without reactions, the focused composer offers no dead Tab route.""" registry = json.loads( @@ -146,8 +174,10 @@ def test_a_passage_still_offers_suggest_when_the_layer_has_no_reactions(browser, expect(page.locator(".lf-fab-bar .lf-react")).to_have_count(0) page.keyboard.press("Tab") - responses = page.locator(".lf-margin-reactions") + responses = page.locator(".lf-fab-bar") expect(responses).to_have_class(re.compile(r"\blf-react-open\b")) + expect(responses.locator(":scope > .lf-fab")).to_be_focused() + page.keyboard.press("ArrowRight") expect(responses.locator(".lf-fab-suggest")).to_be_focused() expect(responses.locator(".lf-react")).to_have_count(0) assert errors == []