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
2 changes: 1 addition & 1 deletion skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ and tray panels;
`runtime/trays.js` owns the left tray edge, active tray, registration, restore, and
shared tray furniture;
`runtime/live-leaves.js` owns the machine-leaves tray's rows, presence words, and walk;
`runtime/living-margin.js` owns the page map, compact map sheet, inline margin threads,
`runtime/living-margin.js` owns the page map, compact map sheet, anchored margin threads,
and the one aggregated action, communication, and information item for each page target;
content modules contribute live controls and semantics through its registration seam but
never place their own RHS rows;
Expand Down
7 changes: 7 additions & 0 deletions skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ const {
} = createTrays({
beforeOpen: () => {
if (chromeLayout?.panelIsOpen()) setPanel(false);
livingMargin?.closePreview();
},
drawnEdge,
el,
Expand All @@ -741,6 +742,7 @@ const {
readerStore,
renderDecisions: () => renderDecisions(openDecisions()),
syncLayout,
trayChanged: () => livingMargin?.render(),
walkRows,
});
const { leavesOffered, othersLinks, renderOthers } = createLiveLeaves({
Expand Down Expand Up @@ -1143,6 +1145,9 @@ chromeLayout = createChromeLayout({
pageShifted: (...args) => pageShifted(...args),
paintHere,
panel,
panelChanged: (open) => {
if (open) livingMargin?.closePreview();
},
panelFocusTarget: threadsBox,
panelFoot,
panelList: threadsBox,
Expand Down Expand Up @@ -3413,12 +3418,14 @@ livingMargin = createLivingMargin({
keys,
offer,
openDecisions,
panelIsOpen: chromeLayout.panelIsOpen,
pageScroller,
paintKeys,
placedAt,
renderMarginThread: conversationRuntime.renderMarginThread,
scrollBehavior,
scrollToElement,
setPanel,
showThread,
stateProjection,
threads: () => conversationRuntime.threadList,
Expand Down
2 changes: 2 additions & 0 deletions skills/leaf/assets/runtime/chrome-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function createChromeLayout({
pageShifted,
paintHere,
panel,
panelChanged,
panelFocusTarget,
panelFoot,
panelList,
Expand Down Expand Up @@ -217,6 +218,7 @@ export function createChromeLayout({
syncGeneral(); // a restored draft has to reach the Send button's disabled state
} else if (panel.open) panel.close();
syncLayout();
panelChanged(open);
readerStore.set(PANEL_KEY, open ? "1" : "0");
paintHere();
// The panel is one of the two surfaces the hover reads, so its arriving or going away
Expand Down
8 changes: 6 additions & 2 deletions skills/leaf/assets/runtime/chrome-style.js
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,12 @@ ${MARK_RULES}
scroll-padding-block: var(--here-ring-room);
margin: 0 8px; padding: 12px; border: 1px solid var(--border-2); border-radius: 10px;
background: var(--paper); color: var(--ink); box-shadow: 0 12px 36px rgba(0,0,0,.18); }
.lf-margin-preview[data-lf-thread] { position-area: inline-end;
position-try-fallbacks: none; align-self: start;
width: min(var(--thread-card), calc(100vw - 24px));
max-height: calc(100dvh - var(--lf-banner-h) - 16px);
margin: calc(var(--lf-banner-h) + 8px) 8px 8px;
border-left: 3px solid var(--mark-ink); }
.lf-margin-preview-head, .lf-page-map-head { display: flex; align-items: center;
justify-content: space-between; gap: 12px; }
.lf-margin-preview-title { font-size: var(--t-5); line-height: 1.35; }
Expand All @@ -1189,8 +1195,6 @@ ${MARK_RULES}
.lf-margin-thread .lf-conversation-msg:first-child { margin-top: 0; }
.lf-margin-thread .lf-say { align-items: flex-end; }
.lf-margin-thread .lf-say textarea { min-width: 0; }
.lf-margin-thread-open { display: block; margin: 8px 0 0 auto; color: var(--muted);
font-size: var(--t-6); }
.lf-margin-preview-action, .lf-page-map-action { width: 100%; min-width: 0;
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px;
align-items: baseline; border: 0; border-radius: var(--r); background: transparent;
Expand Down
6 changes: 5 additions & 1 deletion skills/leaf/assets/runtime/conversation/inline.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ export function createInlineConversations({
node.lfRevision = message.edited?.id ?? "";
node.append(head, body);
if (message.markup) {
const open = offer("button", "lf-btn lf-conversation-open", "Open in Threads");
const open = offer(
"button",
"lf-btn lf-conversation-open",
"Open interactive reply in Threads",
);
open.onclick = () => showThread(message.id);
node.append(open);
}
Expand Down
116 changes: 93 additions & 23 deletions skills/leaf/assets/runtime/living-margin.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { clampedRow } from "./keyboard/bindings.js";
const KINDS = {
action: { label: "Action", symbol: "·", priority: -1 },
change: { label: "Change", symbol: "Δ", priority: 0 },
comment: { label: "Comment", symbol: "¶", priority: 1 },
comment: { label: "Thread", symbol: "¶", priority: 1 },
decision: { label: "Ask", symbol: "?", priority: 2 },
outcome: { label: "Outcome", symbol: "✓", priority: 3 },
activity: { label: "Agent activity", symbol: "↻", priority: 4 },
Expand Down Expand Up @@ -190,12 +190,14 @@ export function createLivingMargin(dependencies) {
keys,
offer,
openDecisions,
panelIsOpen,
pageScroller,
paintKeys,
placedAt,
renderMarginThread,
scrollBehavior,
scrollToElement,
setPanel,
showThread,
stateProjection,
threads,
Expand All @@ -218,7 +220,12 @@ export function createLivingMargin(dependencies) {
) {
const main = document.querySelector("main");
if (!main || !columnRect) return;
nav.style.left = `${columnRect.left + pageScroller.scrollLeft}px`;
// The chrome root is a body child. A standing left tray moves that body's box,
// so viewport coordinates would spend its offset twice when applied to this
// absolute child. Convert the document column's horizontal coordinate into the
// root's moved space; neither side panel changes its document-space vertical origin.
const chromeRect = chromeRoot.getBoundingClientRect();
nav.style.left = `${columnRect.left - chromeRect.left + pageScroller.scrollLeft}px`;
nav.style.top = `${columnRect.top + pageScroller.scrollTop}px`;
nav.style.width = `${columnRect.width}px`;
nav.style.height = `${main.scrollHeight}px`;
Expand Down Expand Up @@ -286,12 +293,24 @@ export function createLivingMargin(dependencies) {
let previewEntry = null;
let previewButton = null;
let previewShowing = false;
let previewFocusRequested = false;
let pinnedKey = null;
let suppressedKey = null;
let cardHovered = false;
let highlighted = null;
let rovingFrame = 0;
let sheetActivation = false;
let observedMain = null;
// The cascade owns available room: panels and trays change the body's named
// container, while an authored sidebar claims the page's left strip. Read the
// posture it resolved instead of asking the viewport a different question.
const threadBeside = () =>
getComputedStyle(document.querySelector("main"))
.getPropertyValue("--lf-thread-beside")
.trim() === "1";
const threadNeedsPress = (entry) =>
!threadBeside() && entry.items.some((item) => item.kind === "comment");
const postureObserver = new ResizeObserver(() => render());

// A margin item is hoisted away from the page target it belongs to, so ancestry
// cannot answer what a press on one of its controls is about. Keep that relationship
Expand Down Expand Up @@ -804,6 +823,12 @@ export function createLivingMargin(dependencies) {

function render() {
const main = document.querySelector("main");
if (main !== observedMain) {
postureObserver.disconnect();
postureObserver.observe(document.body);
if (main) postureObserver.observe(main);
observedMain = main;
}
if (!nav.isConnected) chromeRoot.append(nav);
placeMargin(main?.getBoundingClientRect());
syncInlineOffers();
Expand Down Expand Up @@ -833,19 +858,27 @@ export function createLivingMargin(dependencies) {
collapse: "always",
});
marker.onclick = (event) => {
// The marker always means the contextual thread. If the overview is open,
// hand the right edge back before building the anchored conversation.
if (
panelIsOpen() &&
marker.lfEntry.items.some((item) => item.kind === "comment")
)
setPanel(false);
const previewWasOpen = preview.matches(":popover-open");
previewFocusRequested = event.detail === 0;
togglePinned(marker.lfEntry, marker);
if (event.detail === 0 && pinnedKey === marker.lfEntry.key)
previewList
.querySelector("textarea, button")
?.focus({ preventScroll: true });
if (pinnedKey !== marker.lfEntry.key) previewFocusRequested = false;
else if (previewWasOpen) focusPreviewAction();
};
marker.addEventListener("pointerenter", () => {
suppressedKey = null;
showPreview(marker.lfEntry, marker);
if (!threadNeedsPress(marker.lfEntry)) showPreview(marker.lfEntry, marker);
});
marker.addEventListener("pointerleave", deferPreviewClose);
marker.addEventListener("focus", () => {
if (suppressedKey !== marker.lfEntry.key) showPreview(marker.lfEntry, marker);
if (suppressedKey !== marker.lfEntry.key && !threadNeedsPress(marker.lfEntry))
showPreview(marker.lfEntry, marker);
});
marker.addEventListener("blur", deferPreviewClose);
keys(
Expand Down Expand Up @@ -906,10 +939,30 @@ export function createLivingMargin(dependencies) {
}

function buildPreview(entry) {
const focusedItem = preview.contains(document.activeElement)
? document.activeElement.closest?.("[data-lf-margin-item]")?.dataset.lfMarginItem
const focusedNode = preview.contains(document.activeElement)
? document.activeElement.closest?.("[data-lf-margin-item]")
: null;
previewTitle.textContent = entry.title;
const focusedItem = focusedNode?.dataset.lfMarginItem ?? null;
const hasThread = entry.items.some((item) => item.kind === "comment");
const inlineThread = hasThread && !panelIsOpen() && threadBeside();
const focusedRecord = entry.items.find((item) => item.id === focusedItem);
const focusedBecomesThread = focusedRecord?.kind === "comment" && inlineThread;
if (
focusedNode &&
focusedNode.classList.contains("lf-margin-thread") !== focusedBecomesThread
)
previewClose.focus({ preventScroll: true });
const targetHeading = entry.target?.querySelector(":scope > strong")?.textContent;
const title = inlineThread
? trimmed(targetHeading || entry.title, 72)
: entry.title;
preview.toggleAttribute("data-lf-thread", inlineThread);
preview.setAttribute("aria-label", inlineThread ? `Thread for ${title}` : title);
previewClose.setAttribute(
"aria-label",
inlineThread ? "Close thread" : "Close page-map preview",
);
previewTitle.textContent = title;
previewKinds.replaceChildren(
...kindsIn(entry).map(({ kind, label, symbol, count }) => {
const chip = el("span", `lf-margin-kind lf-margin-${kind}`);
Expand All @@ -920,41 +973,44 @@ export function createLivingMargin(dependencies) {
return chip;
}),
);
const nodes = entry.items.map((item) => previewItemNode(entry, item));
const nodes = entry.items.map((item) =>
previewItemNode(entry, item, { inlineThread }),
);
const keep = new Set(nodes);
for (const child of [...previewList.children]) if (!keep.has(child)) child.remove();
let cursor = previewList.firstChild;
for (const node of nodes) {
if (node === cursor) cursor = cursor.nextSibling;
else previewList.insertBefore(node, cursor);
}
if (focusedItem) {
if (focusedItem && !focusedNode?.isConnected) {
const replacement = [
...previewList.querySelectorAll("[data-lf-margin-item]"),
].find((candidate) => candidate.dataset.lfMarginItem === focusedItem);
(replacement ?? previewClose).focus({ preventScroll: true });
const destination = replacement?.matches("button, textarea:not([disabled])")
? replacement
: (replacement?.querySelector("textarea:not([disabled])") ??
replacement?.querySelector("button") ??
previewClose);
destination.focus({ preventScroll: true });
}
}

function previewItemNode(entry, item) {
function previewItemNode(entry, item, { inlineThread }) {
let node = [...previewList.children].find(
(candidate) => candidate.dataset.lfMarginItem === item.id,
);
if (item.kind === "comment") {
if (item.kind === "comment" && inlineThread) {
if (!node?.classList.contains("lf-margin-thread")) {
node?.remove();
node = el("section", "lf-margin-thread");
const body = el("div", "lf-margin-thread-body");
const open = el("button", "lf-btn lf-margin-thread-open", "Open in Threads");
open.type = "button";
node.append(body, open);
node.append(body);
}
renderMarginThread(
node.querySelector(":scope > .lf-margin-thread-body"),
item.thread,
);
node.querySelector(":scope > .lf-margin-thread-open").onclick = () =>
activate(item, entry);
} else {
if (!node?.classList.contains("lf-margin-preview-action")) {
node?.remove();
Expand Down Expand Up @@ -1034,12 +1090,22 @@ export function createLivingMargin(dependencies) {
showPreview(entry, button);
}

function focusPreviewAction() {
if (!previewFocusRequested) return;
previewFocusRequested = false;
(
previewList.querySelector("textarea:not([disabled])") ??
previewList.querySelector("button")
)?.focus({ preventScroll: true });
}

function closePreview(returnFocus) {
const button = previewButton;
if (returnFocus && button?.lfEntry) suppressedKey = button.lfEntry.key;
pinnedKey = null;
previewEntry = null;
previewButton = null;
previewFocusRequested = false;
button?.style.removeProperty("anchor-name");
if (preview.matches(":popover-open")) preview.hidePopover();
highlight(null);
Expand Down Expand Up @@ -1157,6 +1223,10 @@ export function createLivingMargin(dependencies) {
if (event.newState === "closed" && previewEntry) suppressedKey = previewEntry.key;
});
preview.addEventListener("toggle", (event) => {
if (event.newState === "open") {
focusPreviewAction();
return;
}
if (event.newState !== "closed" || !previewEntry) return;
const button = previewButton;
pinnedKey = null;
Expand Down Expand Up @@ -1195,12 +1265,12 @@ export function createLivingMargin(dependencies) {
{ capture: true, passive: true },
);
window.addEventListener("resize", () => {
scheduleMarginLayout();
scheduleRoving();
render();
});
render();

return {
closePreview: () => closePreview(false),
enterPageMap,
marginTargetAt,
openPageMapItem,
Expand Down
3 changes: 3 additions & 0 deletions skills/leaf/assets/runtime/trays.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function createTrays({
readerStore,
renderDecisions,
syncLayout,
trayChanged,
walkRows,
}) {
// The rows' own box, one per tray. Collected privately as they are made, because what
Expand Down Expand Up @@ -181,6 +182,7 @@ export function createTrays({
// page's own box.
if (key) document.body.dataset.lfTray = key;
else delete document.body.dataset.lfTray;
trayChanged();
paintKeys();
}
// Registration only. No tray opens while this factory evaluates: showTray runs from a
Expand Down Expand Up @@ -216,6 +218,7 @@ export function createTrays({
// (an exported or pre-presented DOM) can restore immediately through the same function.
trayUp = readerStore.get(TRAY_KEY) || null;
if (trayUp) document.body.dataset.lfTray = trayUp;
trayChanged();
if (pagePresented()) restoreTray();
}

Expand Down
Loading
Loading