Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/how-it-works.html
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,9 @@ <h2>Edits are events; the document stays the state</h2>
Command/Ctrl-Enter sends. Tab or its ellipsis extends it in place with six
agent-feedback reaction controls: 👍 keep, ❌ change, 🤔 clarify, ✂️ shorten,
🔎 support, and 🎯 prioritize; digits are optional shortcuts. The token paints
as an emoji in the margin and a faint wash on the words, opens no thread, and
comes off with a press on the emoji. A 👍 on the agent's latest reply request
as an emoji in the margin and a faint wash on the words, and opens no thread.
Press the standing emoji to reveal its separate Remove control, then press
Remove to take the reaction back. A 👍 on the agent's latest reply request
takes the thread out of "On you" without a word typed. The tokens are the
layer's, not the machine's: a project's <code>.leaf/</code> can rename, add,
or remove them.
Expand Down
5 changes: 3 additions & 2 deletions examples/corpus.html
Original file line number Diff line number Diff line change
Expand Up @@ -2734,8 +2734,9 @@ <h2>
</h2>
<p id="bg-reactions-guide">
Select words and press E, or hover an agent reply and use its corner control.
The picker is feedback for the agent, not chat decoration. A saved reaction
leaves only its emoji; press that emoji to remove it.
The picker is feedback for the agent, not chat decoration. A saved passage
reaction leaves only its emoji; press it to reveal the separate Remove
control, then press that control to take the reaction back.
</p>
<p id="bg-react-ok">
<strong>1 · 👍 · keep.</strong> The spare key is with reception.
Expand Down
5 changes: 3 additions & 2 deletions examples/developer/feature-gallery.html
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,9 @@ <h2>
</h2>
<p id="bg-reactions-guide">
Select words and press E, or hover an agent reply and use its corner control.
The picker is feedback for the agent, not chat decoration. A saved reaction
leaves only its emoji; press that emoji to remove it.
The picker is feedback for the agent, not chat decoration. A saved passage
reaction leaves only its emoji; press it to reveal the separate Remove
control, then press that control to take the reaction back.
</p>
<p id="bg-react-ok">
<strong>1 · 👍 · keep.</strong> The spare key is with reception.
Expand Down
2 changes: 2 additions & 0 deletions skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ const anchorControls = createAnchorControls({
invalidatePageGeometry: pageGeometry.invalidate,
messageReferenceRoot: panel,
draftQuote: composerQuote,
presentedControl: (control) => app.margin.presentedControl(control),
focused,
});

const version = createVersionController({
Expand Down
117 changes: 107 additions & 10 deletions skills/leaf/assets/runtime/anchor-controls.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/* Retained controls derived from anchor paint.
*
* This view owns visual comment proxies, accessible comment notes, standing reaction
* controls, and message fragment state. Commands enter only through the constructor.
* controls, and message fragment state. A standing reaction first reveals its dedicated
* removal action; only that action withdraws the reaction. Commands enter only through
* the constructor.
*/

import { sameAnchor } from "./anchor-coordinate.js";
Expand Down Expand Up @@ -35,12 +37,44 @@ export function createAnchorControls({
invalidatePageGeometry,
messageReferenceRoot,
draftQuote,
presentedControl,
focused,
}) {
const visualActionHolders = new WeakMap();
const reactionSeats = new Map();
let mounted = false;
let invalidationQueued = false;

function syncReactionRemoval(record) {
for (const mark of record.seat.querySelectorAll(":scope > .lf-react-mark"))
mark.setAttribute(
"aria-expanded",
mark.dataset.event === record.expanded ? "true" : "false",
);
for (const remove of record.seat.querySelectorAll(":scope > .lf-react-remove"))
remove.hidden = remove.dataset.event !== record.expanded;
}

function setReactionRemoval(record, eventId, { focus = false } = {}) {
if (eventId)
for (const other of reactionSeats.values())
if (other !== record && other.expanded) {
other.expanded = null;
syncReactionRemoval(other);
other.margin?.update({ immediate: true });
}
record.expanded = eventId;
syncReactionRemoval(record);
record.margin?.update({ immediate: true });
if (focus && eventId)
requestAnimationFrame(() => {
const remove = record.seat.querySelector(
`:scope > .lf-react-remove[data-event="${CSS.escape(eventId)}"]`,
);
presentedControl(remove)?.focus({ preventScroll: true });
});
}

const visualActionAnchor = (anchor) =>
pageQueryAll(".lf-visual-action").find((control) =>
sameAnchor(control.lfAnchor, anchor),
Expand Down Expand Up @@ -183,37 +217,66 @@ export function createAnchorControls({
if (!record) {
const seat = el("span", `lf-ui ${SEAT}`);
seat.dataset.lfGen = "1";
record = { seat, roots, margin: null };
record = { seat, roots, expanded: null, margin: null };
reactionSeats.set(at, record);
}
const { seat } = record;
record.roots = roots;
kept.add(at);
if (at.id) seat.dataset.lfFor = at.id;
else seat.removeAttribute("data-lf-for");
const wanted = roots.map((root) => {
let mark = seat.querySelector(`:scope > [data-event="${root.id}"]`);
if (!roots.some((root) => root.id === record.expanded)) record.expanded = null;
const wanted = roots.flatMap((root) => {
let mark = seat.querySelector(
`:scope > .lf-react-mark[data-event="${CSS.escape(root.id)}"]`,
);
if (!mark) {
const entry = registry.$reactions.tokens[root.token];
mark = marginElement(offer("button", "lf-react-mark"), {
key: `take-back:${root.id}`,
key: `reaction:${root.id}:open`,
glyph: entry?.glyph ?? root.token,
label: root.token,
label: `${root.token} reaction actions`,
behavior: "disclosure",
role: "secondary",
});
mark.dataset.event = root.id;
mark.dataset.token = root.token;
mark.setAttribute("aria-label", `${root.token} — take it back`);
mark.onclick = () => withdrawReaction(root);
}
return mark;
let remove = seat.querySelector(
`:scope > .lf-react-remove[data-event="${CSS.escape(root.id)}"]`,
);
if (!remove) {
remove = marginElement(offer("button", "lf-react-remove"), {
key: `reaction:${root.id}:remove`,
icon: "cross",
label: `Remove ${root.token} reaction`,
tone: "negative",
role: "secondary",
});
remove.dataset.event = root.id;
remove.hidden = true;
remove.id = `lf-reaction-remove-${root.id}`;
}
mark.setAttribute("aria-controls", remove.id);
mark.onclick = (event) => {
const standing = focused();
setReactionRemoval(record, record.expanded === root.id ? null : root.id, {
focus:
event.detail === 0 &&
(standing === mark || standing?.lfForwardedControl === mark) &&
standing.matches(":focus-visible, .lf-focus-visible"),
});
};
remove.onclick = () => withdrawReaction(root);
return [mark, remove];
});
for (const child of [...seat.children])
if (!wanted.includes(child)) child.remove();
wanted.forEach((mark, index) => {
if (seat.children[index] !== mark)
seat.insertBefore(mark, seat.children[index] ?? null);
});
syncReactionRemoval(record);
if (!record.margin)
record.margin = registerMarginContribution({
key: "standing-reactions",
Expand All @@ -222,13 +285,14 @@ export function createAnchorControls({
items: () =>
record.roots.map((root) => ({
id: `reaction:${root.id}`,
text: `Take back ${root.token}`,
text: `${root.token} reaction actions`,
activate: () =>
record.seat
.querySelector(`[data-event="${CSS.escape(root.id)}"]`)
?.focus({ preventScroll: true }),
})),
side: "after",
state: () => (record.expanded ? "engaged" : "idle"),
claim: false,
});
else if (changed) record.margin.update();
Expand Down Expand Up @@ -289,18 +353,51 @@ export function createAnchorControls({
event.preventDefault();
};

const onOutsideReaction = (event) => {
for (const record of reactionSeats.values())
if (
record.expanded &&
!event.composedPath().some((node) => {
const source = node?.lfForwardedControl ?? node;
return source instanceof Node && record.seat.contains(source);
})
)
setReactionRemoval(record, null);
};

const onReactionKeydown = (event) => {
if (event.key !== "Escape") return;
const standing = focused();
const active = standing?.lfForwardedControl ?? standing;
for (const record of reactionSeats.values()) {
if (!record.expanded || !record.seat.contains(active)) continue;
const mark = record.seat.querySelector(
`:scope > .lf-react-mark[data-event="${CSS.escape(record.expanded)}"]`,
);
setReactionRemoval(record, null);
presentedControl(mark)?.focus({ preventScroll: true });
event.preventDefault();
event.stopPropagation();
return;
}
};

function mount() {
if (mounted) return;
mounted = true;
document.addEventListener("lf-projection", queueInvalidation);
document.addEventListener("lf-layout", onLayoutInvalidated);
document.addEventListener("pointerdown", onOutsideReaction, { capture: true });
document.addEventListener("keydown", onReactionKeydown, { capture: true });
messageReferenceRoot.addEventListener("click", onMessageReference);
}

function destroy() {
if (mounted) {
document.removeEventListener("lf-projection", queueInvalidation);
document.removeEventListener("lf-layout", onLayoutInvalidated);
document.removeEventListener("pointerdown", onOutsideReaction, { capture: true });
document.removeEventListener("keydown", onReactionKeydown, { capture: true });
messageReferenceRoot.removeEventListener("click", onMessageReference);
}
mounted = false;
Expand Down
12 changes: 11 additions & 1 deletion skills/leaf/assets/runtime/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -479,11 +479,21 @@ body.lf-drawing main .lf-conversation * { cursor: auto !important; }
html.lf-copy mark.lf-react { background: var(--react); color: inherit;
text-decoration: underline 2px solid var(--mark-ink); text-underline-offset: 3px; }
/* The glyphs of the reactions standing on a target — a margin element per reaction, the margin element
being the reaction's own eraser (anchors.js seatReactions). This is an unpositioned
revealing the reaction's separate eraser (anchor-controls.js seatReactions). This is an unpositioned
contribution: the living margin joins it to the target's other RHS controls and
decides whether their one complete item hangs or docks. */
.lf-reacts { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.lf-react-mark { text-align: center; }
/* Opening a standing reaction leaves its familiar face in place and reveals the
separately named removal action beside it. The relation changes; the mark does not
acquire a selected ring or pretend the reaction itself changed. */
.lf-react-mark[data-lf-behavior="disclosure"] {
border-width: 2px; border-color: var(--border-2); color: var(--ink);
box-shadow: 0 1px 0 var(--border-2), 0 3px 6px var(--shade);
}
.lf-margin-element.lf-react-mark[data-lf-behavior="disclosure"]:is(button, [role="button"]):hover:not([aria-disabled="true"]) {
border-color: var(--border-2);
}
/* The draft's own passage uses the accent contour. An open composer is on screen
whenever this is, and an already-posted element keeps the posted colour instead, so
the two states never contend on one element. */
Expand Down
22 changes: 22 additions & 0 deletions skills/leaf/assets/runtime/page-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function createPageMap({
button.lfMapEntry = entry;
button.lfMapItem = item;
delete button.lfMapControl;
delete button.lfForwardedControl;
button.dataset.lfMapItem = item.id;
delete button.dataset.lfMapMarginElement;
const label = item.text || entry.title;
Expand All @@ -162,6 +163,7 @@ export function createPageMap({
function syncSheetControl(button, entry, control) {
button.lfMapEntry = entry;
button.lfMapControl = control;
button.lfForwardedControl = control;
delete button.lfMapItem;
delete button.dataset.lfMapItem;
button.dataset.lfMapMarginElement = sheetControlKey(entry, control);
Expand Down Expand Up @@ -189,6 +191,26 @@ export function createPageMap({
}
const control = button.lfMapControl;
if (!control) return;
const controls = marginElements(control.parentElement);
const relation = control.getAttribute("aria-controls");
const controlled = relation
? controls.find((candidate) => candidate.id === relation)
: null;
// A disclosure that owns another contributed control unfolds within the map.
// The first press can then reveal the exact second action without closing the
// only surface where a spilled contribution is reachable.
if (controlled) {
const entry = button.lfMapEntry;
control.click();
requestAnimationFrame(() => {
const controlledKey = `control:${sheetControlKey(entry, controlled)}`;
const revealed = sheetList.querySelector(
`[data-lf-map-key="${CSS.escape(controlledKey)}"]`,
);
(revealed ?? button).focus({ preventScroll: true });
});
return;
}
const returnTo = from;
closeOwnsFocus = true;
sheet.close();
Expand Down
9 changes: 8 additions & 1 deletion skills/leaf/scripts/leaf/render-checks/standalone.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,14 @@ export function bake() {
mark.replaceWith(staticMark);
mark = staticMark;
}
for (const attr of ["tabindex", "data-lf-offer", "title", "type"])
for (const attr of [
"tabindex",
"data-lf-offer",
"title",
"type",
"aria-controls",
"aria-expanded",
])
mark.removeAttribute(attr);
mark.setAttribute("role", "img");
mark.setAttribute("aria-label", mark.dataset.token);
Expand Down
16 changes: 11 additions & 5 deletions tests/test_render_margin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1130,12 +1130,18 @@ def test_the_feature_gallery_keeps_its_real_actions_reachable(browser, serve, wi
if event.get("token") == "prioritize"
and event.get("anchor", {}).get("section") == "bg-crowded"
)
take_back = sheet.locator(
f'[data-lf-map-margin-element$=":take-back:{reaction["id"]}"]'
reaction_actions = sheet.locator(
f'[data-lf-map-margin-element$=":reaction:{reaction["id"]}:open"]'
)
expect(take_back).to_have_attribute("aria-label", "prioritize — take it back")
expect(reaction_actions).to_have_attribute(
"aria-label", "prioritize reaction actions"
)
reaction_actions.click()
expect(sheet).to_be_visible()
remove = sheet.get_by_role("button", name="Remove prioritize reaction", exact=True)
expect(remove).to_be_focused()
with sending(page, "the withdrawal of the spilled reaction"):
take_back.click()
remove.click()
expect(sheet).to_be_hidden()
expect(crowded.locator(f'[data-event="{reaction["id"]}"]')).to_have_count(0)
last = events_model.read_events(serve.page_dir)[-1]
Expand Down Expand Up @@ -3559,7 +3565,7 @@ def test_a_reaction_receipt_keeps_an_unided_selected_blocks_visual_coordinate(
sent = events_model.read_events(serve.page_dir)[-1]
assert sent["anchor"]["section"] == "s-how" and sent["anchor"]["quote"]
receipt = page.locator(".lf-margin-cluster").filter(
has=page.get_by_role("button", name=re.compile(r"^keep — take it back$"))
has=page.get_by_role("button", name="keep reaction actions", exact=True)
)
expect(receipt).to_have_count(1)
assert abs(receipt.bounding_box()["y"] - paragraph.bounding_box()["y"]) <= 6
Expand Down
8 changes: 4 additions & 4 deletions tests/test_render_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ def test_a_shipped_log_replays_its_example_state(browser, serve):
# its visible route rather than requiring every margin element to stand at rest.
item = glyph.locator("xpath=ancestor::*[@data-lf-margin-for][1]")
visible = item.locator(
f'[data-lf-margin-element-key="take-back:{reaction["id"]}"]:visible, '
f'[data-lf-margin-element-key="take-back:{reaction["id"]}:proxy"]:visible'
f'[data-lf-margin-element-key="reaction:{reaction["id"]}:open"]:visible, '
f'[data-lf-margin-element-key="reaction:{reaction["id"]}:open:proxy"]:visible'
)
more = item.locator(":scope > .lf-margin-more")
if not visible.count() and more.is_visible():
Expand All @@ -263,8 +263,8 @@ def test_a_shipped_log_replays_its_example_state(browser, serve):
sheet = page.get_by_role("dialog", name="Page map", exact=True)
expect(
sheet.locator(
f'[data-lf-map-margin-element$=":take-back:{reaction["id"]}"], '
f'[data-lf-map-margin-element$=":take-back:{reaction["id"]}:proxy"]'
f'[data-lf-map-margin-element$=":reaction:{reaction["id"]}:open"], '
f'[data-lf-map-margin-element$=":reaction:{reaction["id"]}:open:proxy"]'
)
).to_be_visible()
page.keyboard.press("Escape")
Expand Down
Loading
Loading