From f44e2524a8e55ba05d07ce11d6b5fdf04b64ea36 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 2 Sep 2026 16:00:57 -0700 Subject: [PATCH 1/3] Make Button explanations and controls one system Render compact clusters and the complete Page map from canonical Button records, preserve control identity across live updates, and keep active targets expanded. Turn the gallery into a sequence of explanations beside their live controls and cover keyboard, overflow, projection, and performance regressions. --- examples/button-gallery.html | 115 ++- examples/corpus.html | 115 ++- skills/leaf/CLAUDE.md | 77 +- skills/leaf/assets/leaf.js | 15 +- skills/leaf/assets/runtime/chrome-style.js | 11 +- .../leaf/assets/runtime/composing/surface.js | 8 + .../assets/runtime/conversation/landing.js | 6 +- .../leaf/assets/runtime/keyboard/address.js | 19 +- skills/leaf/assets/runtime/living-margin.js | 791 ++++++++++++------ skills/leaf/assets/runtime/margin-layout.js | 4 +- skills/leaf/assets/runtime/passages.js | 2 +- skills/leaf/assets/runtime/projection.js | 7 +- skills/leaf/assets/runtime/reactions.js | 15 +- skills/leaf/assets/theme.css | 11 +- skills/leaf/assets/widgets/lf-suggestion.js | 14 + tests/test_render_anchors.py | 14 + tests/test_render_margin.py | 642 +++++++++++--- tests/test_render_navigation.py | 56 +- tests/test_render_projection.py | 3 +- tests/test_render_reactions.py | 43 +- 20 files changed, 1348 insertions(+), 620 deletions(-) diff --git a/examples/button-gallery.html b/examples/button-gallery.html index ba86be2080..fdaf623c30 100644 --- a/examples/button-gallery.html +++ b/examples/button-gallery.html @@ -26,32 +26,34 @@

Buttons, in action

- Hover for a label. Tab into a cluster to expose its Buttons, then use ← / → and - Enter. Use the contents spine to jump between samples; in a narrow window, Map - in the top bar collects the page’s items. Undo takes back a decision. A live - preview keeps your experiments on reload. + Each section explains one promise beside the live Button that makes it. Hover + for a label, or Tab into a cluster and use ← / → and Enter. Use the contents + spine to jump between samples. In a narrow window, open ⋯ in the top bar, then + Map, to collect the page’s items. A live preview keeps your experiments on + reload.

-

Accept, reject, undo

+

Action Buttons change something now

- The heavier ring means an immediate action. Try either verdict, then the Undo - beside its receipt. Each row below changes a different amount of text. + A heavier ring means the press acts immediately. The green check accepts; the + red cross rejects. Try either verdict on each change, then use the Undo that + replaces it.

- Replace. The sample meeting starts at + Replace one word. The sample meeting starts at nine.ten.

- Insert. Bring a notebook. + Insert a sentence. Bring a notebook. A pencil is useful too.

- Delete. The room is upstairs. + Delete a sentence. The room is upstairs. Please arrive an hour early. @@ -59,18 +61,20 @@

Accept, reject, undo

-

Edit, save, restore

+

A Disclosure opens context first

- Edit opens a field; Save commits its words; Cancel leaves them alone. While - the field is active, its completion Buttons stay exposed. + Edit has a light ring and an ellipsis because it only opens the field. Once + the field is open, Save and Cancel have heavier rings because they act now; + both stay exposed until editing ends.

 Write a different invitation here. Try a few lines to see the editor grow.

- This second draft already has a saved edit. Open Changes, inspect its history, - then Restore the older wording or edit it again. + This saved draft shows two readings beside one item. Changes + opens its versions. Picked up reports that the saved edit reached an agent; it + does not repeat the edit. Restore acts on the version you choose.

The sample workshop is in the blue room.
@@ -78,7 +82,12 @@

Edit, save, restore

-

Open a thread, or start one

+

Thread is another Disclosure

+

+ The speech bubble identifies a Thread. Its light ring says that pressing it + opens the conversation without changing the document. Several conversations + share one Button and add a count. +

An existing conversation. The sample agenda has room for one more topic. Open the speech-bubble Button, reply, then resolve the thread. @@ -96,38 +105,40 @@

Open a thread, or start one

-

Leave a mark

+

Reaction Actions leave a short verdict

- Each row starts with a different standing reaction. Its small square marks it - as selected; press the same reaction to withdraw it. To try another, focus the - row’s Button and press R, or use the comment field’s reaction control. + Each selected reaction sits beside the words it marks. Its heavy ring means + pressing it acts immediately; its square corner means the reaction is settled. + Press it to take it back. To add one, select words and press R, then choose + with the matching digit or Button.

- Settled. The spare key is with reception. + 1 · ok · settled. The spare key is with reception.

- Wrong. This sample claims Tuesday follows Thursday. + 2 · no · wrong. This sample claims Tuesday follows Thursday.

- Unclear. Bring the thing from the other place. + 3 · lost · unclear. Bring the thing from the other place.

- Too long. At this point in time, we can now begin. + 4 · cut · too long. At this point in time, we can now begin.

- Needs detail. The exercise involves a map. + 5 · more · needs detail. The exercise involves a map.

- Attention. The side entrance closes early. + 6 · this · look here. The side entrance closes early.

-

One item, many Buttons

+

Options unfolds peer Buttons in place

- The change below has a thread and all six reactions. At rest it stays compact. - Press … to unfold it, or Tab into it. When the row cannot fit every Button, - More opens the remaining controls in the Page map. Escape returns to the row. + The light-ring ⋯ is an Options Button, not a menu or dialog. Press it, or Tab + into the cluster, to expose up to six peer Buttons beside the same passage. + Use ← / → and Enter; Escape folds them. If they cannot all fit, More opens the + complete cluster in the Page Map.

Use the courtyard for the practice session.

@@ -155,11 +166,10 @@

One item, many Buttons

-

Asks and outcomes

+

Reading Disclosures report without acting

- A Map Button can describe an item as well as offer an action. An unanswered - choice has an Ask; a recorded choice has an Outcome. Open each to inspect it. - These choices only change the sample page. + The ? opens an unanswered Ask. The light-ring check opens a recorded Outcome. + Press either to inspect its choice; neither press changes the document.

Which map should the sample team carry?

@@ -178,40 +188,19 @@

Where should the sample group meet?

-

Changed since the earlier version

+

Change Disclosure points to evidence

- Open the version picker in the top bar and use Δ beside v1. The changed - passage below gets a Change marker; press it to find the changed passage. - Choose v1 in the picker to read the earlier wording, then return to the latest - version. Turn comparison off to clear the marker. + Open the version picker in the top bar and use Δ beside v1. Opposing arrows + then appear beside the changed passage. Their light ring promises navigation: + pressing the Button points to the evidence without changing the document.

The sample walk follows the river to the footbridge.

-
-

What happens after a press

-

- Sending can briefly show a busy mark. A definitive refusal offers Retry and - Cancel; an uncertain connection keeps the same attempt pending. Those states - depend on real delivery, so this gallery does not paint pretend failures. - Sent, Picked up, and Active describe the handoff to an agent, not a second - action. -

- - - Check the sample route - Drag this card to Tried, or use its grip with Enter and the arrows. An - agent’s live work claim can put an Active Button here. - - - - -

- To ask for a refinement, comment on the sample that shows it and say what - should change. Plain experiment clicks do not request implementation work. -

-
+

+ To ask for a refinement, comment on the sample that shows it and say what should + change. Plain experiment clicks do not request implementation work. +

diff --git a/examples/corpus.html b/examples/corpus.html index ceccd4b0fc..47345ae2a4 100644 --- a/examples/corpus.html +++ b/examples/corpus.html @@ -1669,32 +1669,34 @@

Buttons, in action

- Hover for a label. Tab into a cluster to expose its Buttons, then use ← / → and - Enter. Use the contents spine to jump between samples; in a narrow window, Map - in the top bar collects the page’s items. Undo takes back a decision. A live - preview keeps your experiments on reload. + Each section explains one promise beside the live Button that makes it. Hover + for a label, or Tab into a cluster and use ← / → and Enter. Use the contents + spine to jump between samples. In a narrow window, open ⋯ in the top bar, then + Map, to collect the page’s items. A live preview keeps your experiments on + reload.

-

Accept, reject, undo

+

Action Buttons change something now

- The heavier ring means an immediate action. Try either verdict, then the Undo - beside its receipt. Each row below changes a different amount of text. + A heavier ring means the press acts immediately. The green check accepts; the + red cross rejects. Try either verdict on each change, then use the Undo that + replaces it.

- Replace. The sample meeting starts at + Replace one word. The sample meeting starts at nine.ten.

- Insert. Bring a notebook. + Insert a sentence. Bring a notebook. A pencil is useful too.

- Delete. The room is upstairs. + Delete a sentence. The room is upstairs. Please arrive an hour early. @@ -1702,18 +1704,20 @@

Accept, reject, undo

-

Edit, save, restore

+

A Disclosure opens context first

- Edit opens a field; Save commits its words; Cancel leaves them alone. While - the field is active, its completion Buttons stay exposed. + Edit has a light ring and an ellipsis because it only opens the field. Once + the field is open, Save and Cancel have heavier rings because they act now; + both stay exposed until editing ends.

 Write a different invitation here. Try a few lines to see the editor grow.

- This second draft already has a saved edit. Open Changes, inspect its history, - then Restore the older wording or edit it again. + This saved draft shows two readings beside one item. Changes + opens its versions. Picked up reports that the saved edit reached an agent; it + does not repeat the edit. Restore acts on the version you choose.

The sample workshop is in the blue room.
@@ -1721,7 +1725,12 @@

Edit, save, restore

-

Open a thread, or start one

+

Thread is another Disclosure

+

+ The speech bubble identifies a Thread. Its light ring says that pressing it + opens the conversation without changing the document. Several conversations + share one Button and add a count. +

An existing conversation. The sample agenda has room for one more topic. Open the speech-bubble Button, reply, then resolve the thread. @@ -1739,38 +1748,40 @@

Open a thread, or start one

-

Leave a mark

+

Reaction Actions leave a short verdict

- Each row starts with a different standing reaction. Its small square marks it - as selected; press the same reaction to withdraw it. To try another, focus the - row’s Button and press R, or use the comment field’s reaction control. + Each selected reaction sits beside the words it marks. Its heavy ring means + pressing it acts immediately; its square corner means the reaction is settled. + Press it to take it back. To add one, select words and press R, then choose + with the matching digit or Button.

- Settled. The spare key is with reception. + 1 · ok · settled. The spare key is with reception.

- Wrong. This sample claims Tuesday follows Thursday. + 2 · no · wrong. This sample claims Tuesday follows Thursday.

- Unclear. Bring the thing from the other place. + 3 · lost · unclear. Bring the thing from the other place.

- Too long. At this point in time, we can now begin. + 4 · cut · too long. At this point in time, we can now begin.

- Needs detail. The exercise involves a map. + 5 · more · needs detail. The exercise involves a map.

- Attention. The side entrance closes early. + 6 · this · look here. The side entrance closes early.

-

One item, many Buttons

+

Options unfolds peer Buttons in place

- The change below has a thread and all six reactions. At rest it stays compact. - Press … to unfold it, or Tab into it. When the row cannot fit every Button, - More opens the remaining controls in the Page map. Escape returns to the row. + The light-ring ⋯ is an Options Button, not a menu or dialog. Press it, or Tab + into the cluster, to expose up to six peer Buttons beside the same passage. + Use ← / → and Enter; Escape folds them. If they cannot all fit, More opens the + complete cluster in the Page Map.

Use the courtyard for the practice session.

@@ -1798,11 +1809,10 @@

One item, many Buttons

-

Asks and outcomes

+

Reading Disclosures report without acting

- A Map Button can describe an item as well as offer an action. An unanswered - choice has an Ask; a recorded choice has an Outcome. Open each to inspect it. - These choices only change the sample page. + The ? opens an unanswered Ask. The light-ring check opens a recorded Outcome. + Press either to inspect its choice; neither press changes the document.

Which map should the sample team carry?

@@ -1821,40 +1831,19 @@

Where should the sample group meet?

-

Changed since the earlier version

+

Change Disclosure points to evidence

- Open the version picker in the top bar and use Δ beside v1. The changed - passage below gets a Change marker; press it to find the changed passage. - Choose v1 in the picker to read the earlier wording, then return to the latest - version. Turn comparison off to clear the marker. + Open the version picker in the top bar and use Δ beside v1. Opposing arrows + then appear beside the changed passage. Their light ring promises navigation: + pressing the Button points to the evidence without changing the document.

The sample walk follows the river to the footbridge.

-
-

What happens after a press

-

- Sending can briefly show a busy mark. A definitive refusal offers Retry and - Cancel; an uncertain connection keeps the same attempt pending. Those states - depend on real delivery, so this gallery does not paint pretend failures. - Sent, Picked up, and Active describe the handoff to an agent, not a second - action. -

- - - Check the sample route - Drag this card to Tried, or use its grip with Enter and the arrows. An - agent’s live work claim can put an Active Button here. - - - - -

- To ask for a refinement, comment on the sample that shows it and say what - should change. Plain experiment clicks do not request implementation work. -

-
+

+ To ask for a refinement, comment on the sample that shows it and say what should + change. Plain experiment clicks do not request implementation work. +

diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index 6c8e294ffc..c95ad2fac7 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -1426,8 +1426,9 @@ and `…` when there are at least two peers. Hiding one peer costs the same fitt showing it and adds a press, so it is not overflow. With no contributed control, standing information supplies the primary disclosure Button. -The expanded budget is six fittings, including the primary. A larger set shows four -peers and a final Page-map Button whose label gives the remaining count. That opens +The expanded budget is six fittings, including the primary where one exists; a target +made only of peer choices uses all six. A larger set shows the Buttons that fit and a +final Page-map Button whose label gives the remaining count. That opens the existing Page map at the first excess action; every excess control has its own named row which performs that exact action. Do not grow another popover for overflow. The same limit and exact-action route apply when the cluster docks on a narrow screen. @@ -1462,9 +1463,12 @@ peer. A standalone page-widget claim gets an **Active** Button directly. When no the full thread panel or a widget frozen into conversation chrome—the compact `.lf-receipt` remains the local fallback. -Content modules contribute through `registerMarginItem({key, target, controls, state, -...})`; they own their verbs and events, never placement or control styling. `key` is -stable within a target. `state` is a value or live reading of `idle`, `engaged`, `busy`, +Content modules contribute through `registerMarginItem({key, target, controls, subject, +state, ...})`; they own their verbs and events, never placement or control styling. `key` +is stable within a target. Optional `subject` is a string or live reading of the concise +semantic subject used to name that target away from its own paint. Supply it only when +plain text concatenation loses a relation the widget paints visually, such as a rewrite's +`old → new`; contributions at the same target must agree. `state` is a value or live reading of `idle`, `engaged`, `busy`, `failed`, or `settled`; active states keep the owner's peers exposed. A contribution item that sets `represents` and names its `kind` is also the visible reading of that state, so the margin suppresses a generated @@ -1477,6 +1481,14 @@ by decisions, editing, communications, and information triggers. Its behavior st the promise before the press. Behavior, tone, and state are independent axes: never use a heavier border to mean positive, busy, selected, or complete. +`marginAction` also establishes the canonical Button record: key, face, label, +behavior, tone, role, and lifecycle state. Registration assigns its stable owner and +rejects duplicate Button keys within that owner. The compact rail and complete Page map +both render from this record; neither infers semantics by scraping the contributor's +painted DOM. Transient native state such as disabled and `aria-expanded` is mirrored +onto a retained proxy, while the original contributor control remains the only +activation owner. + - `action` has a uniformly heavier ring, carries an imperative verb, and performs its effect immediately; - `disclosure` has the ordinary ring, carries `aria-expanded` when it controls @@ -1561,15 +1573,25 @@ top-level block. At compact widths it returns the host to flow immediately after target's rendered text block (or the target itself). Adding another target action must not add another absolute row, control type, or rail measurement. -That ordered target collection is the Page map's complete location count and the source -for the `g m` address list. A location's disclosure Button announces its position in -the complete collection. The numbered chord exposes the collection's first nine -locations; later locations remain in the Page map and ordinary focus order rather than -making a one-digit chord ambiguous. Addressing an item opens its disclosure Button when -it has one; an action-only item receives focus on its first available action without -performing it. In the compact posture, an informational item opens the Page map sheet -at that location instead of reviving the hidden desktop preview; an action-only item -keeps its direct focus arrival on the action docked into the page. +That ordered target collection is the Page map's complete location count. A location's +disclosure Button announces its position in the collection, while `g m` and the banner's +Map control both open the same complete sheet. The collection is not a numbered address +list: a one-digit route would silently make every location after the ninth unreachable. +The sheet projects the same currently available contributed controls, in the same owner +and role order, plus readings that have no direct control. An offered reading that merely +describes its owner's controls is omitted there rather than becoming a parallel “open +action” beside the real verbs. Ordinary entry focuses the sheet's filter, so a large map +is searchable by Button name, concise target name, or the visible passage containing +that target without tabbing through every preceding action. +A spill opens this complete sheet focused on the first +control the compact cluster omitted; it does not make a smaller overflow-only menu. + +Live reconciliation retains the DOM identity of each surviving Button and each of its +hit-tested descendants, including a count badge. State-feed refreshes can arrive between +pointerdown and pointerup, so rebuilding an unchanged face would cancel the browser's +click even if its replacement had identical markup. The open Page map follows the same +rule for its groups and action proxies; a refresh updates their meaning without replacing +the control under focus or a held pointer. A thread card names the target without offering a second route to the panel the banner already opens. At wide widths it is the conversation itself, measured eight pixels @@ -1603,9 +1625,12 @@ while its rendered descendants remain usable, and a collapsed target has no rend part to offer. The `r` key unfolds this same cluster's secondary Button group for a page selection or -item. Comment, Suggest where available, and the declared reaction Buttons appear there -as peer choices; they do not widen the rail or open a separate palette below the target. -The compact response bar's Tab state stays in that bar. Conversation reactions remain in +item and shows the declared reaction Buttons together within the six-fitting budget. +Comment and Suggest retain their separate `c` and compact response-bar routes rather than +displacing reactions from the mode that explicitly asked for them. The digit register and +visible choices therefore name the same complete set. The choices do not widen the rail +or open a separate palette below the target. The compact response bar's Tab state stays in +that bar. 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. @@ -1925,10 +1950,9 @@ walks open asks. Keep these as single-key presses rather than prefix sequences; is often repeated or held. `d` and `u` move down and up by 60% of the reading page, leaving native Space free for the platform and focused controls. Other letters come from words the surface says: `w` narrows to threads waiting on the reader, while direct -destinations use an uppercase mnemonic after `g`: `g T`, `g A`, and `g L` go to -Threads, Asks, and All leaves, and `g M` enters the Page map at its roving marker. -Where the margin rail has left the compact layout, the same destination opens the complete -Page-map sheet. A key spelling something nothing on screen says is a key nobody reaches for twice. +destinations use a mnemonic after `g`: `g T`, `g A`, and `g L` go to Threads, Asks, +and All leaves, and `g m` opens the complete Page map. A key spelling something nothing +on screen says is a key nobody reaches for twice. Approval spends no page letter: its visible button is in the Tab order and takes native Enter or Space. In particular, a conditional chord mnemonic must not share its final key with a page action, or a dead destination can fall through into a different operation. @@ -2515,12 +2539,9 @@ region. ### Go-to chord -`g` opens one destination mode. `T`, `A`, `L`, and `M` complete a direct trip to -Threads, Asks, All leaves, and the Page map. The first three enter their panel or tray; -`M` focuses the map's roving marker so ArrowUp, ArrowDown, Home, and End are immediately -available, or opens the complete sheet where the compact layout has no rail or the map -has no locations. `m`, `t`, `h`, and `f` name the page's numbered -page-map item, tab, hyperlink, and fold lists, and one digit names a member. `g g` and +`g` opens one destination mode. `T`, `A`, `L`, and `m` complete a direct trip to +Threads, Asks, All leaves, and the complete Page map. `t`, `h`, and `f` name the page's +numbered tab, hyperlink, and fold lists, and one digit names a member. `g g` and `g G` complete the chord themselves, gliding to the top and bottom of the visible scroller. When a thread holds focus, `g k` and `g j` place that card at the top or bottom of its list without moving the page. From a @@ -2543,7 +2564,7 @@ The page-level `g` row promises only the mode; destinations and ranges belong to the rows inside it. Completing an address runs that list's destination: a tab selects and takes focus, a same-document hyperlink follows and leaves focus on its fragment target, an external hyperlink names the browser tab it opens, a fold opens and takes -focus, and a page-map item opens its marker or focuses its first available action. +focus. The complete Page map is a direct destination rather than a numbered list. Arming the mode shows the available direct destinations and numbered lists in the key line and paints `data-lf-goto` on the body, so the contents map can reveal its labels as diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js index b014b106e8..28e7444c56 100644 --- a/skills/leaf/assets/leaf.js +++ b/skills/leaf/assets/leaf.js @@ -130,8 +130,7 @@ * or item the pointer path uses, so the existing `c` comments on it and no second anchor * vocabulary exists. `g` arms a mode in which a mnemonic names a panel or a * document list. `g T`, `g A`, and `g L` land in Threads, Asks, and All leaves; - * `g M` enters the Page map at its roving marker, or opens its sheet when empty or compact. - * `g m 3` goes to the third page-map item in the right margin. + * `g m` opens the complete Page map through the same door as the banner's Map control. * A following digit names a member of a document list, so `g h 3` is the third * hyperlink; `g g` and `g G` are the page's top and bottom edges. * Arming shows every complete route in the key line. Pressed keys use the blue face and @@ -2001,10 +2000,8 @@ const { GO, GOTO, isChordArmed, paintAddresses, setChord } = createAddress({ keylineEl, leavesOffered, letGo, - openPageMapItem: (item) => livingMargin?.openPageMapItem(item), othersLinks, othersPanel, - pageMapItems: () => livingMargin?.pageMapItems() ?? [], pageParts, paintHere, panelCovers, @@ -2676,7 +2673,14 @@ const PAGE = { )} — for the selection, the item you are standing on, or the reply you are reading`, line: "react", when: () => reactionTokens().length > 0 && (anchoringReady || !pageSelection()), - run: () => setReact(true), + run: () => { + // Selection capture normally follows the pointer gesture in its queued turn. + // A fast `r` may arrive before that turn even though the native Selection is + // already complete. Capture it now so the command cannot advertise reaction + // digits while opening no corresponding choices. + if (pageSelection() && !fabAnchorAt()) updateFab(); + setReact(true); + }, }, // Then the two ways of choosing what to say it about. They are one press from the // shelf and named in full by the reference, which is where a capability the reader @@ -3491,6 +3495,7 @@ livingMargin = createLivingMargin({ placedAt, quietSince, renderMarginThread: conversationRuntime.renderMarginThread, + says, scrollBehavior, scrollToElement, setPanel, diff --git a/skills/leaf/assets/runtime/chrome-style.js b/skills/leaf/assets/runtime/chrome-style.js index 0bb461b07a..0beef7bae5 100644 --- a/skills/leaf/assets/runtime/chrome-style.js +++ b/skills/leaf/assets/runtime/chrome-style.js @@ -370,6 +370,9 @@ export function chromeStyle({ transform: translateY(-2px); transition: opacity 90ms ease, transform 90ms ease, visibility 0s linear 90ms; } + .lf-margin-action[data-lf-label-side] > .lf-margin-action-label { + inset: auto; left: var(--lf-label-x); top: var(--lf-label-y); + } .lf-margin-action:is(:hover, :focus-visible, .lf-focus-visible, [aria-expanded="true"]) > .lf-margin-action-label { opacity: 1; visibility: visible; transform: translateY(0); @@ -1500,7 +1503,13 @@ ${MARK_RULES} border: 1px solid var(--border-2); border-radius: 12px; background: var(--paper); color: var(--ink); box-shadow: 0 18px 54px var(--shade); } .lf-page-map-sheet::backdrop { background: color-mix(in srgb, var(--ink) 26%, transparent); } - .lf-page-map-list { margin-top: 10px; overflow: auto; max-height: calc(100vh - 110px); } + .lf-page-map-search { width: 100%; box-sizing: border-box; margin-top: 10px; + padding: 7px 9px; border: 1px solid var(--border-2); border-radius: var(--r); + background: var(--paper); color: var(--ink); font: inherit; } + .lf-page-map-search:focus-visible { outline: var(--here-ring); + --lf-here-ring: page-map-search; outline-offset: 2px; } + .lf-page-map-list { margin-top: 6px; overflow: auto; max-height: calc(100vh - 150px); } + .lf-page-map-empty { margin: 18px 8px 8px; color: var(--muted); text-align: center; } .lf-page-map-group { padding: 10px 0; border-top: 1px solid var(--rule); } .lf-page-map-group:first-child { border-top: 0; } .lf-page-map-group h3 { margin: 0 8px 4px; font-size: var(--t-5); line-height: 1.35; } diff --git a/skills/leaf/assets/runtime/composing/surface.js b/skills/leaf/assets/runtime/composing/surface.js index 96a2dac0bd..6eb5f6a54d 100644 --- a/skills/leaf/assets/runtime/composing/surface.js +++ b/skills/leaf/assets/runtime/composing/surface.js @@ -397,6 +397,14 @@ export function createSelectionSurface({ const sel = pageSelection(); const anchor = sel ? selectionAnchor(sel) : null; if (anchor?.quote.length >= MIN_QUOTE) { + // A fast keyboard action can capture this completed native selection before the + // pointer gesture's queued update arrives. That later update is the same target, + // not a request to reopen its Comment composer: reopening calls closeReactions + // and used to collapse choices immediately after `r` exposed them. + if (sameAnchor(anchor, fabAnchor)) { + placeFab(); + return; + } // Selecting words is still the browser's gesture. Open Leaf's response field beside // them without moving focus into it, so the live Selection remains available to Copy // and the native context menu. An explicit Comment press uses the same field and diff --git a/skills/leaf/assets/runtime/conversation/landing.js b/skills/leaf/assets/runtime/conversation/landing.js index dfcdc9d219..85612157f4 100644 --- a/skills/leaf/assets/runtime/conversation/landing.js +++ b/skills/leaf/assets/runtime/conversation/landing.js @@ -194,8 +194,8 @@ export function createPanelLanding({ const listNode = (id) => threadsBox.querySelector(`.lf-thread[data-id="${id}"], .lf-msg[data-mid="${id}"]`); - function revealThread(id) { - setPanel(true); + function revealThread(id, { panelReady = false } = {}) { + if (!panelReady) setPanel(true); const node = listNode(id); if (!node) return; const thread = node.closest(".lf-thread"); @@ -233,7 +233,7 @@ export function createPanelLanding({ const thread = listNode(id)?.closest(".lf-thread"); (conversationInputOf(thread) ?? thread)?.focus({ preventScroll: true }); } - revealThread(id); + revealThread(id, { panelReady: true }); } return { retainPanelLanding, revealThread, showThread }; diff --git a/skills/leaf/assets/runtime/keyboard/address.js b/skills/leaf/assets/runtime/keyboard/address.js index 11dd7199c0..3af7c3eb27 100644 --- a/skills/leaf/assets/runtime/keyboard/address.js +++ b/skills/leaf/assets/runtime/keyboard/address.js @@ -23,10 +23,8 @@ export function createAddress({ keylineEl, leavesOffered, letGo, - openPageMapItem, othersLinks, othersPanel, - pageMapItems, pageParts, paintHere, panelCovers, @@ -41,14 +39,15 @@ export function createAddress({ threadsBox, }) { // ---------- the g chord: the page's destinations ---------- - // g names one-off travel. An uppercase mnemonic completes a direct destination (`g T` - // Threads, `g A` Asks, `g L` All leaves, `g M` Page map), while a numbered list takes a + // g names one-off travel. A mnemonic completes a direct destination (`g T` Threads, + // `g A` Asks, `g L` All leaves, `g m` Page map), while a numbered list takes a // following digit (`g t 2` selects the second tab, `g h 3` follows the third hyperlink, // and `g f 2` opens the second fold). // Repeated movement through threads and asks belongs to their single-key category walks, // t/T and a/A, so those categories do not also carry numbered addresses. // - // Which numbered lists there are is this table and nothing else. The chord's scope, the chips, the + // Which numbered lists there are is this table and nothing else. The complete Page map + // is a direct destination because a one-digit list would silently truncate it. The chord's scope, the chips, the // line's words and the reference are all readings of it, so a fourth list is an entry here // rather than an edit to four consumers, and nothing that reads the table asks which list // it is holding. An entry says its letter, the word every surface calls the list by, the @@ -169,7 +168,7 @@ export function createAddress({ }, { id: "navigation.page-map", - key: "Shift+m", + key: "m", does: "Go to the Page map", line: "Page map", when: () => true, @@ -177,14 +176,6 @@ export function createAddress({ }, ]; const ADDRESSES = [ - { - id: "navigation.page-map-item", - key: "m", - word: "page-map items", - does: "Go to the nth page-map item", - list: pageMapItems, - go: openPageMapItem, - }, { id: "navigation.tab", key: "t", diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js index 09986e0985..cd64bb8b18 100644 --- a/skills/leaf/assets/runtime/living-margin.js +++ b/skills/leaf/assets/runtime/living-margin.js @@ -57,6 +57,15 @@ const ROLE_PRIORITY = new Map([ ]); const RESTING_BUTTON_BUDGET = 2; const EXPANDED_BUTTON_BUDGET = 6; +const BUTTON_RECORD = Symbol("Leaf Button record"); +const FORWARDED_BUTTON_ATTRIBUTES = [ + "aria-busy", + "aria-controls", + "aria-disabled", + "aria-expanded", + "aria-haspopup", + "aria-pressed", +]; // Built-in Button faces use one stroked, currentColor icon vocabulary. Reaction tokens // are authored content and may still supply a glyph; platform emoji never supplies a @@ -101,6 +110,52 @@ const changedOffers = () => { for (const listener of offerListeners) listener(); }; +const visibleButtonLabel = ({ behavior, label }) => + behavior === "action" || label.endsWith("…") ? label : `${label}…`; + +function buttonRecord(control) { + const record = control?.[BUTTON_RECORD]; + if (!record) throw new TypeError("A contributed Button must use marginAction"); + return record; +} + +function marginControls(controls) { + if (!(controls instanceof Element)) return []; + if (controls.matches(".lf-margin-action")) return [controls]; + return [...controls.querySelectorAll(".lf-margin-action")]; +} + +function validateMarginControls(offered) { + const keys = new Set(); + for (const control of marginControls(offered.controls)) { + const record = buttonRecord(control); + if (keys.has(record.key)) + throw new TypeError( + `Duplicate Button key "${record.key}" in margin item "${offered.key}"`, + ); + if (record.owner && record.owner !== offered.key) + throw new TypeError( + `Button "${record.key}" already belongs to margin item "${record.owner}"`, + ); + keys.add(record.key); + record.owner = offered.key; + control.dataset.lfButtonOwner = offered.key; + } +} + +function syncForwardedButtonState(projection, source) { + const label = source.getAttribute("aria-label"); + if (label == null) projection.removeAttribute("aria-label"); + else projection.setAttribute("aria-label", label); + projection.disabled = + source.disabled || source.getAttribute("aria-disabled") === "true"; + for (const attribute of FORWARDED_BUTTON_ATTRIBUTES) { + const value = source.getAttribute(attribute); + if (value == null) projection.removeAttribute(attribute); + else projection.setAttribute(attribute, value); + } +} + // One Button grammar for every gesture in a target's RHS cluster. Contributors keep // their verbs and events; the margin owns the behavior and anatomy that make the // controls one family. The visible word remains in the DOM as a transient label, so @@ -132,14 +187,25 @@ export function marginAction( throw new TypeError(`Unknown margin-action behavior: ${behavior}`); if (!ACTION_ROLES.has(role)) throw new TypeError(`Unknown margin-action role: ${role}`); - const labelText = String(label); + const record = control[BUTTON_RECORD] ?? {}; + Object.assign(record, { + key: String(key), + glyph: glyph == null ? null : String(glyph), + icon, + label: String(label), + behavior, + tone, + role, + state, + }); + control[BUTTON_RECORD] = record; control.classList.add("lf-margin-action"); control.removeAttribute("title"); - control.dataset.lfButtonKey = String(key); - control.dataset.lfBehavior = behavior; - control.dataset.lfTone = tone; - control.dataset.lfRole = role; + control.dataset.lfButtonKey = record.key; + control.dataset.lfBehavior = record.behavior; + control.dataset.lfTone = record.tone; + control.dataset.lfRole = record.role; marginActionState(control, state); if (behavior !== "action" && !control.hasAttribute("aria-expanded")) control.setAttribute("aria-expanded", "false"); @@ -150,34 +216,65 @@ export function marginAction( let spaceNode = control.querySelector(":scope > .lf-margin-action-space"); let labelNode = control.querySelector(":scope > .lf-margin-action-label"); if (icon) { - glyphNode = iconElement(icon); + if (!(glyphNode instanceof SVGSVGElement) || glyphNode.dataset.lfIcon !== icon) + glyphNode = iconElement(icon); } else { if (!(glyphNode instanceof HTMLSpanElement)) glyphNode = document.createElement("span"); - glyphNode.className = "lf-margin-action-glyph"; - glyphNode.removeAttribute("data-lf-icon"); - glyphNode.textContent = glyph; + if (glyphNode.className !== "lf-margin-action-glyph") + glyphNode.className = "lf-margin-action-glyph"; + if (glyphNode.hasAttribute("data-lf-icon")) + glyphNode.removeAttribute("data-lf-icon"); + if (glyphNode.textContent !== glyph) glyphNode.textContent = glyph; } if (!spaceNode) spaceNode = document.createElement("span"); if (!labelNode) labelNode = document.createElement("span"); - glyphNode.setAttribute("aria-hidden", "true"); - spaceNode.className = "lf-margin-action-space"; - spaceNode.setAttribute("aria-hidden", "true"); - spaceNode.textContent = " "; - labelNode.className = "lf-margin-action-label"; - labelNode.textContent = - behavior === "action" || labelText.endsWith("…") ? labelText : `${labelText}…`; - control.replaceChildren(glyphNode, spaceNode, labelNode); + if (glyphNode.getAttribute("aria-hidden") !== "true") + glyphNode.setAttribute("aria-hidden", "true"); + if (spaceNode.className !== "lf-margin-action-space") + spaceNode.className = "lf-margin-action-space"; + if (spaceNode.getAttribute("aria-hidden") !== "true") + spaceNode.setAttribute("aria-hidden", "true"); + if (spaceNode.textContent !== " ") spaceNode.textContent = " "; + if (labelNode.className !== "lf-margin-action-label") + labelNode.className = "lf-margin-action-label"; + const visibleLabel = visibleButtonLabel(record); + if (labelNode.textContent !== visibleLabel) labelNode.textContent = visibleLabel; + // A reading with several members adds one retained count badge. It remains part of + // the same hit target, so a heartbeat between pointerdown and pointerup must preserve + // it along with the icon and label. + const countNode = control.querySelector(":scope > .lf-margin-count"); + const anatomy = [glyphNode, spaceNode, labelNode, ...(countNode ? [countNode] : [])]; + if ( + control.childNodes.length !== anatomy.length || + anatomy.some((node, index) => control.childNodes[index] !== node) + ) + control.replaceChildren(...anatomy); if (!control.hasAttribute("aria-label")) - control.setAttribute("aria-label", labelText); + control.setAttribute("aria-label", record.label); return control; } +function syncActionCount(control, count) { + let badge = control.querySelector(":scope > .lf-margin-count"); + if (count <= 1) { + badge?.remove(); + return; + } + if (!badge) badge = document.createElement("span"); + if (badge.className !== "lf-margin-count") badge.className = "lf-margin-count"; + if (badge.getAttribute("aria-hidden") !== "true") + badge.setAttribute("aria-hidden", "true"); + if (badge.textContent !== String(count)) badge.textContent = count; + if (control.lastChild !== badge) control.append(badge); +} + export function marginActionState(control, state) { if (!(control instanceof Element) || !control.classList.contains("lf-margin-action")) throw new TypeError("A margin-action state needs a margin action"); if (!ACTION_STATES.has(state)) throw new TypeError(`Unknown margin-action state: ${state}`); + buttonRecord(control).state = state; control.dataset.lfState = state; if (state === "busy") control.setAttribute("aria-busy", "true"); else control.removeAttribute("aria-busy"); @@ -189,6 +286,7 @@ export function registerMarginItem({ target, controls, items = () => [], + subject = null, state = "idle", side = "before", claim = true, @@ -199,6 +297,8 @@ export function registerMarginItem({ throw new TypeError(`Unknown margin-item side: ${side}`); if (typeof state !== "string" && typeof state !== "function") throw new TypeError("A margin item's state must be a string or function"); + if (subject != null && typeof subject !== "string" && typeof subject !== "function") + throw new TypeError("A margin item's subject must be a string or function"); if (typeof state === "string" && !ACTION_STATES.has(state)) throw new TypeError(`Unknown margin-item state: ${state}`); if (controls instanceof Element) controls.classList.add("lf-margin-contribution"); @@ -207,15 +307,18 @@ export function registerMarginItem({ target, controls, items, + subject, state, side, claim, reserve, }; + validateMarginControls(offered); offeredItems.add(offered); changedOffers(); return { update({ immediate = false } = {}) { + validateMarginControls(offered); changedOffers(); if (immediate) layoutMarginRows(); }, @@ -225,6 +328,11 @@ export function registerMarginItem({ // guard is active. Removing it first can synchronously fire focusout, whose // fold render moves that same node before Element.remove completes. changedOffers(); + for (const control of marginControls(controls)) { + const record = buttonRecord(control); + if (record.owner === offered.key) delete record.owner; + control.removeAttribute("data-lf-button-owner"); + } controls?.remove(); }, }; @@ -334,6 +442,7 @@ export function createLivingMargin(dependencies) { placedAt, quietSince, renderMarginThread, + says, scrollBehavior, scrollToElement, setPanel, @@ -380,14 +489,8 @@ export function createLivingMargin(dependencies) { const marginHeld = toolbar.contains(document.activeElement) || preview.contains(document.activeElement); - const sheetHeld = sheet.contains(document.activeElement); if (compact.matches && preview.matches(":popover-open")) closePreview(false); if (compact.matches && marginHeld) requestAnimationFrame(() => focusMapControl()); - if (!compact.matches && sheet.open) { - sheetActivation = true; - sheet.close(); - if (sheetHeld) requestAnimationFrame(() => focusMapControl()); - } render(); } // One seat in the banner's one order, taken once: the map stands with the page's other @@ -424,14 +527,20 @@ export function createLivingMargin(dependencies) { sheetClose.type = "button"; sheetClose.onclick = () => sheet.close(); sheetHead.append(sheetClose); + const sheetSearch = el("input", "lf-page-map-search"); + sheetSearch.type = "search"; + sheetSearch.placeholder = "Find a Button or location"; + sheetSearch.setAttribute("aria-label", "Find a Button or location in Page map"); const sheetList = el("div", "lf-page-map-list"); - sheet.append(sheetHead, sheetList); + const sheetEmpty = el("p", "lf-page-map-empty", "No matching Buttons or locations"); + sheetEmpty.hidden = true; + sheetEmpty.setAttribute("role", "status"); + sheet.append(sheetHead, sheetSearch, sheetList, sheetEmpty); chromeRoot.append(sheet); const rows = new Map(); const moreButtons = new Map(); const spillButtons = new Map(); - const spilledOptions = new Map(); const optionGroups = new Map(); const controlProxies = new WeakMap(); const readingButtons = new Map(); @@ -462,12 +571,7 @@ export function createLivingMargin(dependencies) { .getPropertyValue("--lf-thread-beside") .trim() === "1"; const markerNeedsPreview = (entry) => primaryReading(entry)?.kind === "comment"; - const controlsOf = (offered) => { - const controls = offered.controls; - if (!(controls instanceof Element)) return []; - if (controls.matches(".lf-margin-action")) return [controls]; - return [...controls.querySelectorAll(".lf-margin-action")]; - }; + const controlsOf = (offered) => marginControls(offered.controls); const offerReadings = (offered) => { const items = typeof offered.items === "function" ? offered.items() : offered.items; return items ?? []; @@ -526,13 +630,13 @@ export function createLivingMargin(dependencies) { STATE_PRIORITY.get(offerState(right.offered)); if (state) return state; const role = - ROLE_PRIORITY.get(left.control.dataset.lfRole) - - ROLE_PRIORITY.get(right.control.dataset.lfRole); + ROLE_PRIORITY.get(buttonRecord(left.control).role) - + ROLE_PRIORITY.get(buttonRecord(right.control).role); if (role) return role; const offer = left.offered.key.localeCompare(right.offered.key); if (offer) return offer; - return left.control.dataset.lfButtonKey.localeCompare( - right.control.dataset.lfButtonKey, + return buttonRecord(left.control).key.localeCompare( + buttonRecord(right.control).key, ); }; const directControlRecords = (entry) => @@ -779,7 +883,9 @@ export function createLivingMargin(dependencies) { group = { key, target, - title: trimmed([word, itemSays(target)].filter(Boolean).join(" · "), 72), + word, + subject: null, + title: null, items: [], offers: [], }; @@ -942,14 +1048,21 @@ export function createLivingMargin(dependencies) { `Duplicate margin-item key for ${target.id || targetPath(target)}: ${offered.key}`, ); group.offers.push(offered); - for (const control of controlsOf(offered)) - control.dataset.lfButtonOwner = offered.key; + const subject = + typeof offered.subject === "function" ? offered.subject() : offered.subject; + if (String(subject ?? "").trim()) { + if (group.subject && group.subject !== String(subject).trim()) + throw new TypeError( + `Conflicting margin-item subjects for ${target.id || targetPath(target)}`, + ); + group.subject = String(subject).trim(); + } const items = typeof offered.items === "function" ? offered.items() : offered.items; for (const item of items ?? []) { const kind = item.kind ?? "action"; if (!KINDS[kind]) throw new TypeError(`Unknown margin-item kind: ${kind}`); - group.items.push({ marker: false, ...item, kind }); + group.items.push({ marker: false, ...item, owner: offered.key, kind }); } } @@ -962,6 +1075,12 @@ export function createLivingMargin(dependencies) { ); return { ...group, + title: trimmed( + [group.word, group.subject ?? itemSays(group.target)] + .filter(Boolean) + .join(" · "), + 72, + ), items: group.items .filter( (item) => @@ -1040,6 +1159,15 @@ export function createLivingMargin(dependencies) { // positioned widget happens to leave enough local room for the absolute // prototype; that accident must not give one nested target a desktop posture. hangs: () => !compact.matches, + // A wide row is hoisted into main's positioning context. If its live width no + // longer fits the rail, move the same node beside its target before static flow + // takes over; restore the hoist before measuring whether it fits again. + float: (item) => { + if (item.lfEntry?.offers.length) moveExternalHost(item, false); + }, + dock: (item) => { + if (item.lfEntry?.offers.length) moveExternalHost(item, true); + }, place: (item, column) => { const target = item.lfEntry?.target; if (!target) return; @@ -1062,23 +1190,13 @@ export function createLivingMargin(dependencies) { return [...counts].map(([kind, count]) => ({ kind, count, ...KINDS[kind] })); } - function markerName(entry, index, anchored) { + function markerName(entry, index, anchored, position) { const choice = primaryReading(entry); const face = markerFace(entry).face; const count = choice?.items.length ?? 0; const reading = `${face.label}${count > 1 ? `s (${count})` : ""}`; const subject = count === 1 && choice.items[0].acknowledgmentFace ? choice.text : entry.title; - const main = document.querySelector("main"); - const position = - entry.target && main?.scrollHeight - ? Math.round( - ((entry.target.getBoundingClientRect().top - - main.getBoundingClientRect().top) / - main.scrollHeight) * - 100, - ) - : null; return `${reading}, ${index + 1} of ${anchored}, ${subject}${position == null ? "" : `, ${Math.max(0, Math.min(100, position))} percent down`}`; } @@ -1095,10 +1213,6 @@ export function createLivingMargin(dependencies) { }); } - function pageMapItems() { - return pageMapEntries.map((entry) => hosts.get(entry.key)).filter(Boolean); - } - function clusterButtons(host) { if (!host) return []; return [...host.querySelectorAll(".lf-margin-action")].filter( @@ -1131,31 +1245,6 @@ export function createLivingMargin(dependencies) { }); } - function openPageMapItem(item) { - const entry = item?.lfEntry; - if (!entry?.target) return; - scrollToElement(entry.target, undefined, "nearest"); - const marker = rows.get(entry.key); - if (marker && !marker.hidden) { - if (compact.matches) openSheet(entry); - else { - // A pointer focuses the marker before its click. Reproduce that arrival, then let - // the control's own click remain the one semantic path into its preview. - marker.focus({ preventScroll: true }); - marker.click(); - } - return; - } - const action = [...item.querySelectorAll(".lf-margin-action")].find( - (control) => - control !== marker && - !control.disabled && - !control.hidden && - control.checkVisibility(), - ); - if (action) focusForNavigation(action); - } - function setOptionsOpen( entry, open, @@ -1203,33 +1292,27 @@ export function createLivingMargin(dependencies) { const entry = pageMapEntries.find((candidate) => candidate.target === target); const more = entry && moreButtons.get(entry.key); if (!entry || !more) return false; - if (expandedOptionsKey === entry.key) return true; + if (expandedOptionsKey === entry.key) { + const options = optionGroups.get(entry.key); + if (options?.isConnected && !options.hidden) return true; + expandedOptionsKey = null; + render(); + } if (more.hidden) return false; setOptionsOpen(entry, true); return true; } - // Enter the rail without opening one addressed item. The roving marker is already the - // Page map's reading position; prefer its visible member so the Arrow/Home/End scope is - // live on arrival, and repair the tab stop when an earlier layout has not painted one. - function focusPageMap() { - const available = availableRows(); - if (!available.length) return false; - const visible = visibleRows(); - const candidates = visible.length ? visible : available; - const next = candidates.find((row) => row.tabIndex === 0) ?? candidates[0]; - if (!visible.length && next.lfEntry?.target) - scrollToElement(next.lfEntry.target, "instant", "nearest"); - holdTabStop(next); - next.focus({ preventScroll: true }); - return true; + // One destination opens the complete Page map at every width. The rail is a compact + // projection of that map, not a second destination with a one-digit address ceiling. + function enterPageMap() { + openSheet(); } - // The Page map has one capability and two responsive surfaces. The margin rail is the - // direct reading position where it has a member; the sheet is the complete map where - // the rail has deliberately left the layout or the map is empty. - function enterPageMap() { - if (!focusPageMap()) openSheet(); + function pageMapInvoker() { + const shelf = mapButton.closest(".lf-banner-menu"); + if (shelf?.lfInvoker?.checkVisibility()) return shelf.lfInvoker; + return mapButton; } function focusMapControl(entry = null) { @@ -1345,7 +1428,7 @@ export function createLivingMargin(dependencies) { }, ]; - function paintMarker(row, entry, index, anchored, primary) { + function paintMarker(row, entry, primary) { const { kinds: markerKinds, face, label, count: markerCount } = markerFace(entry); const choice = primaryReading(entry); row.lfEntry = entry; @@ -1359,44 +1442,52 @@ export function createLivingMargin(dependencies) { role: "reading", state: readingState(choice), }); - // Where this marker stands in the walk places it for a reader listening, and reads - // as progress if it were painted. The name carries it; the visible word does not. - row.setAttribute("aria-label", markerName(entry, index, anchored)); syncThreadRelation(row, markerNeedsPreview(entry)); row.removeAttribute("aria-pressed"); - if (markerCount > 1) { - const count = el("span", "lf-margin-count"); - count.setAttribute("aria-hidden", "true"); - count.textContent = markerCount; - row.append(count); - } + syncActionCount(row, markerCount); if (row.lfTakeFocus) { delete row.lfTakeFocus; (row.hidden ? document.body : row).focus({ preventScroll: true }); } } - function externalPerch(target, main) { + function externalPerch(target, main, flow = compact.matches) { if (!main) return target; - // A wide item must be a child of main's own positioning context. In the - // compact flow it belongs immediately after the rendered block that owns its - // target: hoisting every item to a common section makes controls for its first - // paragraph appear after the section's last one. A declared shadow tree still - // contributes to that one document-owned layer: climb through its host before - // placing the item, otherwise the document stylesheet cannot give a plug-in's - // controls the common action shape. - let perch = compact.matches ? (blockAt(target) ?? target) : target; + // A hanging item must be a child of main's own positioning context. In flow it + // belongs immediately after the rendered block that owns its target. A declared + // shadow tree still contributes through its host, where document CSS can reach the + // controls. + let perch = flow ? (blockAt(target) ?? target) : target; while (!main.contains(perch)) { const root = perch.getRootNode(); if (!(root instanceof ShadowRoot)) return target; perch = root.host; } - if (compact.matches) return perch; + if (flow) return perch; while (perch.parentElement !== main && main.contains(perch.parentElement)) perch = perch.parentElement; return perch; } + function moveExternalHost(host, flow) { + const main = document.querySelector("main"); + const target = host.lfEntry?.target; + if (!main || !target || compact.matches) return; + const perch = externalPerch(target, main, flow); + let after = perch; + for (const entry of pageMapEntries) { + const candidate = hosts.get(entry.key); + if (candidate === host) break; + if ( + candidate?.isConnected && + externalPerch(entry.target, main, flow) === perch && + candidate.parentNode === perch.parentNode + ) + after = candidate; + } + if (after.nextSibling !== host) moveHost(host, () => after.after(host)); + } + function optionControlNode(control, entry) { let node = controlProxies.get(control); if (!node) { @@ -1404,41 +1495,19 @@ export function createLivingMargin(dependencies) { node.type = "button"; controlProxies.set(control, node); } - const icon = control.querySelector(":scope > .lf-margin-action-icon")?.dataset - .lfIcon; - const glyph = control.querySelector( - ":scope > .lf-margin-action-glyph", - )?.textContent; - const label = - control.querySelector(":scope > .lf-margin-action-label")?.textContent || - control.textContent.trim() || - "Action"; + const record = buttonRecord(control); marginAction(node, { - key: `${control.dataset.lfButtonKey}:proxy`, - ...(icon ? { icon } : { glyph: glyph || "·" }), - label, - behavior: control.dataset.lfBehavior || "action", - tone: control.dataset.lfTone || "neutral", - role: control.dataset.lfRole || "secondary", - state: control.dataset.lfState || "idle", + key: `${record.key}:proxy`, + ...(record.icon ? { icon: record.icon } : { glyph: record.glyph }), + label: record.label, + behavior: record.behavior, + tone: record.tone, + role: record.role, + state: record.state, }); - node.setAttribute("aria-label", control.getAttribute("aria-label") || label); + syncForwardedButtonState(node, control); node.lfForwardedControl = control; - node.dataset.lfButtonOwner = control.dataset.lfButtonOwner; - node.disabled = - control.disabled || control.getAttribute("aria-disabled") === "true"; - for (const attribute of [ - "aria-busy", - "aria-controls", - "aria-disabled", - "aria-expanded", - "aria-haspopup", - "aria-pressed", - ]) { - const value = control.getAttribute(attribute); - if (value == null) node.removeAttribute(attribute); - else node.setAttribute(attribute, value); - } + node.dataset.lfButtonOwner = record.owner; node.onclick = () => { control.click(); }; @@ -1472,12 +1541,7 @@ export function createLivingMargin(dependencies) { "aria-label", `${label} for ${entry.title}${count > 1 ? `, ${count} items` : ""}`, ); - if (count > 1) { - const badge = el("span", "lf-margin-count"); - badge.setAttribute("aria-hidden", "true"); - badge.textContent = count; - node.append(badge); - } + syncActionCount(node, count); node.onclick = () => { if (node.lfChoice.kind !== "comment") { setOptionsOpen(node.lfEntry, false, { returnFocus: true }); @@ -1510,7 +1574,10 @@ export function createLivingMargin(dependencies) { function syncOptionGroup(group, entry, primary, optionsOpen) { const allNodes = optionNodes(entry, primary); const unique = [...new Set(allNodes)]; - const peerCapacity = Math.max(0, EXPANDED_BUTTON_BUDGET - 1); + // An option-only target has no primary fitting outside this group. Let its peers use + // the whole cluster budget instead of reserving a ghost seventh position; reaction + // mode is the common case, where its six declared choices should fit exactly. + const peerCapacity = Math.max(0, EXPANDED_BUTTON_BUDGET - (primary ? 1 : 0)); const needsSpill = unique.length > peerCapacity; // The spill route consumes the last visible fitting; it does not increase the // cluster beyond its budget. A fully expanded cluster is therefore either one @@ -1535,7 +1602,6 @@ export function createLivingMargin(dependencies) { ); wanted.push(offered.controls); } - spilledOptions.set(entry.key, unique.slice(visibleCapacity)); let spill = spillButtons.get(entry.key); if (needsSpill) { if (!spill) { @@ -1552,6 +1618,7 @@ export function createLivingMargin(dependencies) { state: "idle", }); spill.dataset.lfSpillCount = String(hidden); + spill.lfFirstSpilledOption = unique[visibleCapacity]; spill.setAttribute("aria-label", `Show ${hidden} more in Page map`); spill.onclick = () => openSheet(entry, { invoker: spill, focusSpill: true }); wanted.push(spill); @@ -1678,8 +1745,92 @@ export function createLivingMargin(dependencies) { function moveHost(host, move) { const held = host.contains(document.activeElement) ? document.activeElement : null; - move(); - if (held?.isConnected) held.focus({ preventScroll: true }); + // Moving a focused expanded cluster between the hanging rail and document flow + // synchronously emits focusout. That is a placement transition, not the reader + // leaving the cluster, so keep the options state machine from treating it as an + // instruction to fold the controls it just exposed. + const wasSettlingOptionsFocus = settlingOptionsFocus; + settlingOptionsFocus = true; + try { + move(); + if (held?.isConnected) held.focus({ preventScroll: true }); + } finally { + settlingOptionsFocus = wasSettlingOptionsFocus; + } + } + + const labelRect = (name, left, top, label) => ({ + name, + rect: { + left, + right: left + label.width, + top, + bottom: top + label.height, + }, + }); + + const rectsOverlap = (left, right) => + left.left < right.right && + left.right > right.left && + left.top < right.bottom && + left.bottom > right.top; + + function placeButtonLabel(control) { + const label = control.querySelector(":scope > .lf-margin-action-label"); + if (!label || !control.checkVisibility()) return; + const buttonBox = control.getBoundingClientRect(); + const labelBox = label.getBoundingClientRect(); + const edgeAligned = Math.max( + 4, + Math.min(buttonBox.right - labelBox.width, innerWidth - 4 - labelBox.width), + ); + const cluster = control.closest(".lf-margin-item") ?? control.parentElement; + const clusterButtons = [...(cluster?.querySelectorAll(".lf-margin-action") ?? [])] + .filter((candidate) => candidate.checkVisibility()) + .map((candidate) => candidate.getBoundingClientRect()); + const clusterLeft = Math.min(...clusterButtons.map((box) => box.left)); + const clusterRight = Math.max(...clusterButtons.map((box) => box.right)); + const centered = (buttonBox.top + buttonBox.bottom - labelBox.height) / 2; + const candidates = [ + labelRect("below", edgeAligned, buttonBox.bottom + 6, labelBox), + labelRect("above", edgeAligned, buttonBox.top - 6 - labelBox.height, labelBox), + labelRect("after", clusterRight + 6, centered, labelBox), + labelRect("before", clusterLeft - 6 - labelBox.width, centered, labelBox), + ]; + const blockers = [ + ...[...document.querySelectorAll(".lf-margin-action")].filter( + (candidate) => candidate !== control && candidate.checkVisibility(), + ), + ...document.querySelectorAll(".lf-banner, .lf-keyline"), + ].map((candidate) => candidate.getBoundingClientRect()); + const fits = ({ rect }) => + rect.left >= 4 && + rect.right <= innerWidth - 4 && + rect.top >= 4 && + rect.bottom <= innerHeight - 4; + const choice = + candidates.find( + (candidate) => + fits(candidate) && + !blockers.some((blocker) => rectsOverlap(candidate.rect, blocker)), + ) ?? + candidates.find(fits) ?? + candidates[0]; + control.dataset.lfLabelSide = choice.name; + label.style.setProperty("--lf-label-x", `${choice.rect.left - buttonBox.left}px`); + label.style.setProperty("--lf-label-y", `${choice.rect.top - buttonBox.top}px`); + } + + let labelPlacementFrame = 0; + function scheduleButtonLabels() { + if (labelPlacementFrame) return; + labelPlacementFrame = requestAnimationFrame(() => { + labelPlacementFrame = 0; + for (const control of document.querySelectorAll( + '.lf-margin-action:is(:hover, :focus-visible, .lf-focus-visible, [aria-expanded="true"])', + )) + placeButtonLabel(control); + }); } function unfoldOpenThreadOwner(entry) { @@ -1709,7 +1860,8 @@ export function createLivingMargin(dependencies) { transferThreadFocus = false; const main = document.querySelector("main"); if (!nav.isConnected) chromeRoot.append(nav); - measureMargin(main?.getBoundingClientRect())?.(); + const mainRect = main?.getBoundingClientRect(); + measureMargin(mainRect)?.(); syncInlineOffers(); pageMapEntries = collectEntries().filter((entry) => entry.target); // Read contributor visibility once for the whole render, before folding any @@ -1738,13 +1890,12 @@ export function createLivingMargin(dependencies) { rows.delete(key); moreButtons.delete(key); spillButtons.delete(key); - spilledOptions.delete(key); optionGroups.delete(key); hosts.delete(key); } const externalDocks = new Map(); let corePosition = 0; - pageMapEntries.forEach((entry, index) => { + pageMapEntries.forEach((entry) => { let marker = rows.get(entry.key); let more = moreButtons.get(entry.key); let options = optionGroups.get(entry.key); @@ -1882,7 +2033,26 @@ export function createLivingMargin(dependencies) { ); corePosition += 1; } - paintMarker(marker, entry, index, pageMapEntries.length, primary); + paintMarker(marker, entry, primary); + }); + // Geometry is one read-only batch after every row has reconciled. Reading a target + // between two marker writes forced one full document layout per Page-map entry — + // including on the two-second heartbeat. The spoken positions use the main rect + // already read above and one final scroll height, then write every name together. + const mainHeight = main?.scrollHeight ?? 0; + const positions = pageMapEntries.map((entry) => + entry.target && mainRect && mainHeight + ? Math.round( + ((entry.target.getBoundingClientRect().top - mainRect.top) / mainHeight) * + 100, + ) + : null, + ); + pageMapEntries.forEach((entry, index) => { + const marker = rows.get(entry.key); + const name = markerName(entry, index, pageMapEntries.length, positions[index]); + if (marker?.getAttribute("aria-label") !== name) + marker?.setAttribute("aria-label", name); }); mapButton.hidden = pageMapEntries.length === 0; mapButton.textContent = `Map (${pageMapEntries.length})`; @@ -1926,6 +2096,7 @@ export function createLivingMargin(dependencies) { refreshHighlight(); scheduleMarginLayout(); scheduleRoving(); + scheduleButtonLabels(); paintKeys(); } @@ -2164,99 +2335,201 @@ export function createLivingMargin(dependencies) { return item?.querySelector("textarea") ?? null; } + function sheetControls(entry) { + const records = entry.offers + .flatMap((offered) => + controlsOf(offered) + .filter((control) => entry.shownControls.has(control)) + .map((control) => ({ control, offered })), + ) + .sort(compareControlRecords); + return [...new Set(records.map(({ control }) => control))]; + } + + function sheetItemKey(entry, item) { + return `${entry.key}:item:${item.id}`; + } + + function sheetControlKey(entry, control) { + const record = buttonRecord(control); + return `${entry.key}:${record.owner}:${record.key}`; + } + + function syncSheetFace(button, { icon, glyph, label, visibleLabel = label }) { + let face = button.querySelector(":scope > .lf-margin-kind"); + if (icon) { + if (!(face instanceof SVGSVGElement) || face.dataset.lfIcon !== icon) + face = iconElement(icon, "lf-margin-kind"); + } else { + if (!(face instanceof HTMLSpanElement)) face = document.createElement("span"); + if (face.className !== "lf-margin-kind") face.className = "lf-margin-kind"; + if (face.hasAttribute("data-lf-icon")) face.removeAttribute("data-lf-icon"); + if (face.textContent !== glyph) face.textContent = glyph; + } + let text = button.querySelector(":scope > .lf-page-map-action-label"); + if (!text) text = el("span", "lf-page-map-action-label"); + if (text.textContent !== visibleLabel) text.textContent = visibleLabel; + if ( + button.childNodes.length !== 2 || + button.childNodes[0] !== face || + button.childNodes[1] !== text + ) + button.replaceChildren(face, text); + if (button.getAttribute("aria-label") !== label) + button.setAttribute("aria-label", label); + } + + function syncSheetItem(button, entry, item) { + button.lfMapEntry = entry; + button.lfMapItem = item; + delete button.lfMapControl; + button.dataset.lfMapItem = item.id; + delete button.dataset.lfMapButton; + const label = item.text || entry.title; + syncSheetFace(button, { + icon: KINDS[item.kind].icon, + label: `Open ${KINDS[item.kind].label.toLowerCase()}: ${label}`, + visibleLabel: label, + }); + button.disabled = false; + } + + function syncSheetControl(button, entry, control) { + button.lfMapEntry = entry; + button.lfMapControl = control; + delete button.lfMapItem; + delete button.dataset.lfMapItem; + button.dataset.lfMapButton = sheetControlKey(entry, control); + const record = buttonRecord(control); + button.dataset.lfBehavior = record.behavior; + button.dataset.lfTone = record.tone; + button.dataset.lfRole = record.role; + button.dataset.lfState = record.state; + syncSheetFace(button, { + ...(record.icon ? { icon: record.icon } : { glyph: record.glyph }), + label: record.label, + visibleLabel: visibleButtonLabel(record), + }); + syncForwardedButtonState(button, control); + } + + function makeSheetAction(key) { + const button = el("button", "lf-page-map-action"); + button.type = "button"; + button.dataset.lfMapKey = key; + button.onclick = () => { + if (button.lfMapItem) { + activate(button.lfMapItem, button.lfMapEntry); + return; + } + const control = button.lfMapControl; + if (!control) return; + const from = sheetFrom; + sheetActivation = true; + sheet.close(); + // Closing the native modal is synchronous; preserve the source interaction and + // forward the press before a later state render can retire its real control. + if (from?.isConnected && from.checkVisibility()) + from.focus({ preventScroll: true }); + control.click(); + }; + return button; + } + function renderSheet() { - const focusedItem = sheet.contains(document.activeElement) - ? document.activeElement.dataset.lfMapItem - : null; - const focusedButton = sheet.contains(document.activeElement) - ? document.activeElement.dataset.lfMapButton + const active = sheet.contains(document.activeElement) + ? document.activeElement : null; const heldScroll = sheetList.scrollTop; - sheetList.replaceChildren( - ...pageMapEntries.map((entry) => { - const group = el("section", "lf-page-map-group"); - group.append(el("h3", "", entry.title)); - const actions = el("div", "lf-page-map-actions"); - for (const item of entry.items) { - const button = el("button", "lf-page-map-action"); - button.type = "button"; - button.append( - iconElement(KINDS[item.kind].icon, "lf-margin-kind"), - el("span", "", item.text || entry.title), - ); - button.setAttribute( - "aria-label", - `Open ${KINDS[item.kind].label.toLowerCase()}: ${item.text || entry.title}`, - ); - button.dataset.lfMapItem = item.id; - button.onclick = () => activate(item, entry); - actions.append(button); - } - for (const option of spilledOptions.get(entry.key) ?? []) { - const button = el("button", "lf-page-map-action"); - button.type = "button"; - const icon = option.querySelector(":scope > .lf-margin-action-icon")?.dataset - .lfIcon; - const glyph = option.querySelector( - ":scope > .lf-margin-action-glyph", - )?.textContent; - const label = - option.getAttribute("aria-label") || - option.querySelector(":scope > .lf-margin-action-label")?.textContent || - "Page action"; - button.append( - icon - ? iconElement(icon, "lf-margin-kind") - : el("span", "lf-margin-kind", glyph || "·"), - el("span", "", label), - ); - button.setAttribute("aria-label", label); - button.disabled = - option.disabled || option.getAttribute("aria-disabled") === "true"; - button.dataset.lfMapButton = `${entry.key}:${option.dataset.lfButtonOwner ?? "reading"}:${option.dataset.lfButtonKey}`; - button.onclick = () => { - const from = sheetFrom; - sheetActivation = true; - sheet.close(); - requestAnimationFrame(() => { - // Spilled Buttons have no on-page box to own a floating thread card. - // The Page map already supplies the named route into the conversation. - if (option.lfChoice?.kind === "comment") - openThreads(option.lfChoice.items, entry); - else { - // Keep the source interaction alive until its own action consumes it. - // Moving to the map marker first would discard temporary responses and - // their anchor before this proxy could press the original Button. - if (from?.isConnected && from.checkVisibility()) - from.focus({ preventScroll: true }); - option.click(); - } - }); - }; - actions.append(button); - } - group.append(actions); - return group; - }), + const groups = new Map( + [...sheetList.children].map((group) => [group.dataset.lfMapGroup, group]), ); - sheetList.scrollTop = heldScroll; - if (focusedItem || focusedButton) { - const replacement = [ - ...sheetList.querySelectorAll("[data-lf-map-item], [data-lf-map-button]"), - ].find( - (candidate) => - (focusedItem && candidate.dataset.lfMapItem === focusedItem) || - (focusedButton && candidate.dataset.lfMapButton === focusedButton), + const wantedGroups = []; + for (const entry of pageMapEntries) { + let group = groups.get(entry.key); + if (!group) { + group = el("section", "lf-page-map-group"); + group.dataset.lfMapGroup = entry.key; + group.append(el("h3"), el("div", "lf-page-map-actions")); + } + const heading = group.querySelector(":scope > h3"); + if (heading.textContent !== entry.title) heading.textContent = entry.title; + const actions = group.querySelector(":scope > .lf-page-map-actions"); + const existing = new Map( + [...actions.children].map((button) => [button.dataset.lfMapKey, button]), + ); + const controls = sheetControls(entry); + const controlOwners = new Set( + controls.map((control) => buttonRecord(control).owner), ); - (replacement ?? sheetClose).focus({ preventScroll: true }); + const items = entry.items.filter( + (item) => !item.owner || !controlOwners.has(item.owner), + ); + const wantedActions = []; + for (const item of items) { + const key = sheetItemKey(entry, item); + const button = existing.get(key) ?? makeSheetAction(key); + syncSheetItem(button, entry, item); + wantedActions.push(button); + } + for (const control of controls) { + const key = `control:${sheetControlKey(entry, control)}`; + const button = existing.get(key) ?? makeSheetAction(key); + syncSheetControl(button, entry, control); + wantedActions.push(button); + } + for (const child of [...actions.children]) + if (!wantedActions.includes(child)) child.remove(); + wantedActions.forEach((button, index) => { + if (actions.children[index] !== button) + actions.insertBefore(button, actions.children[index] ?? null); + }); + // A rewrite's own label says `old → new`, but the location a reader remembers + // is usually the sentence around it. Index the same text block the passage runtime + // uses for anchoring, alongside the visible Page-map labels, so a search for either + // the Button or its surrounding document words reaches this one group. + const passage = blockAt(entry.target); + group.lfMapSearch = [group.textContent, passage ? says(passage) : ""] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + wantedGroups.push(group); } + for (const child of [...sheetList.children]) + if (!wantedGroups.includes(child)) child.remove(); + wantedGroups.forEach((group, index) => { + if (sheetList.children[index] !== group) + sheetList.insertBefore(group, sheetList.children[index] ?? null); + }); + filterSheet(); + sheetList.scrollTop = heldScroll; + if (active && (!active.isConnected || !active.checkVisibility())) + sheetSearch.focus({ preventScroll: true }); + } + + function filterSheet() { + const query = sheetSearch.value.trim().toLocaleLowerCase(); + let shown = 0; + for (const group of sheetList.children) { + const matches = !query || group.lfMapSearch.includes(query); + group.hidden = !matches; + if (matches) shown += 1; + } + sheetEmpty.textContent = query + ? "No matching Buttons or locations" + : "No Buttons or locations yet"; + sheetEmpty.hidden = shown !== 0; } - function openSheet(entry = null, { invoker = mapButton, focusSpill = false } = {}) { + function openSheet(entry = null, { invoker = null, focusSpill = false } = {}) { + const from = invoker ?? pageMapInvoker(); sheetTarget = entry?.target ?? null; // The command's door owns the return route, not incidental keyboard focus. Page // addresses and the map chord use the Map toggle; overflow names its exact Button. - if (!sheet.open) sheetFrom = invoker; + if (!sheet.open) { + sheetFrom = from; + sheetSearch.value = ""; + } renderSheet(); if (!sheet.open) sheet.showModal(); const index = entry @@ -2270,13 +2543,20 @@ export function createLivingMargin(dependencies) { else if (groupBox.bottom > listBox.bottom) sheetList.scrollTop += groupBox.bottom - listBox.bottom; } + const spilled = focusSpill ? from.lfFirstSpilledOption : null; + const forwarded = spilled?.lfForwardedControl ?? spilled; const destination = focusSpill - ? group?.querySelector("[data-lf-map-button]") + ? [...(group?.querySelectorAll(".lf-page-map-action") ?? [])].find( + (button) => + button.lfMapControl === forwarded || + spilled?.lfChoice?.items.some((item) => button.lfMapItem?.id === item.id), + ) : group?.querySelector(".lf-page-map-action"); - (destination ?? sheetClose).focus({ preventScroll: true }); + (destination ?? sheetSearch).focus({ preventScroll: true }); paintKeys(); } - mapButton.onclick = () => openSheet(); + mapButton.onclick = () => openSheet(null, { invoker: pageMapInvoker() }); + sheetSearch.addEventListener("input", filterSheet); sheet.addEventListener("close", () => { const from = sheetFrom; sheetFrom = null; @@ -2323,7 +2603,12 @@ export function createLivingMargin(dependencies) { // the card beside a marker was placed once, when it opened. margin-layout.js says when // it has moved the rows, and the card follows in that same frame, so a reader never // sees it standing beside where its marker used to be. - document.addEventListener("lf-margin-layout", placeThreadPreview); + document.addEventListener("lf-margin-layout", () => { + placeThreadPreview(); + scheduleButtonLabels(); + }); + for (const event of ["pointerover", "focusin"]) + document.addEventListener(event, scheduleButtonLabels, { capture: true }); document.addEventListener( "pointerdown", (event) => { @@ -2390,8 +2675,6 @@ export function createLivingMargin(dependencies) { marginTargetAt, openButtonOptions, openInlineThread, - openPageMapItem, - pageMapItems, render, }; } diff --git a/skills/leaf/assets/runtime/margin-layout.js b/skills/leaf/assets/runtime/margin-layout.js index 6abf2f145a..1327e601c4 100644 --- a/skills/leaf/assets/runtime/margin-layout.js +++ b/skills/leaf/assets/runtime/margin-layout.js @@ -79,11 +79,12 @@ function placeRows(columnRect) { export function layoutMarginRows() { cancelAnimationFrame(pending); pending = 0; - for (const row of rows.keys()) { + for (const [row, options] of rows) { if (!row.isConnected) { rows.delete(row); continue; } + if (row.classList.contains("lf-docked")) options.float?.(row); row.classList.remove("lf-docked", "lf-waiting"); row.style.transform = ""; } @@ -138,6 +139,7 @@ export function layoutMarginRows() { if (options.fallback === "hide") row.classList.add("lf-waiting"); else { row.classList.add("lf-docked"); + options.dock?.(row); docked = true; } } else inMargin.push(row); diff --git a/skills/leaf/assets/runtime/passages.js b/skills/leaf/assets/runtime/passages.js index 771b83150d..ddc08ced9b 100644 --- a/skills/leaf/assets/runtime/passages.js +++ b/skills/leaf/assets/runtime/passages.js @@ -353,7 +353,7 @@ export function createPassages(dependencies) { // asks about where a node sits — which section, which block, which passage cell, whether // it is chrome — is asked of the page, and a climb that stops at a shadow root answers // about the widget's own markup instead. - const upFrom = (node) => node.parentElement ?? node.getRootNode()?.host ?? null; + const upFrom = (node) => node?.parentElement ?? node?.getRootNode()?.host ?? null; // contains() stops at a boundary the same way, and this is the one that decides whether a // quote is found at all: a section holding an x-shadow widget does not contain the words diff --git a/skills/leaf/assets/runtime/projection.js b/skills/leaf/assets/runtime/projection.js index 2ae59be0f4..2bf7bd566b 100644 --- a/skills/leaf/assets/runtime/projection.js +++ b/skills/leaf/assets/runtime/projection.js @@ -70,7 +70,9 @@ export function createProjection(runtime, dependencies) { const sigs = new Map(); const siblingPositions = new Map(); for (const el of [root, ...root.querySelectorAll("[id]")]) { - if (!el.id) continue; + // The protected lf- namespace belongs to generated runtime controls, not markup + // a version may author or a widget may use as its state coordinate. + if (!el.id || el.id.startsWith("lf-")) continue; const attrs = [...el.attributes] .filter((a) => !PAGE_PAINT_ATTRIBUTES.has(a.name)) .map((a) => `${a.name}=${a.value}`) @@ -81,7 +83,8 @@ export function createProjection(runtime, dependencies) { if (!positions) { positions = new Map(); for (const sibling of parent?.children ?? []) - if (sibling.id) positions.set(sibling, positions.size); + if (sibling.id && !sibling.id.startsWith("lf-")) + positions.set(sibling, positions.size); siblingPositions.set(parent, positions); } sigs.set( diff --git a/skills/leaf/assets/runtime/reactions.js b/skills/leaf/assets/runtime/reactions.js index 7b004a9024..a5952c0a20 100644 --- a/skills/leaf/assets/runtime/reactions.js +++ b/skills/leaf/assets/runtime/reactions.js @@ -137,6 +137,7 @@ export function createReactions({ let surfaceOrdinal = 0; let marginSurface = null; let marginOffer = null; + let marginComment = null; let marginSuggest = null; function buildReactSurface( surface, @@ -229,7 +230,7 @@ export function createReactions({ setReact(false); suggestHere(); }; - const marginComment = marginAction(offer("button", "lf-fab"), { + marginComment = marginAction(offer("button", "lf-fab"), { key: "comment", icon: "comment", label: "Comment", @@ -315,8 +316,17 @@ export function createReactions({ const anchor = fabAnchorAt(); const target = anchor && fabTargetAt(); if (!marginSurface || !target) return false; - marginSuggest.hidden = !anchor.quote || designIsOn(); + // `r` is an explicit reaction mode. Comment and Suggest remain their own `c` and + // response-bar routes; keeping them in this cluster would displace reaction choices + // behind More while the key line claimed all six were open. + marginComment.hidden = true; + marginSuggest.hidden = true; fabBar.dataset.lfMarginRaised = "1"; + // Register the response surface in the state it is about to show. Registering its + // collapsed face first makes the projection treat the six choices as hidden owner + // content; a fast `r` can then arm their digit shortcuts while only the old floating + // ellipsis remains on screen. + marginSurface.classList.add("lf-react-open"); paintReactionStanding( marginSurface, [...fabBar.querySelectorAll(".lf-react[aria-pressed='true']")] @@ -338,6 +348,7 @@ export function createReactions({ marginUnfolded = !standing; return true; } + marginSurface.classList.remove("lf-react-open"); marginOffer.unregister(); marginOffer = null; return false; diff --git a/skills/leaf/assets/theme.css b/skills/leaf/assets/theme.css index bd3dde0cb2..06d00f8c14 100644 --- a/skills/leaf/assets/theme.css +++ b/skills/leaf/assets/theme.css @@ -1113,6 +1113,13 @@ aside.sidenote { font-size: var(--t-5); line-height: 1.45; color: var(--muted); gap: var(--sp-1) var(--sp-3); margin: 14px 0; } .facts dt { color: var(--muted); font-weight: 400; font-size: var(--t-5); } .facts dd { margin: 0; } +@media screen and (max-width: 640px) { + /* A long term must not spend most of a phone's line on the first column and leave + its explanation as a one-word ribbon. Stack each fact while the reading column is + narrow; later terms keep the ordinary block rhythm through the row gap. */ + .facts { grid-template-columns: minmax(0, 1fr); align-items: start; } + .facts dd + dt { margin-top: var(--sp-2); } +} /* A column of quantities lines up on its last digit — the comparison a table of numbers is there for, and one tabular figures alone don't make. Said per cell, heading included, because which column holds quantities is the author's to @@ -1427,6 +1434,7 @@ lf-suggestion:not([data-lf-state]) > :is(lf-old, lf-new) > .lf-quiet { .lf-margin-item { position: absolute; z-index: 2; left: 100%; margin-left: 22px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; padding-right: var(--here-ring-room); pointer-events: auto; } +.lf-margin-item:is(:hover, :focus-within) { z-index: 3; } .lf-margin-item::before { content: ""; position: absolute; right: 100%; top: 16px; width: 18px; border-top: 1px solid var(--border-2); opacity: 0; pointer-events: none; } @@ -1444,9 +1452,6 @@ lf-suggestion:not([data-lf-state]) > :is(lf-old, lf-new) > .lf-quiet { .lf-margin-item.lf-docked { position: static; display: flex; width: fit-content; max-width: 100%; margin: 2px 0 12px; flex-wrap: wrap; white-space: normal; } .lf-margin-item.lf-docked::before { display: none; } -.lf-margin-item.lf-docked .lf-margin-action > .lf-margin-action-label { - inset-inline: 0 auto; -} .lf-margin-options { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } .lf-margin-options[hidden] { display: none; } diff --git a/skills/leaf/assets/widgets/lf-suggestion.js b/skills/leaf/assets/widgets/lf-suggestion.js index eab90cea73..16fee441bc 100644 --- a/skills/leaf/assets/widgets/lf-suggestion.js +++ b/skills/leaf/assets/widgets/lf-suggestion.js @@ -187,6 +187,11 @@ customElements.define( ? this : this.parentElement, controls: this.#row, + // The slots use tint and strike/insert paint to carry their relationship on the + // page. Away from that paint, concatenating them turns `red` → `blue` into the + // meaningless `redblue`; give the shared Page-map projection the same relation + // in words without teaching it this widget's tags. + subject: () => this.#subject(), state: () => this.#failed ? "failed" @@ -368,6 +373,15 @@ customElements.define( return text.length > 48 ? text.slice(0, 48) + "…" : text; } + #subject() { + const cut = this.querySelector(":scope > lf-old"); + const put = this.querySelector(":scope > lf-new"); + const before = cut ? says(cut) : ""; + const after = put ? says(put) : ""; + if (before && after) return `${before} → ${after}`; + return before || after || this.id; + } + accept() { return this.#decide("accept"); } diff --git a/tests/test_render_anchors.py b/tests/test_render_anchors.py index b8e3610653..7db677c747 100644 --- a/tests/test_render_anchors.py +++ b/tests/test_render_anchors.py @@ -67,6 +67,20 @@ pytestmark = pytest.mark.nightly +def test_a_missing_node_has_no_passage_location(browser, serve): + """No DOM node means no passage location, rather than a runtime error.""" + page, errors = open_page(browser, serve(SUGGESTION_PAGE)) + found = page.evaluate( + """async () => { + const {closestAcross} = await import('/runtime/passages.js'); + return closestAcross(null, 'main'); + }""" + ) + assert found is None + assert errors == [] + page.close() + + def test_the_banner_stands_where_it_says_it_does(browser, serve): """The document reserves exactly the head covered by the painted banner. diff --git a/tests/test_render_margin.py b/tests/test_render_margin.py index 3e2c5f4934..49b0d1a222 100644 --- a/tests/test_render_margin.py +++ b/tests/test_render_margin.py @@ -16,7 +16,6 @@ _traffic, _until, compare_with, - key_line, leaf_page, live_url, margins_laid_out, @@ -56,13 +55,6 @@ "anchor": {"section": "sug-refill"}, } -PAGE_MAP_ITEM_HINT = '.lf-keyline .lf-key[data-lf-commands~="navigation.page-map-item"]' - - -def address_span(count): - capped = min(count, 9) - return f"1–{capped}" if capped > 1 else "1" - def test_margin_layout_batches_the_composed_page_without_refolding_controls( browser, serve @@ -117,20 +109,75 @@ def test_margin_layout_batches_the_composed_page_without_refolding_controls( page.close() +def test_unchanged_margin_refresh_cost_is_bounded_by_refresh_count(browser, serve): + """A heartbeat refresh cannot force layout once per Page-map location.""" + corpus = next(example for example in EXAMPLES if example.stem == "corpus") + page, errors = open_page(browser, serve(corpus)) + resized(page, 1440, 900) + margins_laid_out(page) + assert page.locator(".lf-margin-item").count() >= 15 + session = page.context.new_cdp_session(page) + session.send("Performance.enable") + before = { + metric["name"]: metric["value"] + for metric in session.send("Performance.getMetrics")["metrics"] + } + refreshes = 5 + geometry_reads = page.evaluate( + """refreshes => { + const main = document.querySelector('main'); + const rect = main.getBoundingClientRect.bind(main); + let reads = 0; + main.getBoundingClientRect = () => { + reads += 1; + return rect(); + }; + for (let i = 0; i < refreshes; i++) + document.dispatchEvent(new CustomEvent('lf-actions')); + main.getBoundingClientRect = rect; + return reads; + }""", + refreshes, + ) + after = { + metric["name"]: metric["value"] + for metric in session.send("Performance.getMetrics")["metrics"] + } + session.detach() + work = { + name: after[name] - before[name] + for name in ( + "LayoutCount", + "RecalcStyleCount", + ) + } + # Browser bookkeeping can add a small number of layouts around the measured + # dispatches. The old interleaved pass forced 26 layouts and 44 style + # recalculations per refresh, so these process-independent bounds still separate + # the two architectures without turning shared-runner timing into a contract. + assert work["LayoutCount"] <= refreshes * 4, work + assert work["RecalcStyleCount"] <= refreshes * 18, work + assert geometry_reads == refreshes, geometry_reads + assert errors == [] + page.close() + + def test_a_docked_cluster_keeps_later_margin_buttons_beside_their_targets( browser, serve ): - """Docking adds page height; later hanging Buttons use that final flow.""" + """A wide cluster that has to dock stays with its target inside a section.""" fixture = leaf_page( "Mixed margin postures", - '

First target

Second target

', + '

First target

' + '

Unrelated intervening prose

' + '

Second target

', ) page, errors = open_page(browser, serve(fixture)) page.evaluate( """async () => { const {offer, marginAction, registerMarginItem} = await import('/runtime/widget-api.js'); - for (const [id, count] of [['first', 6], ['second', 1]]) { + for (const [id, count] of [['first', 8], ['second', 1]]) { const controls = document.createElement('span'); for (let i = 0; i < count; i++) controls.append( marginAction(offer('button', ''), { @@ -145,32 +192,149 @@ def test_a_docked_cluster_keeps_later_margin_buttons_beside_their_targets( ) first = page.locator('[data-lf-margin-for="first"]') second = page.locator('[data-lf-margin-for="second"]') - for width in (1440, 1000, 1440): + for width in (1440, 1200, 1000, 1440): resized(page, width, 900) margins_laid_out(page) - assert ("lf-docked" in first.get_attribute("class")) == (width == 1000) + assert ("lf-docked" in first.get_attribute("class")) == (width <= 1000) expect(second).not_to_have_class(re.compile(r"\blf-docked\b")) assert second.bounding_box()["y"] == pytest.approx( page.locator("#second").bounding_box()["y"], abs=1 ) + if width <= 1000: + assert first.evaluate( + "item => item.previousElementSibling === document.querySelector('#first')" + ), "the docked controls were severed from their target by later prose" + else: + assert first.evaluate( + "item => item.parentElement === document.querySelector('main')" + ) assert errors == [] page.close() -def expect_page_map_address(page, count, pressed): - key_line(page) - hint = page.locator(PAGE_MAP_ITEM_HINT) - expect(hint).to_have_count(1) - expect(hint).to_contain_text("page-map items") - keys = hint.locator(".lf-key-sequence > kbd") - assert keys.evaluate_all("keys => keys.map(key => key.textContent)") == [ - "g", - "m", - address_span(count), +def test_a_transient_button_label_avoids_the_next_margin_button(browser, serve): + """A tooltip moves rather than covering a neighboring Button.""" + fixture = leaf_page( + "Close margin labels", + '

First target

Second target

', + ) + page, errors = open_page(browser, serve(fixture)) + resized(page, 1440, 900) + page.evaluate( + """async () => { + const {offer, marginAction, registerMarginItem} = + await import('/runtime/widget-api.js'); + for (const id of ['first', 'second']) { + registerMarginItem({key: id, target: document.getElementById(id), + controls: marginAction(offer('button', ''), { + key: 'act', icon: 'dot', label: `Act on ${id}` + })}); + } + }""" + ) + first = page.locator('[data-lf-margin-for="first"]') + second = page.locator('[data-lf-margin-for="second"]') + button = first.get_by_role("button", name="Act on first", exact=True) + button.hover() + expect(button).to_have_attribute("data-lf-label-side", re.compile(".+")) + label = button.locator(".lf-margin-action-label") + expect(label).to_be_visible() + button_box, label_box, second_box = [ + locator.bounding_box() for locator in (button, label, second) ] - assert keys.evaluate_all("keys => keys.map(key => key.dataset.lfKeyState)") == [ - "pressed" - ] * pressed + ["neutral"] * (3 - pressed) + default_label_box = { + "x": button_box["x"] + button_box["width"] - label_box["width"], + "y": button_box["y"] + button_box["height"] + 6, + "width": label_box["width"], + "height": label_box["height"], + } + + def overlaps(left, right): + return ( + left["x"] < right["x"] + right["width"] + and left["x"] + left["width"] > right["x"] + and left["y"] < right["y"] + right["height"] + and left["y"] + left["height"] > right["y"] + ) + + assert overlaps(default_label_box, second_box), ( + "the fixture no longer exercises overlapping margin rows" + ) + assert not overlaps(label_box, second_box) + assert errors == [] + page.close() + + +@pytest.mark.parametrize("width", [1440, 390]) +def test_dense_gallery_labels_cover_no_neighboring_button(browser, serve, width): + """Every label in a tightly stacked real cluster finds a clear side.""" + example = next(path for path in EXAMPLES if path.stem == "button-gallery") + page, errors = open_page(browser, serve(example)) + resized(page, width, 900) + page.locator("#bg-neighbors").scroll_into_view_if_needed() + for target in ("bg-neighbor-a", "bg-neighbor-b", "bg-neighbor-c"): + buttons = page.locator( + f'[data-lf-margin-for="{target}"] .lf-margin-action:visible' + ) + for index in range(buttons.count()): + button = buttons.nth(index) + button.hover() + expect(button).to_have_attribute("data-lf-label-side", re.compile(".+")) + reading = button.evaluate( + """control => { + const label = control.querySelector('.lf-margin-action-label'); + const box = label.getBoundingClientRect(); + const overlap = (left, right) => left.left < right.right && + left.right > right.left && left.top < right.bottom && + left.bottom > right.top; + const neighbors = [...document.querySelectorAll('.lf-margin-action')] + .filter(candidate => candidate !== control && candidate.checkVisibility()) + .map(candidate => candidate.getBoundingClientRect()); + return {inside: box.left >= 4 && box.right <= innerWidth - 4 && + box.top >= 4 && box.bottom <= innerHeight - 4, + overlaps: neighbors.filter(neighbor => overlap(box, neighbor)).length}; + }""" + ) + assert reading == {"inside": True, "overlaps": 0} + assert errors == [] + page.close() + + +def test_an_unchanged_repaint_cannot_cancel_a_button_press(browser, serve): + """The retained Button keeps its hit-tested descendants through reconciliation.""" + comment = { + "kind": "comment", + "author": "user", + "revision": 1, + "text": "Hold this Thread Button across a state repaint.", + "anchor": {"section": "how-cap"}, + } + another = { + **comment, + "text": "Keep the count badge under the same pointer too.", + } + page, errors = open_page(browser, serve(PANEL_PAGE, events=[comment, another])) + resized(page, 1280, 900) + marker = page.locator('[data-lf-margin-for="how-cap"] > .lf-margin-marker') + icon = marker.locator(":scope > .lf-margin-action-icon") + badge = marker.locator(":scope > .lf-margin-count") + icon.evaluate("node => { window.__heldButtonIcon = node; }") + badge.evaluate("node => { window.__heldButtonBadge = node; }") + marker.evaluate( + """button => button.addEventListener('click', () => { + button.dataset.testClicks = String(Number(button.dataset.testClicks || 0) + 1); + })""" + ) + box = badge.bounding_box() + page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + page.mouse.down() + page.evaluate("() => document.dispatchEvent(new CustomEvent('lf-actions'))") + assert icon.evaluate("node => node === window.__heldButtonIcon") + assert badge.evaluate("node => node === window.__heldButtonBadge") + page.mouse.up() + expect(marker).to_have_attribute("data-test-clicks", "1") + assert errors == [] + page.close() def resized_shell(page, inline_size, height): @@ -230,7 +394,7 @@ def resized_shell(page, inline_size, height): ] -@pytest.mark.parametrize("width", [1440, 700, 390]) +@pytest.mark.parametrize("width", [1440, 1200, 700, 390]) def test_the_button_gallery_keeps_its_real_actions_reachable(browser, serve, width): """The shipped sampler stays usable after edits, verdicts, and dense overflow.""" example = next(path for path in EXAMPLES if path.stem == "button-gallery") @@ -285,9 +449,7 @@ def test_the_button_gallery_keeps_its_real_actions_reachable(browser, serve, wid if event.get("token") == "this" and event.get("anchor", {}).get("section") == "bg-crowded" ) - take_back = sheet.locator( - f'[data-lf-map-button$=":take-back:{reaction["id"]}:proxy"]' - ) + take_back = sheet.locator(f'[data-lf-map-button$=":take-back:{reaction["id"]}"]') expect(take_back).to_have_attribute("aria-label", "this — take it back") sends = _traffic(page).sends take_back.click() @@ -301,105 +463,311 @@ def test_the_button_gallery_keeps_its_real_actions_reachable(browser, serve, wid page.close() -def test_g_addresses_the_page_map_prefix_in_its_announced_order(browser, serve): - """The first nine Page-map locations keep their announced position as address.""" - page, errors = open_page(browser, serve(PAGE_MAP_PAGE, events=PAGE_MAP_EVENTS)) +def test_the_button_gallery_explains_each_button_where_it_is_used(browser, serve): + """Each Button promise and the live example that keeps it share one section.""" + example = next(path for path in EXAMPLES if path.stem == "button-gallery") + page, errors = open_page(browser, serve(example)) resized(page, 1440, 900) - marker = page.locator('[data-lf-margin-for="map-3"] > .lf-margin-marker') - address = page.evaluate( - """() => { - const marker = [...document.querySelectorAll('.lf-margin-marker')] - .find(candidate => candidate.lfEntry?.target?.id === 'map-3'); - const position = marker.getAttribute('aria-label').match(/(\\d+) of (\\d+)/); - const mapCount = document.querySelector('.lf-living-margin') - .getAttribute('aria-label').match(/(\\d+) locations/); - const targetTop = document.querySelector('#map-3') - .getBoundingClientRect().top; - return {number: Number(position[1]), count: Number(position[2]), - mapCount: Number(mapCount[1]), targetTop, - viewportHeight: innerHeight}; + expect(page.locator("#bg-grammar")).to_have_count(0) + sections = { + "bg-changes": ( + "Action Buttons change something now", + "#bg-changes-guide", + "#bg-replace", + ), + "bg-editing": ( + "A Disclosure opens context first", + "#bg-editing-guide", + "#bg-draft", + ), + "bg-conversations": ( + "Thread is another Disclosure", + "#bg-conversations-guide", + "#bg-thread-text", + ), + "bg-reactions": ( + "Reaction Actions leave a short verdict", + "#bg-reactions-guide", + "#bg-react-ok", + ), + "bg-clusters": ( + "Options unfolds peer Buttons in place", + "#bg-clusters-guide", + "#bg-crowded", + ), + "bg-readings": ( + "Reading Disclosures report without acting", + "#bg-readings-guide", + "#bg-choice-ask", + ), + "bg-version-changes": ( + "Change Disclosure points to evidence", + "#bg-version-guide", + "#bg-change", + ), + } + for section_id, (heading, guide_selector, example_selector) in sections.items(): + section = page.locator(f"#{section_id}") + expect(section.get_by_role("heading", name=heading, exact=True)).to_be_visible() + expect(section.locator(guide_selector)).to_be_visible() + expect(section.locator(example_selector)).to_be_visible() + + examples = { + "action": page.locator('[data-lf-margin-for="bg-replace"] .lf-sug-accept'), + "disclosure": page.locator( + '[data-lf-margin-for="bg-thread-text"] .lf-margin-marker' + ), + "options": page.locator('[data-lf-margin-for="bg-crowded"] > .lf-margin-more'), + "settled": page.locator('[data-lf-margin-for="bg-react-ok"] .lf-react-mark'), + } + for behavior in ("action", "disclosure", "options"): + expect(examples[behavior]).to_have_attribute("data-lf-behavior", behavior) + expect(examples["action"]).to_have_attribute("data-lf-tone", "positive") + expect(examples["settled"]).to_have_attribute("data-lf-state", "settled") + assert page.locator("#bg-reactions p strong").all_text_contents() == [ + "1 · ok · settled.", + "2 · no · wrong.", + "3 · lost · unclear.", + "4 · cut · too long.", + "5 · more · needs detail.", + "6 · this · look here.", + ] + assert errors == [] + page.close() + + +def test_margin_registration_rejects_ambiguous_button_identity(browser, serve): + """One owner plus one Button key must identify exactly one activation source.""" + page, errors = open_page(browser, serve(PANEL_PAGE)) + message = page.evaluate( + """async () => { + const {offer, marginAction, registerMarginItem} = + await import('/runtime/widget-api.js'); + const controls = document.createElement('span'); + for (const label of ['First', 'Second']) + controls.append(marginAction(offer('button', ''), { + key: 'same', icon: 'dot', label + })); + try { + registerMarginItem({ + key: 'ambiguous', target: document.querySelector('#how-cap'), controls + }); + } catch (error) { + return error.message; + } + return null; }""" ) - assert address["count"] == address["mapCount"] - assert address["count"] == 12 - assert address["number"] <= 9 - assert address["targetTop"] > address["viewportHeight"], ( - "the target must begin off screen so this proves the address is page-wide" - ) - marker.evaluate( - """control => control.addEventListener('click', () => { - control.dataset.activationClicks = - String(Number(control.dataset.activationClicks || 0) + 1); - })""" - ) + assert message == 'Duplicate Button key "same" in margin item "ambiguous"' + expect(page.locator('[data-lf-margin-for="how-cap"]')).to_have_count(0) + assert errors == [] + page.close() - def activation_state(): - return page.evaluate( - """() => { - const marker = document.querySelector( - '[data-lf-margin-for="map-3"] > .lf-margin-marker' - ); - return { - focused: document.activeElement === document.querySelector( - '.lf-margin-preview textarea'), - clicks: marker.dataset.activationClicks, - open: document.querySelector('.lf-margin-preview') - .matches(':popover-open'), - expanded: marker.getAttribute('aria-expanded'), - target: document.querySelector('#map-3') - .classList.contains('lf-margin-target'), - }; - }""" - ) - marker.click() - pointer_activation = activation_state() - assert pointer_activation == { - "focused": True, - "clicks": "1", - "open": True, +def test_open_page_map_uses_the_canonical_button_record_and_live_state(browser, serve): + """One retained proxy follows Button semantics and ARIA without owning activation.""" + page, errors = open_page(browser, serve(PANEL_PAGE)) + page.evaluate( + """async () => { + const {offer, marginAction, marginActionState, registerMarginItem} = + await import('/runtime/widget-api.js'); + const control = marginAction(offer('button', ''), { + key: 'inspect', icon: 'question', label: 'Inspect source', + behavior: 'disclosure', tone: 'negative', role: 'reading', + state: 'engaged' + }); + control.setAttribute('aria-controls', 'how-cap'); + control.setAttribute('aria-expanded', 'true'); + control.setAttribute('aria-haspopup', 'dialog'); + control.setAttribute('aria-pressed', 'true'); + control.onclick = () => window.lfCanonicalPresses += 1; + window.lfCanonicalPresses = 0; + window.lfCanonicalButton = { + control, + registration: registerMarginItem({ + key: 'fixture', target: document.querySelector('#how-cap'), controls: control + }), + update() { + marginActionState(control, 'busy'); + control.setAttribute('aria-expanded', 'false'); + control.setAttribute('aria-haspopup', 'menu'); + control.removeAttribute('aria-pressed'); + this.registration.update({immediate: true}); + } + }; + }""" + ) + page.keyboard.press("g") + page.keyboard.press("m") + sheet = page.get_by_role("dialog", name="Page map", exact=True) + proxy = sheet.get_by_role("button", name="Inspect source", exact=True) + expect(proxy).to_be_visible() + assert proxy.evaluate( + """button => ({ + behavior: button.dataset.lfBehavior, + tone: button.dataset.lfTone, + role: button.dataset.lfRole, + state: button.dataset.lfState, + label: button.querySelector('.lf-page-map-action-label').textContent, + expanded: button.getAttribute('aria-expanded'), + pressed: button.getAttribute('aria-pressed'), + popup: button.getAttribute('aria-haspopup'), + controls: button.getAttribute('aria-controls'), + })""" + ) == { + "behavior": "disclosure", + "tone": "negative", + "role": "reading", + "state": "engaged", + "label": "Inspect source…", "expanded": "true", - "target": True, + "pressed": "true", + "popup": "dialog", + "controls": "how-cap", } - marker.click() - expect(page.locator(".lf-margin-preview")).to_be_hidden() + + proxy.evaluate("button => button.dataset.stableProof = 'same-proxy'") + proxy.focus() + page.evaluate("() => window.lfCanonicalButton.update()") + expect(proxy).to_be_focused() + expect(proxy).to_have_attribute("data-stable-proof", "same-proxy") + expect(proxy).to_have_attribute("data-lf-state", "busy") + expect(proxy).to_have_attribute("aria-busy", "true") + expect(proxy).to_have_attribute("aria-expanded", "false") + expect(proxy).to_have_attribute("aria-haspopup", "menu") + expect(proxy).not_to_have_attribute("aria-pressed", re.compile(".+")) + + page.evaluate( + """() => { + const fixture = window.lfCanonicalButton; + fixture.control.setAttribute('aria-disabled', 'true'); + fixture.registration.update({immediate: true}); + }""" + ) + expect(proxy).to_be_disabled() page.evaluate( """() => { - document.body.focus(); - document.scrollingElement.scrollTo(0, 0); - document.querySelector( - '[data-lf-margin-for="map-3"] > .lf-margin-marker' - ).dataset.activationClicks = '0'; + const fixture = window.lfCanonicalButton; + fixture.control.removeAttribute('aria-disabled'); + fixture.registration.update({immediate: true}); }""" ) - expect(marker).not_to_be_focused() + expect(proxy).to_be_enabled() + proxy.click() + expect(sheet).to_be_hidden() + assert page.evaluate("() => window.lfCanonicalPresses") == 1 + assert errors == [] + page.close() + +def test_g_m_opens_the_complete_page_map_beyond_nine_locations(browser, serve): + """The Map destination never projects an arbitrary numbered prefix.""" + page, errors = open_page(browser, serve(PAGE_MAP_PAGE, events=PAGE_MAP_EVENTS)) + resized(page, 1440, 900) + before = page.evaluate("() => document.scrollingElement.scrollTop") page.keyboard.press("g") - expect_page_map_address(page, address["count"], 1) page.keyboard.press("m") - expect_page_map_address(page, address["count"], 2) - page.keyboard.press(str(address["number"])) + sheet = page.get_by_role("dialog", name="Page map", exact=True) + expect(sheet).to_be_visible() + expect(sheet.locator(".lf-page-map-group")).to_have_count(12) + expect(sheet).to_contain_text("Map note 12") + search = sheet.get_by_role( + "searchbox", name="Find a Button or location in Page map" + ) + expect(search).to_be_focused() + search.fill("Map note 12") + expect(sheet.locator(".lf-page-map-group:visible")).to_have_count(1) + expect(sheet.locator(".lf-page-map-group:visible")).to_contain_text("Map note 12") + search.fill("") + expect(sheet.locator(".lf-page-map-group:visible")).to_have_count(12) + assert page.evaluate("() => document.scrollingElement.scrollTop") == before + assert errors == [] + page.close() - preview = page.locator(".lf-margin-preview") - expect(preview).to_be_visible() - expect(preview).to_contain_text("Map note 3") - assert activation_state() == pointer_activation, ( - "the page-map address and the marker's click leave different state" + +def test_g_m_exposes_the_inline_gallery_verdicts_as_real_buttons(browser, serve): + """Late action-only targets keep their verbs in the complete Page map.""" + example = next(path for path in EXAMPLES if path.stem == "button-gallery") + page, errors = open_page(browser, serve(example)) + resized(page, 1440, 900) + page.evaluate( + """() => { + const prefix = document.createElement('span'); + prefix.textContent = 'A deliberately long introduction places the remembered words later: '; + document.querySelector('#bg-neighbors').prepend(prefix); + }""" ) - page.keyboard.press("Escape") - resized(page, 390, 760) page.keyboard.press("g") page.keyboard.press("m") - page.keyboard.press(str(address["number"])) - compact_item = page.locator(".lf-page-map-sheet .lf-page-map-action").filter( - has_text="Map note 3" + sheet = page.get_by_role("dialog", name="Page map", exact=True) + expect(sheet).to_be_visible() + for title, word in ( + ("rewrite · two → three", "three"), + ("rewrite · red → blue", "blue"), + ("rewrite · large → small", "small"), + ): + group = sheet.locator(".lf-page-map-group").filter(has_text=title) + expect( + group.get_by_role("button", name=f"Accept the suggested change: {word}") + ).to_be_visible() + expect( + group.get_by_role("button", name=f"Reject the suggested change: {word}") + ).to_be_visible() + + search = sheet.get_by_role( + "searchbox", name="Find a Button or location in Page map" + ) + search.fill("Pack") + expect(sheet.locator(".lf-page-map-group:visible")).to_have_count(3) + expect(sheet.locator(".lf-page-map-group:visible")).to_contain_text( + ["two → three", "red → blue", "large → small"] + ) + sheet.locator(".lf-page-map-group:visible").first.get_by_role( + "button", name="Accept the suggested change: three" + ).focus() + page.evaluate( + """() => { + const passage = document.querySelector('#bg-neighbors'); + const word = [...passage.childNodes].find( + node => node.nodeType === Node.TEXT_NODE && node.textContent.includes('Pack') + ); + word.textContent = word.textContent.replace('Pack', 'Carry'); + document.dispatchEvent(new CustomEvent('lf-actions')); + }""" + ) + expect(search).to_be_focused() + expect(sheet.locator(".lf-page-map-group:visible")).to_have_count(0) + expect(sheet.get_by_text("No matching Buttons or locations")).to_be_visible() + page.evaluate( + """() => { + const passage = document.querySelector('#bg-neighbors'); + const word = [...passage.childNodes].find( + node => node.nodeType === Node.TEXT_NODE && node.textContent.includes('Carry') + ); + word.textContent = word.textContent.replace('Carry', 'Pack'); + document.dispatchEvent(new CustomEvent('lf-actions')); + }""" + ) + expect(sheet.locator(".lf-page-map-group:visible")).to_have_count(3) + search.fill("red → blue") + expect(sheet.locator(".lf-page-map-group:visible")).to_have_count(1) + red = sheet.locator(".lf-page-map-group").filter(has_text="rewrite · red → blue") + reject = red.get_by_role( + "button", name="Reject the suggested change: blue", exact=True + ) + reject.evaluate("button => button.dataset.testIdentity = 'held'") + page.evaluate("() => document.dispatchEvent(new CustomEvent('lf-actions'))") + expect(reject).to_have_attribute("data-test-identity", "held") + reject.focus() + page.keyboard.press("Enter") + round_trip(page) + sent = events_model.read_events(serve.page_dir)[-1] + assert (sent["kind"], sent["widget"], sent["action"]) == ( + "action", + "bg-neighbor-b", + "reject", ) - expect(page.locator(".lf-page-map-sheet")).to_be_visible() - expect(compact_item).to_be_focused() - expect(compact_item).to_be_in_viewport() - page.keyboard.press("Escape") - expect(page.locator("#map-3")).to_be_in_viewport() assert errors == [] page.close() @@ -737,21 +1105,6 @@ def test_one_target_has_one_primary_button_and_inline_secondary_buttons(browser, page.mouse.move(0, 0) expect(edit.locator(".lf-margin-action-label")).to_be_hidden() - draft_address = draft_item.evaluate( - """item => { - const position = item.querySelector(':scope > .lf-margin-marker') - .getAttribute('aria-label').match(/(\\d+) of (\\d+)/); - return {number: Number(position[1]), count: Number(position[2])}; - }""" - ) - page.keyboard.press("g") - expect_page_map_address(page, draft_address["count"], 1) - page.keyboard.press("m") - expect_page_map_address(page, draft_address["count"], 2) - assert draft_address["number"] <= 9 - page.keyboard.press(str(draft_address["number"])) - expect(draft_item.locator(".lf-draft-pencil")).to_be_focused() - shapes = page.locator( ".lf-sug-accept:visible, .lf-draft-pencil:visible, .lf-margin-more:visible, " ".lf-margin-marker:visible" @@ -909,12 +1262,6 @@ def test_one_target_has_one_primary_button_and_inline_secondary_buttons(browser, sent = events_model.read_events(serve.page_dir)[-1] assert sent["token"] == "ok" and sent["anchor"] == {"section": "sug-refill"} - page.keyboard.press("g") - page.keyboard.press("m") - page.keyboard.press(str(draft_address["number"])) - expect(draft_item.locator(".lf-draft-pencil")).to_be_focused() - expect(page.locator(".lf-page-map-sheet")).to_be_hidden() - assert errors == [] page.close() @@ -1195,7 +1542,7 @@ def test_a_spilled_thread_opens_the_full_conversation_without_a_hidden_anchor( item = page.locator('[data-lf-margin-for="sug-refill"]') item.locator(".lf-margin-spill").click() sheet = page.locator(".lf-page-map-sheet") - sheet.locator("[data-lf-map-button]").filter(has_text="Thread").click() + sheet.get_by_role("button", name=re.compile("^Open thread:")).click() expect(sheet).to_be_hidden() expect(page.locator(".lf-margin-preview")).to_be_hidden() expect(page.locator(".lf-panel")).to_have_class(re.compile(r"\bopen\b")) @@ -2308,12 +2655,14 @@ def test_the_small_screen_map_is_a_complete_accessible_sheet(browser, serve, ope if opener == "keyboard": page.keyboard.press("g") expect(page.locator(".lf-keyline")).to_contain_text("Page map") - page.keyboard.press("Shift+m") + page.keyboard.press("m") else: toggle.click() sheet = page.locator(".lf-page-map-sheet") expect(sheet).to_be_visible() - expect(sheet.get_by_role("button", name="Close")).to_be_focused() + expect( + sheet.get_by_role("searchbox", name="Find a Button or location in Page map") + ).to_be_focused() expect(sheet.locator(".lf-page-map-action").first).to_have_css("min-height", "44px") assert page.evaluate("() => document.scrollingElement.scrollTop") == before @@ -2338,6 +2687,26 @@ def test_the_small_screen_map_is_a_complete_accessible_sheet(browser, serve, ope page.close() +def test_a_folded_compact_map_returns_to_the_banner_overflow(browser, serve): + """A modal returns to the visible door that exposed its folded Map address.""" + example = next(path for path in EXAMPLES if path.stem == "button-gallery") + page, errors = open_page(browser, serve(example)) + resized(page, 390, 700) + more = page.get_by_role("button", name="More page addresses", exact=True) + more.click() + toggle = page.locator(".lf-page-map-toggle") + expect(toggle).to_be_visible() + toggle.click() + sheet = page.get_by_role("dialog", name="Page map", exact=True) + expect(sheet).to_be_visible() + page.keyboard.press("Escape") + expect(sheet).to_be_hidden() + expect(more).to_be_focused() + expect(more).to_have_attribute("aria-expanded", "false") + assert errors == [] + page.close() + + def test_crossing_to_the_small_screen_retires_the_desktop_preview(browser, serve): """A responsive posture exposes one map surface, never both at once.""" page, errors = open_page( @@ -2357,8 +2726,8 @@ def test_crossing_to_the_small_screen_retires_the_desktop_preview(browser, serve page.close() -def test_crossing_to_the_wide_screen_retires_the_small_screen_sheet(browser, serve): - """The restored desktop rail replaces, rather than layers under, the mobile map.""" +def test_the_complete_page_map_survives_a_crossing_to_the_wide_screen(browser, serve): + """The Page map is one destination while its compact rail changes posture.""" page, errors = open_page( browser, serve(DECISION_PAGE, events=[OUTCOME_ON_DECISION, COMMENT_ON_DECISION]) ) @@ -2370,7 +2739,8 @@ def test_crossing_to_the_wide_screen_retires_the_small_screen_sheet(browser, ser resized(page, 1200, 900) expect(page.locator(".lf-living-margin")).to_be_visible() expect(page.locator(".lf-page-map-toggle")).to_be_hidden() - expect(sheet).to_be_hidden() + expect(sheet).to_be_visible() + expect(sheet.locator(".lf-page-map-action")).to_have_count(4) assert errors == [] page.close() diff --git a/tests/test_render_navigation.py b/tests/test_render_navigation.py index 5eb76b49e9..e0c86f1db9 100644 --- a/tests/test_render_navigation.py +++ b/tests/test_render_navigation.py @@ -1398,16 +1398,10 @@ def comment(anchor, text): ), ( "navigation.page-map", - ["g", "M"], + ["g", "m"], ["pressed", "neutral"], "Page map", ), - ( - "navigation.page-map-item", - ["g", "m", "1–3"], - ["pressed", "neutral", "neutral"], - "page-map items", - ), ( "navigation.link", ["g", "h", "1–2"], @@ -1569,35 +1563,16 @@ def disclosure_state(): page.keyboard.press("Escape") expect(page.locator(".lf-decisions-panel")).not_to_be_visible() - # Uppercase M enters the Page map itself. Its existing roving-focus keys are live - # immediately; lowercase m continues to address and open an individual item. - page.keyboard.press("g") - page.keyboard.press("Shift+m") - expect(page.locator(".lf-margin-marker:focus")).to_have_count(1) - assert page.locator(".lf-margin-marker:focus").evaluate( - """row => { - const box = row.getBoundingClientRect(); - return box.bottom > 0 && box.top < innerHeight; - }""" - ) - expect(line).to_contain_text("walk the page map") - expect(line).to_contain_text("first marker") - expect(line).to_contain_text("last marker") - page.keyboard.press("Escape") - - # Page-map items are the right-hand locations. An item with information opens the - # same preview as its marker, including a thread anchored at that location. + # The Page map is one complete destination rather than a capped numbered prefix. page.keyboard.press("g") page.keyboard.press("m") - expect_address_steps(page, [["g", "m", "1"], ["g", "m", "2"], ["g", "m", "3"]]) - page.keyboard.press("1") - expect(page.locator(".lf-margin-preview")).to_be_visible() - assert page.locator(".lf-margin-thread").count() >= 1 - expect(page.locator(".lf-margin-thread textarea").first).to_be_focused() - page.keyboard.press("Escape") - expect(page.locator(".lf-margin-preview")).to_be_hidden() + sheet = page.get_by_role("dialog", name="Page map", exact=True) + expect(sheet).to_be_visible() + expect(sheet.locator(".lf-page-map-group")).to_have_count(3) + expect( + sheet.get_by_role("searchbox", name="Find a Button or location in Page map") + ).to_be_focused() page.keyboard.press("Escape") - expect(page.locator(".lf-margin-preview")).to_be_hidden() # The hyperlinks, from the head of the page where both are on screen. A chip is hung on the # corner a member starts at, which for an inline that wraps is the corner of its first @@ -1939,11 +1914,14 @@ def test_the_g_chord_opens_an_empty_page_map(browser, serve): expect(page.locator(".lf-page-map-action")).to_have_count(0) page.keyboard.press("g") expect(page.locator(".lf-keyline")).to_contain_text("Page map") - page.keyboard.press("Shift+m") + page.keyboard.press("m") sheet = page.locator(".lf-page-map-sheet") expect(sheet).to_be_visible() - expect(sheet.get_by_role("button", name="Close")).to_be_focused() + expect( + sheet.get_by_role("searchbox", name="Find a Button or location in Page map") + ).to_be_focused() + expect(sheet).to_contain_text("No Buttons or locations yet") expect(sheet.locator(".lf-page-map-action")).to_have_count(0) assert errors == [] page.close() @@ -1970,13 +1948,13 @@ def test_numbered_addresses_show_progress_on_complete_routes_without_moving( ) == ["pressed", "neutral", "neutral"] expect_address_steps( page, - [["g", "m", "1"], ["g", "h", "1"], ["g", "h", "2"], ["g", "f", "1"]], + [["g", "h", "1"], ["g", "h", "2"], ["g", "f", "1"]], ) assert ( page.locator(f"{CHIPS} kbd").evaluate_all( "keys => keys.map(key => key.dataset.lfKeyState)" ) - == ["pressed", "neutral", "neutral"] * 4 + == ["pressed", "neutral", "neutral"] * 3 ) def sequence_geometry(locator): @@ -2518,7 +2496,7 @@ def test_escape_gives_the_chord_back_one_press_at_a_time(browser, serve): ) == ["pressed", "neutral", "neutral"] expect_address_steps( page, - [["g", "m", "1"], ["g", "h", "1"], ["g", "h", "2"], ["g", "f", "1"]], + [["g", "h", "1"], ["g", "h", "2"], ["g", "f", "1"]], ) expect(line).to_contain_text("cancel") expect(line.locator('[data-lf-commands="navigation.address.back"]')).to_have_class( @@ -2540,7 +2518,7 @@ def test_escape_gives_the_chord_back_one_press_at_a_time(browser, serve): page.keyboard.press("Escape") expect_address_steps( page, - [["g", "m", "1"], ["g", "h", "1"], ["g", "h", "2"], ["g", "f", "1"]], + [["g", "h", "1"], ["g", "h", "2"], ["g", "f", "1"]], ) assert link_route.locator(":scope > kbd").evaluate_all( "keys => keys.map(key => key.dataset.lfKeyState)" diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py index 12f54290ff..a303f7a028 100644 --- a/tests/test_render_projection.py +++ b/tests/test_render_projection.py @@ -2661,7 +2661,8 @@ def test_replay_signatures_distinguish_widget_state_from_runtime_paint(browser, const { shallowSigs } = await import("/runtime/widget-api.js"); const root = document.createElement("div"); root.id = "signature-root"; - root.innerHTML = '
' + + root.innerHTML = '' + + '
' + '
'; const before = Object.fromEntries(shallowSigs(root)); root.prepend(root.lastElementChild); diff --git a/tests/test_render_reactions.py b/tests/test_render_reactions.py index ffb2477e6e..6255f17806 100644 --- a/tests/test_render_reactions.py +++ b/tests/test_render_reactions.py @@ -12,6 +12,7 @@ from render_harness import leaf_page from render_support import ( BOTH_STAMPS, + EXAMPLES, PANEL_PAGE, PART_DIAGRAM_PAGE, PROPOSED_PAGE, @@ -267,6 +268,34 @@ def test_a_token_press_marks_the_passage_and_a_second_press_takes_it_back( page.close() +def test_r_immediately_opens_the_gallery_reactions_and_digit_chooses(browser, serve): + """The shortcut line never advertises digits behind a still-collapsed ellipsis.""" + example = next(path for path in EXAMPLES if path.stem == "button-gallery") + page, errors = open_page(browser, serve(example)) + settled = page.locator( + '[data-lf-margin-for="bg-react-ok"] .lf-react-mark[data-token="ok"]' + ) + settled.click() + round_trip(page) + select_paragraph(page, "#bg-react-ok") + page.evaluate("() => document.body.focus()") + page.keyboard.press("r") + + surface = page.locator(".lf-margin-reactions") + expect(surface).to_have_class(re.compile(r"\blf-react-open\b")) + expect(surface.locator(":scope > .lf-react-trigger:visible")).to_have_count(0) + expect(surface.locator(".lf-react:visible")).to_have_count(6) + assert "1–6" in key_line(page) + + page.keyboard.press("2") + round_trip(page) + sent = events_model.read_events(serve.page_dir)[-1] + assert sent["kind"] == "comment" and sent["token"] == "no" + assert sent["anchor"]["section"] == "bg-react-ok" + assert errors == [] + page.close() + + @pytest.mark.parametrize("scheme", ["light", "dark"]) def test_selected_reactions_keep_neutral_button_furniture(browser, serve, scheme): """A standing reaction keeps neutral furniture and a witness distinct from hover.""" @@ -571,13 +600,17 @@ def test_spilled_reactions_keep_their_target_and_yield_to_the_map( sheet = page.get_by_role("dialog", name="Page map", exact=True) expect(sheet).to_be_visible() - first = sheet.locator("[data-lf-map-button]").nth(0) - second = sheet.locator("[data-lf-map-button]").nth(1) + first = sheet.locator("[data-lf-map-button]:focus") expect(first).to_be_focused() + first_key = first.get_attribute("data-lf-map-button") + assert ":responses:reaction:" in first_key page.keyboard.press("Tab") + second = sheet.locator("[data-lf-map-button]:focus") expect(second).to_be_focused() + second_key = second.get_attribute("data-lf-map-button") + assert ":responses:reaction:" in second_key page.keyboard.press("Shift+Tab") - expect(first).to_be_focused() + expect(sheet.locator(f'[data-lf-map-button="{first_key}"]')).to_be_focused() page.keyboard.press("Escape") expect(sheet).to_be_hidden() expect(spill).to_be_focused() @@ -585,8 +618,10 @@ def test_spilled_reactions_keep_their_target_and_yield_to_the_map( page.keyboard.press("Enter") expect(sheet).to_be_visible() + first = sheet.locator(f'[data-lf-map-button="{first_key}"]') expect(first).to_be_focused() page.keyboard.press("Tab") + second = sheet.locator(f'[data-lf-map-button="{second_key}"]') expect(second).to_be_focused() token = second.get_attribute("aria-label").split(" — ")[0] sends = _traffic(page).sends @@ -594,7 +629,7 @@ def test_spilled_reactions_keep_their_target_and_yield_to_the_map( second.click() else: page.keyboard.press("Enter") - # Page map forwards the press on the next frame; there is no trip to await yet. + # Page map forwards the press synchronously; its network trip is still asynchronous. _until(page, lambda traffic: traffic.sends > sends, "sent the spilled reaction") round_trip(page) sent = events_model.read_events(serve.page_dir)[-1] From 8d53693e76d4588174cfdd933206b3edafdde61a Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 2 Sep 2026 16:44:19 -0700 Subject: [PATCH 2/3] Fix Button refresh and capacity review findings --- skills/leaf/CLAUDE.md | 6 ++-- skills/leaf/assets/runtime/living-margin.js | 12 ++++--- skills/leaf/assets/runtime/reactions.js | 35 ++---------------- .../leaf/packages/default/widgets/lf-draft.js | 15 +++++--- tests/test_render_margin.py | 36 +++++++++++++++++-- tests/test_render_semantic_selection.py | 2 +- 6 files changed, 58 insertions(+), 48 deletions(-) diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index 7f14f286b0..c5ee142908 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -1451,9 +1451,9 @@ and `…` when there are at least two peers. Hiding one peer costs the same fitt showing it and adds a press, so it is not overflow. With no contributed control, standing information supplies the primary disclosure Button. -The expanded budget is six fittings, including the primary where one exists; a target -made only of peer choices uses all six. A larger set shows the Buttons that fit and a -final Page-map Button whose label gives the remaining count. That opens +The expanded budget is six fittings, including the primary or visible reading marker +where one exists; a target made only of peer choices uses all six. A larger set shows +the Buttons that fit and a final Page-map Button whose label gives the remaining count. That opens the existing Page map at the first excess action; every excess control has its own named row which performs that exact action. Do not grow another popover for overflow. The same limit and exact-action route apply when the cluster docks on a narrow screen. diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js index 903d438b1d..5f88264fb5 100644 --- a/skills/leaf/assets/runtime/living-margin.js +++ b/skills/leaf/assets/runtime/living-margin.js @@ -1588,10 +1588,14 @@ export function createLivingMargin(dependencies) { function syncOptionGroup(group, entry, primary, optionsOpen) { const allNodes = optionNodes(entry, primary); const unique = [...new Set(allNodes)]; - // An option-only target has no primary fitting outside this group. Let its peers use - // the whole cluster budget instead of reserving a ghost seventh position; reaction - // mode is the common case, where its six declared choices should fit exactly. - const peerCapacity = Math.max(0, EXPANDED_BUTTON_BUDGET - (primary ? 1 : 0)); + // Peers may use the whole cluster budget only when no fitting stands outside this + // group. Reaction mode is the common case: it has neither a primary nor a reading + // marker, so its six declared choices fit exactly. A reading-only target keeps its + // marker visible, and that fitting counts just as a contributed primary would. + const peerCapacity = Math.max( + 0, + EXPANDED_BUTTON_BUDGET - (primary || markerFace(entry).kinds.length ? 1 : 0), + ); const needsSpill = unique.length > peerCapacity; // The spill route consumes the last visible fitting; it does not increase the // cluster beyond its budget. A fully expanded cluster is therefore either one diff --git a/skills/leaf/assets/runtime/reactions.js b/skills/leaf/assets/runtime/reactions.js index a5952c0a20..883c00908c 100644 --- a/skills/leaf/assets/runtime/reactions.js +++ b/skills/leaf/assets/runtime/reactions.js @@ -137,8 +137,6 @@ export function createReactions({ let surfaceOrdinal = 0; let marginSurface = null; let marginOffer = null; - let marginComment = null; - let marginSuggest = null; function buildReactSurface( surface, pressed, @@ -218,30 +216,6 @@ export function createReactions({ marginSurface = el("div", "lf-margin-reactions"); marginSurface.setAttribute("role", "group"); marginSurface.setAttribute("aria-label", "Other responses"); - marginSuggest = marginAction(offer("button", "lf-fab-suggest"), { - key: "suggest", - icon: "edit", - label: "Suggest", - behavior: "disclosure", - role: "secondary", - }); - marginSuggest.onclick = () => { - if (!fabAnchorAt()?.quote || designIsOn()) return; - setReact(false); - suggestHere(); - }; - marginComment = marginAction(offer("button", "lf-fab"), { - key: "comment", - icon: "comment", - label: "Comment", - behavior: "disclosure", - role: "primary", - }); - 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()), @@ -317,10 +291,7 @@ export function createReactions({ const target = anchor && fabTargetAt(); if (!marginSurface || !target) return false; // `r` is an explicit reaction mode. Comment and Suggest remain their own `c` and - // response-bar routes; keeping them in this cluster would displace reaction choices - // behind More while the key line claimed all six were open. - marginComment.hidden = true; - marginSuggest.hidden = true; + // response-bar routes, so this temporary contribution contains reactions alone. fabBar.dataset.lfMarginRaised = "1"; // Register the response surface in the state it is about to show. Registering its // collapsed face first makes the projection treat the six choices as hidden owner @@ -435,9 +406,7 @@ export function createReactions({ const firstChoice = reactSurface === fabBar ? responseChoices(fabBar)[0] - : reactSurface === marginSurface && !marginSuggest.hidden - ? marginSuggest - : pickerFor(reactSurface).palette.querySelector(".lf-react"); + : pickerFor(reactSurface).palette.querySelector(".lf-react"); if (focusPicker || (surface && reactFrom === pickerFor(reactSurface).trigger)) firstChoice?.focus({ preventScroll: true, diff --git a/skills/leaf/packages/default/widgets/lf-draft.js b/skills/leaf/packages/default/widgets/lf-draft.js index 8b0769c8b3..75fbc8985d 100644 --- a/skills/leaf/packages/default/widgets/lf-draft.js +++ b/skills/leaf/packages/default/widgets/lf-draft.js @@ -247,6 +247,11 @@ customElements.define( label: control.querySelector(":scope > .lf-margin-action-label")?.textContent, })), ); + // Establish availability before the action watcher makes its first synchronous + // reading. Later watcher callbacks run inside `lf-actions`; the margin and Ask + // projections listen to that same event after the widgets have painted, so those + // callbacks must not fan one shared refresh back out through every draft. + this.#paintButtons(); measure(this.#row, () => { // The engaged cluster is Save + Cancel; reserve that complete fitting while // the detached measurement row contains its direct controls, before resting @@ -365,7 +370,7 @@ customElements.define( return button; } - #paintButtons() { + #paintButtons({ notify = true } = {}) { marginAction(this.#save, { key: this.#failed ? "retry" : "save", icon: this.#failed ? "retry" : "check", @@ -396,12 +401,14 @@ customElements.define( this.#failureReceipt?.remove(); delete this.#row.dataset.lfMarginReceipt; } - this.#margin?.update(); - this.#decisionActions?.update(); + if (notify) { + this.#margin?.update(); + this.#decisionActions?.update(); + } } #paintAvailability = () => { - if (this.#pencil) this.#paintButtons(); + if (this.#pencil) this.#paintButtons({ notify: false }); }; #delta(before, after, cache = true) { diff --git a/tests/test_render_margin.py b/tests/test_render_margin.py index e4dc5215f7..c754c6c3de 100644 --- a/tests/test_render_margin.py +++ b/tests/test_render_margin.py @@ -21,6 +21,7 @@ live_url, margins_laid_out, open_page, + panel_comment, panel_settled, resized, round_trip, @@ -1252,9 +1253,6 @@ def test_one_target_has_one_primary_button_and_inline_secondary_buttons(browser, expect(options).to_be_visible() expect(preview).to_be_hidden() expect(suggestion_item.locator(".lf-margin-action:visible")).to_have_count(6) - expect(reactions.locator(":scope > .lf-fab")).to_have_class( - re.compile(r"lf-margin-action") - ) expect(reactions.locator(".lf-react").first).to_have_class( re.compile(r"lf-margin-action") ) @@ -1608,6 +1606,38 @@ def test_button_order_budget_and_spilled_actions_are_stable_at_both_widths( page.close() +def test_a_reading_marker_counts_toward_the_expanded_button_budget(browser, serve): + """A reading-only target never grows a seventh fitting beside its marker.""" + url = serve(PANEL_PAGE) + panel_comment(serve.page_dir, "Keep this thread visible.", {"section": "how-cap"}) + page, errors = open_page(browser, url) + page.evaluate( + """async () => { + const {offer, marginAction, registerMarginItem} = + await import('/runtime/widget-api.js'); + const controls = document.createElement('span'); + for (let index = 0; index < 6; index += 1) + controls.append(marginAction(offer('button', ''), { + key: `peer-${index}`, icon: 'dot', label: `Peer ${index}`, + role: 'secondary' + })); + window.readingBudgetFixture = registerMarginItem({ + key: 'reading-budget', target: document.querySelector('#how-cap'), + controls, side: 'after' + }); + }""" + ) + item = page.locator('[data-lf-margin-for="how-cap"]') + item.locator(":scope > .lf-margin-more").click() + expect(item.locator(".lf-margin-action:visible")).to_have_count(6) + expect(item.locator(":scope > .lf-margin-marker")).to_be_visible() + expect(item.locator(".lf-margin-spill")).to_have_attribute( + "data-lf-spill-count", "2" + ) + assert errors == [] + page.close() + + def test_a_spilled_thread_opens_the_full_conversation_without_a_hidden_anchor( browser, serve ): diff --git a/tests/test_render_semantic_selection.py b/tests/test_render_semantic_selection.py index 3c078c6e41..1424069c75 100644 --- a/tests/test_render_semantic_selection.py +++ b/tests/test_render_semantic_selection.py @@ -748,7 +748,7 @@ def test_cancelling_page_search_restores_the_control_that_opened_it(browser, ser page.keyboard.press("/") expect(page.get_by_role("searchbox", name="Search page text")).to_be_focused() - page.locator("p").click() + page.locator("main p").click() page.keyboard.press("/") expect(page.locator(".lf-keyline")).to_contain_text("close search") expect(page.locator(".lf-keyline")).not_to_contain_text("back to hints") From 955b82317c8f2cbd3b2019600b1be1daea22a027 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 2 Sep 2026 16:58:22 -0700 Subject: [PATCH 3/3] Clarify draft refresh coalescing --- skills/leaf/packages/default/widgets/lf-draft.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/skills/leaf/packages/default/widgets/lf-draft.js b/skills/leaf/packages/default/widgets/lf-draft.js index 75fbc8985d..1374dbfb10 100644 --- a/skills/leaf/packages/default/widgets/lf-draft.js +++ b/skills/leaf/packages/default/widgets/lf-draft.js @@ -248,9 +248,10 @@ customElements.define( })), ); // Establish availability before the action watcher makes its first synchronous - // reading. Later watcher callbacks run inside `lf-actions`; the margin and Ask - // projections listen to that same event after the widgets have painted, so those - // callbacks must not fan one shared refresh back out through every draft. + // reading, which no longer notifies. Every later transition this paints — failed, + // sending, engaged — already runs through a notifying `#paintButtons`, so the + // watcher's own callback would fan one shared refresh out through every draft for + // a projection none of them changed. this.#paintButtons(); measure(this.#row, () => { // The engaged cluster is Save + Cancel; reserve that complete fitting while