Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 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