diff --git a/docs/how-it-works.html b/docs/how-it-works.html
index 919a08bde..b424f71df 100644
--- a/docs/how-it-works.html
+++ b/docs/how-it-works.html
@@ -485,9 +485,10 @@
Edits are events; the document stays the state
stands, which is what a reload has always shown you.
- The cheapest answer is one token. Select a passage, ⌥-click an element, or
- stand in an agent reply, then open … or press r.
- The ellipsis becomes the ok, no, lost,
+ The cheapest answer is one token. Select a passage, select an item with
+ s, or stand in an agent reply, then open … or
+ press r. Alt-clicking an item opens its comment box directly. The
+ ellipsis becomes the ok, no, lost,
cut, more, and this buttons; digits are
optional shortcuts. The token paints as a glyph in the margin and a faint wash
on the words, opens no thread, and comes off with a press on the glyph. An
diff --git a/docs/index.html b/docs/index.html
index 5314268d4..aa1d9ef98 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -137,10 +137,11 @@
Example: Review a plan
Mark it up. Select any passage and comment; the passage
stays lit while you write, and the comment anchors to it and stays anchored
as versions change — to the passage you picked, even where the page says the
- same thing twice. Diagrams and images take comments by click, and a
- suggestion mode proposes replacement text that the agent takes verbatim or
- answers with why not. The agent opens threads the same way — a question
- about one sentence arrives in the margin beside it, not in the terminal.
+ same thing twice. Alt-clicking an item opens its comment box directly;
+ diagrams and images also expose response actions by click. A suggestion mode
+ proposes replacement text that the agent takes verbatim or answers with why
+ not. The agent opens threads the same way — a question about one sentence
+ arrives in the margin beside it, not in the terminal.
Your comment reaches the session. On Claude Code, a
diff --git a/examples/corpus.html b/examples/corpus.html
index 5c9694e57..17af4f211 100644
--- a/examples/corpus.html
+++ b/examples/corpus.html
@@ -927,7 +927,8 @@ Left out on purpose
The dependency bumps and the flaky-test fixes stay in the changelog only —
nothing a user would change behavior over. If one of them deserves a note
- after all, say so in a comment and a draft for it arrives in the next version.
+ after all, Alt-click this section and say why; a draft arrives in the next
+ version.
diff --git a/examples/release-notes.html b/examples/release-notes.html
index 36f90cef0..8c522f221 100644
--- a/examples/release-notes.html
+++ b/examples/release-notes.html
@@ -107,7 +107,8 @@ Left out on purpose
The dependency bumps and the flaky-test fixes stay in the changelog only —
nothing a user would change behavior over. If one of them deserves a note
- after all, say so in a comment and a draft for it arrives in the next version.
+ after all, Alt-click this section and say why; a draft arrives in the next
+ version.
diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md
index ca59beb00..eaa292e29 100644
--- a/skills/leaf/CLAUDE.md
+++ b/skills/leaf/CLAUDE.md
@@ -1122,12 +1122,13 @@ has no hover. Export keeps the glyph with its press taken off and writes the was
into the words as a `` (BAKE), the highlight registry being script state
no file can hold.
-The bar the selection raises is `.lf-fab-bar`: the `.lf-fab` comment glyph every
-route into the composer still goes through, followed by one reaction ellipsis.
+The bar a selection or keyboard-selected item raises is `.lf-fab-bar`: the `.lf-fab`
+comment glyph followed by one reaction ellipsis.
For a page target, the ellipsis hands Comment and the layer's token buttons to
that target's shared margin item; it never opens a box below the floating bar.
-`showFab` shows and places the compact bar; `activateAimTarget` raises it for both
-the ⌥ press and keyboard item hint. `r` opens the same choices on the selection,
+`showFab` shows and places the compact bar; `selectResponseTarget` raises it for a
+keyboard item hint. The ⌥ press has already chosen Comment, so `openTargetComposer`
+opens the composer directly on the same captured anchor. `r` opens the same choices on the selection,
the standing item, or the latest agent message in the thread the reader is in.
With none of those targets, it shows “Select something to react to” and opens
nothing. Page-wide reactions remain an explicit ellipsis above the panel's general
@@ -1629,9 +1630,11 @@ so its ordinary Escape rung remains the route back.
### Item selection is explicit
`s` names the visible items and declared visual parts that Alt-click can aim at. Both
-routes read `aimTargetAt`, then raise the same comment and reaction bar. The target kind
-changes only the anchor: a whole item names its authored id, while a visual part adds
-its declared token and resolves the bar against that part's geometry.
+routes read `aimTargetAt`, and the target kind changes only the anchor: a whole item
+names its authored id, while a visual part adds its declared token. Their next surface
+follows the gesture's stated intent. `s` selects the target and raises its Comment and
+reaction bar; Alt-click promises Comment and opens the composer directly. The same
+anchor resolves either surface against the target's geometry.
The short, viewport-local hints form a prefix-free tree over one alphabet. Most targets
cost one letter; only the tail branches when the viewport holds more targets than the
diff --git a/skills/leaf/assets/leaf.js b/skills/leaf/assets/leaf.js
index 5e2606376..2365b9ede 100644
--- a/skills/leaf/assets/leaf.js
+++ b/skills/leaf/assets/leaf.js
@@ -1192,7 +1192,6 @@ const { landTyping, mayLandTyping, pageSelection, selectionAnchor, snapSelection
});
const {
- activateAimTarget,
BANNER_CLEAR,
activateVisual,
beside,
@@ -1201,9 +1200,11 @@ const {
fabTargetAt,
fabReturnTo,
openOnItem,
+ openTargetComposer,
placeClear,
placeComposer,
refreshFab,
+ selectResponseTarget,
showFab,
standDown,
updateFab,
@@ -1257,12 +1258,12 @@ const {
});
const { AIM, aimIsOn, aimedItem } = createAim({
- activateAimTarget,
aimTargetAt,
designIsOn: () => designOn,
designPress,
designTarget,
inChrome: (node) => inChrome(node),
+ openTargetComposer,
openOnDesign,
pointerAt,
refreshAim,
@@ -1928,7 +1929,6 @@ const { GO, GOTO, isChordArmed, paintAddresses, setChord } = createAddress({
const { PAGE_SEARCH, SELECT, isSelecting, paintTargets, startSelecting } =
createTargetSelection({
- activateAimTarget,
aimTargets,
allButTheReference,
anchoringIsReady: () => anchoringReady,
@@ -1952,6 +1952,7 @@ const { PAGE_SEARCH, SELECT, isSelecting, paintTargets, startSelecting } =
selectionLayer,
selectionSearch,
selectionStatus,
+ selectResponseTarget,
shownParts,
shownRect: (...args) => shownRect(...args),
updateFab,
diff --git a/skills/leaf/assets/runtime/anchors.js b/skills/leaf/assets/runtime/anchors.js
index cf35c6137..528f2f27f 100644
--- a/skills/leaf/assets/runtime/anchors.js
+++ b/skills/leaf/assets/runtime/anchors.js
@@ -742,9 +742,8 @@ export function createAnchors(dependencies) {
// a promise has to interrupt where an annotation may whisper, so the aim has a box
// of its own in the chrome's layer (refreshAim, and the .lf-aim rule's account of
// why). An open composer doesn't stand the aim down — a press while the box is up
- // selects another target and raises its action bar — so the two can show at once,
- // which is the true state: where the draft stands, and where the next response would
- // land.
+ // moves the draft onto another target — so the two can show at once, which is the
+ // true state: where the draft stands, and where the next comment would land.
const draft =
composerIsOpen() && composerAnchor()
? resolveAnchor(composerAnchor(), text)
diff --git a/skills/leaf/assets/runtime/composing/aim.js b/skills/leaf/assets/runtime/composing/aim.js
index 3d9684c54..dfce0d32c 100644
--- a/skills/leaf/assets/runtime/composing/aim.js
+++ b/skills/leaf/assets/runtime/composing/aim.js
@@ -1,5 +1,5 @@
export function createAim({
- activateAimTarget,
+ openTargetComposer,
aimTargetAt,
designIsOn,
designPress,
@@ -32,19 +32,19 @@ export function createAim({
// is spelled from the modifier through the register's own table rather than written out
// twice in two platforms' glyphs.
const AIM = {
- id: "aim.respond",
+ id: "aim.comment",
modifier: "Alt",
keys: [],
label: `${spell("Alt")} click`,
- does: "Respond to the item under the pointer",
+ does: "Comment on the item under the pointer",
when: aimIsAvailable,
};
// What the pointer is over, asked of the page rather than of an event, so pressing the key
// without moving the mouse answers too — the user holds ⌥ to find out what they would
// get, and the answer cannot wait for them to jiggle the mouse first. An open composer
- // is no reason to say nothing: the press still acts (it selects another target and
- // raises its actions), so the promise still paints — what stood down here left that
- // one press made blind.
+ // is no reason to say nothing: the press still acts (it moves the draft onto another
+ // target), so the promise still paints — what stood down here left that one press made
+ // blind.
function aimedTarget() {
const pointer = pointerAt();
if (pointer.x < 0) return null;
@@ -148,7 +148,11 @@ export function createAim({
if (ev.type === "mousedown" || ev.type === "click") ev.preventDefault();
ev.stopPropagation();
if (ev.type !== "click") return;
- if (claimedPress.aim) activateAimTarget(claimedPress.aim);
+ if (claimedPress.aim)
+ openTargetComposer(claimedPress.aim, {
+ left: ev.clientX + 6,
+ top: ev.clientY - 40,
+ });
else if (claimedPress.design)
openOnDesign(claimedPress.design, {
left: ev.clientX + 6,
diff --git a/skills/leaf/assets/runtime/composing/surface.js b/skills/leaf/assets/runtime/composing/surface.js
index d811133f9..c3ac2aa36 100644
--- a/skills/leaf/assets/runtime/composing/surface.js
+++ b/skills/leaf/assets/runtime/composing/surface.js
@@ -338,11 +338,20 @@ export function createSelectionSurface({
showFab(null, null, { returnFocus: "none" });
openComposer({ section: item.id }, "", from.left, from.top);
}
- // Pointer and keyboard aim choose a semantic target. Every target then raises the
- // same actions; its anchor alone decides which element supplies the mark and geometry.
- function activateAimTarget({ anchor }) {
+ // Keyboard selection chooses a semantic target before the reader has chosen what to
+ // do with it, so it raises the shared response actions. The anchor alone decides which
+ // element supplies the mark and geometry.
+ function selectResponseTarget({ anchor }) {
showFab(anchor);
}
+ // Alt-click already names the action as well as the target. It opens the composer in
+ // the same transaction instead of asking the reader to choose Comment a second time.
+ // Target capture still happened before this door, so comments and reactions keep one
+ // durable anchor model and the claimed press still reaches nothing underneath it.
+ function openTargetComposer({ anchor }, from) {
+ showFab(null, null, { returnFocus: "none" });
+ openComposer(anchor, "", from.left, from.top);
+ }
// The button follows the selection. What counts as one is measured on the quote it would
// store, not on the selection's own toString(): those are different strings, and gating on
// the one the reader sees while storing the one the document holds lets a two-character
@@ -602,10 +611,11 @@ export function createSelectionSurface({
fabTargetAt,
fabReturnTo,
openOnItem,
- activateAimTarget,
+ openTargetComposer,
placeClear,
placeComposer,
refreshFab,
+ selectResponseTarget,
showFab,
standDown,
updateFab,
diff --git a/skills/leaf/assets/runtime/composing/targets.js b/skills/leaf/assets/runtime/composing/targets.js
index 02002684e..a4e39ccff 100644
--- a/skills/leaf/assets/runtime/composing/targets.js
+++ b/skills/leaf/assets/runtime/composing/targets.js
@@ -1,16 +1,16 @@
import { sameAnchor } from "../anchors.js";
import { bindings } from "../keyboard/bindings.js";
-// Keyboard item aim and whole-page text search. `s` opens a viewport-local map of the
-// same stable items and visual parts Alt-click reaches; `/` opens the page's text search
-// directly or from that map.
+// Keyboard item selection and whole-page text search. `s` opens a viewport-local map of
+// the same stable items and visual parts Alt-click reaches, then raises their general
+// response actions; `/` opens the page's text search directly or from that map.
const HINT_KEYS = [..."asdfghjklqwertyuiopzxcvbnm"];
const HINT_INDENT = 10;
const MIN_SEARCH = 3;
export function createTargetSelection({
- activateAimTarget,
+ selectResponseTarget,
aimTargets,
allButTheReference,
anchoringIsReady,
@@ -273,7 +273,7 @@ export function createTargetSelection({
function choose(target) {
setOpen(false);
document.body.focus({ preventScroll: true });
- activateAimTarget(target);
+ selectResponseTarget(target);
announce(`Selected ${target.label}. Choose a response.`);
}
diff --git a/tests/test_render_aim.py b/tests/test_render_aim.py
index 3fce915e3..69b9e4f1f 100644
--- a/tests/test_render_aim.py
+++ b/tests/test_render_aim.py
@@ -102,10 +102,8 @@ def test_the_catalog_sidenote_can_be_aimed_whole(browser, serve):
note.click()
page.keyboard.up("Alt")
- # The press raises the compact bar on the note — the comment icon, then the reaction
- # ellipsis — and the comment icon opens the composer on the whole note.
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ # The chord already names Comment, so the press opens the composer on the whole note.
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
expect(page.locator(".lf-composer")).to_be_visible()
assert page.evaluate(DRAFT_MARK) == "logout-frequency"
assert errors == []
@@ -149,8 +147,7 @@ def test_an_aimed_comment_keeps_its_place_with_the_asks_tray_open(browser, serve
target.click()
page.keyboard.up("Alt")
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
expect(page.locator(".lf-composer")).to_be_visible()
placed = page.evaluate(
"""() => {
@@ -201,9 +198,8 @@ def test_the_aim_reads_the_pointer_where_the_press_is_dispatched_from(browser, s
page.mouse.click(seam["x"], seam["y"])
page.keyboard.up("Alt")
- # The press raises the bar on the item the aim held; Comment on it is the composer.
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ # The press opens Comment on the item the aim held.
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
expect(page.locator(".lf-composer")).to_be_visible()
assert page.evaluate(DRAFT_MARK) == seam["at"]
assert errors == []
@@ -236,8 +232,7 @@ def test_an_aimed_first_press_records_its_pointer_before_claiming_it(browser, se
}"""
)
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
expect(page.locator(".lf-composer")).to_be_visible()
assert page.evaluate(DRAFT_MARK) == "p2"
assert errors == []
@@ -341,9 +336,8 @@ def test_an_aimed_press_does_only_what_the_outline_promised(
expect(bar).to_be_hidden()
expect(composer).to_be_hidden()
else:
- # The press raises the bar on the item; Comment on it is the composer.
- expect(bar).to_be_visible()
- page.locator(".lf-fab").click()
+ # The chord promised Comment, so the press opens the composer directly.
+ expect(bar).to_be_hidden()
expect(composer).to_be_visible()
mark = page.evaluate(DRAFT_MARK)
# A box a standing thread already outlines keeps the posted colour and takes
@@ -454,10 +448,8 @@ def test_an_aim_on_a_seam_promises_and_takes_the_same_item(browser, serve):
)
page.mouse.click(edge["x"], edge["y"])
page.keyboard.up("Alt")
- # The press raises the bar on what it took, and Comment is the way from there into
- # the composer — the same route a selection takes.
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ # The press opens Comment on what it took.
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
expect(page.locator(".lf-composer")).to_be_visible()
assert page.evaluate(DRAFT_MARK) == promised, (
f"the outline promised {promised} on the seam and the press commented on "
@@ -480,8 +472,7 @@ def test_a_key_still_reaches_its_control_after_an_aimed_press(browser, serve):
page.keyboard.down("Alt")
heading.click()
page.keyboard.up("Alt")
- expect(page.locator(".lf-fab-bar")).to_be_visible() # the press was the aim's
- page.locator(".lf-fab").click()
+ expect(page.locator(".lf-fab-bar")).to_be_hidden() # the press was the aim's
composer = page.locator(".lf-composer")
expect(composer).to_be_visible()
page.keyboard.press("Escape")
@@ -499,20 +490,19 @@ def test_a_key_still_reaches_its_control_after_an_aimed_press(browser, serve):
def test_the_aim_still_promises_while_a_composer_is_open(browser, serve):
- """An armed press with the box up selects a new target, so aim still says where.
+ """An armed press with the box up moves it to a new target, so aim still says where.
claimPress acts whether or not a composer stands open. Holding ⌥ over a second item
raises its box beside the draft's own mark; two at once is the true state — where
- the draft stands, and where a response would land. The press raises that target's
- actions, and choosing Comment carries the typed text onto the new anchor."""
+ the draft stands, and where the next comment would land. The press carries the typed
+ text onto the new anchor."""
page, errors = open_page(browser, serve(REPLAYED_PAGE))
heading = page.locator("#t")
heading.hover()
page.keyboard.down("Alt")
heading.click()
page.keyboard.up("Alt")
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
composer = page.locator(".lf-composer")
expect(composer).to_be_visible()
composer.locator("textarea").fill("carried words")
@@ -527,9 +517,8 @@ def test_the_aim_still_promises_while_a_composer_is_open(browser, serve):
)
card.click()
page.keyboard.up("Alt")
- # The bar comes up on the card over the open box; its Comment moves the box.
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ # The second explicit comment gesture moves the open draft onto the card.
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
expect(composer).to_be_visible()
expect(composer.locator("textarea")).to_have_value("carried words")
assert [page.evaluate(AIMED), page.evaluate(DRAFT_MARK)] == [
@@ -586,7 +575,7 @@ def test_design_mode_comments_on_what_a_press_lands_on_and_nothing_else(browser,
page.keyboard.press("?")
reference = page.locator(".lf-help")
expect(reference).to_be_visible()
- expect(reference.locator('tr[data-lf-command="aim.respond"]')).to_have_count(0)
+ expect(reference.locator('tr[data-lf-command="aim.comment"]')).to_have_count(0)
page.keyboard.press("Escape")
expect(page.locator("body")).to_have_class(re.compile(r"\blf-design\b"))
@@ -1109,19 +1098,19 @@ def test_a_declared_flowchart_node_keeps_its_comment_across_renderings(browser,
page.close()
-def test_a_linked_flowchart_node_uses_the_shared_aim_actions(browser, serve):
- """Alt-click claims the linked visual part without following the link, then raises
- the same Comment and Reaction choices as any other aimed item."""
+def test_a_linked_flowchart_node_opens_its_comment_without_following_the_link(
+ browser, serve
+):
+ """Alt-click claims the linked visual part without following the link and opens
+ Comment on the part in the same gesture."""
page, errors = open_page(browser, serve(PART_DIAGRAM_PAGE))
diagram = page.locator("#flow")
handler = diagram.locator('g[id*="flowchart-H-"]')
expect(handler.locator("xpath=ancestor::*[local-name()='a'][1]")).to_have_count(1)
handler.click(modifiers=["Alt"])
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- expect(page.locator(".lf-composer")).to_be_hidden()
- expect(handler).to_have_class(re.compile(r"\blf-action-target\b"))
- page.locator(".lf-fab").click()
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
+ expect(page.locator(".lf-composer")).to_be_visible()
expect(handler).to_have_class(re.compile(r"\blf-mark-el\b.*\blf-pending\b"))
expect(diagram).not_to_have_class(re.compile(r"\blf-mark-el\b"))
page.locator(".lf-composer textarea").fill("keep this linked step visible")
@@ -1212,8 +1201,8 @@ def test_a_declared_box_takes_its_comment_on_every_type_that_carries_an_id(
def aim(target, **press):
target.click(modifiers=["Alt"], **press)
- expect(page.locator(".lf-fab-bar")).to_be_visible()
- page.locator(".lf-fab").click()
+ expect(page.locator(".lf-fab-bar")).to_be_hidden()
+ expect(page.locator(".lf-composer")).to_be_visible()
state = page.locator('#life g[id*="state-Queued-"]')
aim(state)
diff --git a/tests/test_render_reactions.py b/tests/test_render_reactions.py
index 4185e7a41..900d53808 100644
--- a/tests/test_render_reactions.py
+++ b/tests/test_render_reactions.py
@@ -12,6 +12,7 @@
from render_support import (
PANEL_PAGE,
PART_DIAGRAM_PAGE,
+ TARGETS_PAGE,
key_line,
open_page,
panel_comment,
@@ -283,15 +284,25 @@ def test_r_extends_the_targets_margin_item_and_needs_a_target(browser, serve):
page.close()
-def test_alt_click_raises_the_bar_on_an_item_and_a_token_outlines_it(browser, serve):
- """A whole element goes through the gesture that already names one: ⌥-click. The
- bar comes up on the item with Comment first and a compact reaction trigger. Opening
- it and choosing a token puts an element anchor in the log, which paints as a dashed
- hairline on the item's boxes and a glyph seated at its first line."""
- page, errors = open_page(browser, serve(PANEL_PAGE))
- page.keyboard.down("Alt")
- page.locator("#how-patch").click()
- page.keyboard.up("Alt")
+def test_an_item_hint_raises_the_bar_and_a_token_outlines_the_item(browser, serve):
+ """Keyboard item selection leaves the response open. Choosing a token puts an
+ element anchor in the log, which paints as a dashed hairline on the item's boxes
+ and a glyph seated at its first line."""
+ page, errors = open_page(browser, serve(TARGETS_PAGE))
+ page.keyboard.press("s")
+ expect(page.locator(".lf-target-hint")).to_have_count(3)
+ code = page.evaluate(
+ """() => {
+ const target = document.querySelector('#prose').getBoundingClientRect();
+ return [...document.querySelectorAll('.lf-target-hint')]
+ .sort((a, b) => {
+ const ar = a.getBoundingClientRect(), br = b.getBoundingClientRect();
+ return Math.hypot(ar.left - target.left, ar.top - target.top)
+ - Math.hypot(br.left - target.left, br.top - target.top);
+ })[0].dataset.lfTarget;
+ }"""
+ )
+ page.keyboard.type(code)
bar = page.locator(".lf-fab-bar")
expect(bar).to_be_visible()
expect(page.locator(".lf-composer")).to_be_hidden()
@@ -299,8 +310,8 @@ def test_alt_click_raises_the_bar_on_an_item_and_a_token_outlines_it(browser, se
page.locator('.lf-margin-reactions .lf-react[data-token="this"]').click()
round_trip(page)
sent = events_model.read_events(serve.page_dir)[-1]
- assert sent["token"] == "this" and sent["anchor"] == {"section": "how-patch"}
- shown = painted(page, [["how-patch", "this"]])
+ assert sent["token"] == "this" and sent["anchor"] == {"section": "prose"}
+ shown = painted(page, [["prose", "this"]])
assert shown["outlined"] and shown["washed"] == "", shown
assert errors == []
page.close()