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
8 changes: 6 additions & 2 deletions skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1732,8 +1732,12 @@ cost one letter; only the tail branches when the viewport holds more targets tha
alphabet. Unlike `g` addresses, these hints are ephemeral and make no promise across a
scroll or revision. They are the whole route, so none may be dropped because its chip
collides. A target whose visible box is strictly smaller and fully enclosed by another
target steps its chip right once per enclosing box. Equal boxes stay at the same depth;
the collision pass separates their chips without inventing a hierarchy.
target steps its chip right once per enclosing box. An ancestor and descendant with the
same visible box name one target: the innermost remains, matching direct aim. Equal boxes
outside one containment chain stay at the same depth, and the collision pass separates
their chips without inventing a hierarchy. Membership is fixed for the length of a
scroll and re-read once it settles, so a target arriving mid-scroll is named at rest
rather than on the frame it appears.

Tab and Shift-Tab walk the visible target map and announce each item. Enter chooses the
last one announced. A viewport change that removes or renames that target clears the
Expand Down
60 changes: 50 additions & 10 deletions skills/leaf/assets/runtime/composing/targets.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function createTargetSelection({
let hintActive = -1;
let opener = null;
let searchReturnsToHints = false;
let scrolling = false;

const clips = () => new Map();
const covered = () => banner.getBoundingClientRect().bottom;
Expand All @@ -68,11 +69,13 @@ export function createTargetSelection({
const y = Math.max(covered(), Math.min(innerHeight - 1, box.top + 1));
return !inChrome(document.elementFromPoint(x, y));
};
// Chromium retains geometry for descendants suppressed by a closed disclosure.
// Require a part the browser paints before that stale box can enter or remain in the
// hint map. shownParts keeps display: contents items eligible through visible children.
// Chromium retains geometry for descendants suppressed by a closed disclosure. Ask
// visibility before geometry so those descendants cost no box reads. A display: contents
// item has no box of its own and stays eligible through a visible child.
const targetShown = ({ element }) =>
shownParts(element).some((part) => part.checkVisibility());
element.checkVisibility() ||
(getComputedStyle(element).display === "contents" &&
shownParts(element).some((part) => part.checkVisibility()));

function clippedRect(box, clip) {
if (!box || !clip) return null;
Expand Down Expand Up @@ -115,6 +118,12 @@ export function createTargetSelection({
return codes.slice(0, count);
}

const sameVisibleBox = (a, b) =>
Math.abs(a.left - b.left) < 0.5 &&
Math.abs(a.top - b.top) < 0.5 &&
Math.abs(a.right - b.right) < 0.5 &&
Math.abs(a.bottom - b.bottom) < 0.5;

function visibleTargets() {
const cache = clips();
const targets = aimTargets()
Expand All @@ -123,11 +132,24 @@ export function createTargetSelection({
.map((target) => ({ ...target, rect: shownRect(target.element, cache) }))
.filter(({ rect }) => exposed(rect))
.sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
const codes = hintCodes(targets.length);
return targets.map((target, index) => ({
// Direct aiming chooses the innermost stable item under the pointer. When an
// ancestor and descendant paint the same visible box, naming both would offer two
// keys for that one choice. Keep distinct nested extents and unrelated overlaps.
const unique = targets.filter(
(outer) =>
!targets.some(
(inner) =>
inner !== outer &&
outer.element !== inner.element &&
outer.element.contains(inner.element) &&
sameVisibleBox(outer.rect, inner.rect),
),
);
const codes = hintCodes(unique.length);
return unique.map((target, index) => ({
...target,
code: codes[index],
nesting: targets.filter(
nesting: unique.filter(
(outer) =>
outer !== target &&
outer.rect.left <= target.rect.left &&
Expand All @@ -151,6 +173,7 @@ export function createTargetSelection({
matches = [];
active = -1;
hintActive = -1;
scrolling = false;
selectionInput.value = "";
selectionSearch.hidden = true;
if (on && withHints) {
Expand Down Expand Up @@ -390,9 +413,9 @@ export function createTargetSelection({
if (selectionLayer.childElementCount) selectionLayer.replaceChildren();
return;
}
const refreshed = !searching && !prefix;
const refreshed = !searching && !prefix && !scrolling;
const heard = hinted()[hintActive];
if (refreshed) {
const heard = hinted()[hintActive];
candidates = visibleTargets();
const still = heard
? candidates.findIndex(
Expand All @@ -403,6 +426,7 @@ export function createTargetSelection({
hintActive = still;
}
const drawn = [];
const drawnTargets = new Set();
if (!searching) {
const cache = clips();
for (const target of candidates) {
Expand All @@ -415,6 +439,7 @@ export function createTargetSelection({
chip.style.top = `${Math.min(bottomCovered() - 10, Math.max(covered(), rect.top))}px`;
if (rect.clippedTop || rect.top < covered()) chip.classList.add("lf-in");
drawn.push(chip);
drawnTargets.add(target);
}
} else if (matches[active]) {
const owner = matchOwner(matches[active]);
Expand All @@ -431,6 +456,7 @@ export function createTargetSelection({
drawn.push(mark);
}
}
if (!refreshed && heard && !drawnTargets.has(heard)) hintActive = -1;
selectionLayer.replaceChildren(...drawn);
if (!searching) spreadHints(drawn);
}
Expand All @@ -440,11 +466,25 @@ export function createTargetSelection({
"scroll",
() => {
if (!open) return;
scrolling = true;
paintHere();
},
{ capture: true, passive: true },
);
addEventListener(
"scrollend",
() => {
if (!open || !scrolling) return;
scrolling = false;
paintHere();
},
{ capture: true, passive: true },
);
addEventListener("resize", () => open && paintHere());
addEventListener("resize", () => {
if (!open) return;
scrolling = false;
paintHere();
});

const PAGE_SEARCH = {
id: "page.search.open",
Expand Down
2 changes: 1 addition & 1 deletion skills/leaf/packages/default/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -1315,7 +1315,7 @@ lf-toc a:hover, lf-toc a:focus-visible { color: var(--accent); }
forced-color-adjust: none; background: Highlight;
}
}
.lf-toc-target { display: block; width: 1px; height: 1px; margin-bottom: -1px; }
.lf-toc-target { display: block; width: 1px; height: 0; }

/* lf-decision: a semantic reading and arrival boundary, with no presentation of its own.
* Block rather than the custom-element default inline so its box begins at the
Expand Down
4 changes: 3 additions & 1 deletion skills/leaf/references/page-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ page width.

## Stable anchors

Give every section, major block, and widget item a stable, meaningful `id`.
Give each section, major block, and widget item a stable, meaningful `id` at the
tightest semantic boundary a reader can distinguish. Where a sole child fills a
transparent wrapper, let the child carry the pair's one id.
Threads and reading position attach to those ids across versions. Keep an id
where its passage survives, and stay out of the `lf-` prefix: it is the runtime's
namespace for ids and for classes alike, and `data-lf-` is the same for
Expand Down
84 changes: 84 additions & 0 deletions tests/test_render_semantic_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,38 @@ def test_nested_item_hints_show_containment_without_covering_each_other(browser,
page.close()


def test_identical_nested_item_hints_choose_the_innermost_target(browser, serve):
"""A transparent wrapper and its only child can describe one visible box. The
chooser names that box once and agrees with direct aiming by selecting the child."""
html = leaf_page(
"identical nested targets",
'<section id="outer"><div id="inner">One visible box.</div></section>',
head="<style>section, div { margin: 0; }</style>",
)
page, errors = open_page(browser, serve(html))
geometry = page.evaluate(
"""() => ['outer', 'inner'].map(id => {
const { left, top, right, bottom } =
document.getElementById(id).getBoundingClientRect();
return { left, top, right, bottom };
})"""
)
assert all(
abs(geometry[0][edge] - geometry[1][edge]) < 0.5
for edge in ("left", "top", "right", "bottom")
), geometry

page.keyboard.press("s")
hints = page.locator(".lf-target-hint")
expect(hints).to_have_count(1)
page.keyboard.type(hints.get_attribute("data-lf-target"))
page.keyboard.press("c")
expect(page.locator(".lf-composer")).to_be_visible()
assert page.evaluate(DRAFT_MARK) == "inner"
assert errors == []
page.close()


def test_selection_hints_name_only_items_shown_by_a_disclosure(browser, serve):
"""A shut disclosure keeps its own hint but not hints for its contents. The same
rule applies after a prefix narrows the open disclosure's map."""
Expand Down Expand Up @@ -643,6 +675,58 @@ def test_hint_browsing_forgets_a_target_that_scrolls_out_of_the_map(browser, ser
page.close()


def test_scrolling_item_hints_does_not_measure_hidden_targets(browser, serve):
"""A smooth scroll repositions the small visible map and refreshes its membership
once at rest; targets inside a closed disclosure never incur geometry reads."""
hidden_count = 1000
hidden = "".join(f'<span id="hidden-{i}">{i}</span>' for i in range(hidden_count))
html = leaf_page(
"hidden hint targets",
f"""
<h1 id="title">Visible targets</h1>
<div id="contents" style="display: contents"><p>A boxless visible target.</p></div>
<details id="evidence">
<summary>Hidden targets</summary>
{hidden}
</details>
<div style="height: 1200px" aria-hidden="true"></div>
""",
)
page, errors = open_page(browser, serve(html))
page.keyboard.press("s")
expect(page.locator(".lf-target-hint")).to_have_count(3)

page.evaluate(
"""() => {
const originalRect = Element.prototype.getBoundingClientRect;
const originalVisibility = Element.prototype.checkVisibility;
let rectReads = 0;
let visibilityReads = 0;
Element.prototype.getBoundingClientRect = function (...args) {
rectReads += 1;
return originalRect.apply(this, args);
};
Element.prototype.checkVisibility = function (...args) {
visibilityReads += 1;
return originalVisibility.apply(this, args);
};
addEventListener('scrollend', () => requestAnimationFrame(() => {
window.lfHintScrollReads = {rectReads, visibilityReads};
Element.prototype.getBoundingClientRect = originalRect;
Element.prototype.checkVisibility = originalVisibility;
}), {capture: true, once: true});
document.scrollingElement.scrollTo({top: 600, behavior: 'smooth'});
}"""
)
page.wait_for_function("() => window.lfHintScrollReads")
reads = page.evaluate("() => window.lfHintScrollReads")

assert reads["rectReads"] < hidden_count, reads
assert reads["visibilityReads"] < hidden_count * 3, reads
assert errors == []
page.close()


def test_cancelling_page_search_restores_the_control_that_opened_it(browser, serve):
"""Direct search is one layer. A repeated slash cannot change that when focus has
left its box: Escape still closes search and returns to the original control."""
Expand Down
18 changes: 18 additions & 0 deletions tests/test_render_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@ def test_a_table_of_contents_reads_the_page_outline_and_reveals_its_heading(
expect(target).to_have_attribute("data-lf-gen", "1")
expect(target).to_have_class(re.compile(r"\blf-ui\b"))

# A generated fragment target must not trap the heading's collapsed top margin
# inside an otherwise transparent section. The section, target, and first visible
# heading should begin at the same rendered edge.
verify_geometry = page.get_by_role("heading", name="Verify").evaluate(
"""heading => {
const section = heading.parentElement;
const target = heading.previousElementSibling;
return {
sectionTop: section.getBoundingClientRect().top,
targetTop: target.getBoundingClientRect().top,
headingTop: heading.getBoundingClientRect().top,
};
}"""
)
assert max(verify_geometry.values()) - min(verify_geometry.values()) < 0.5, (
verify_geometry
)

details = page.locator("details")
expect(details).not_to_have_attribute("open", "")
toc.get_by_role("link", name="Move the readers").click()
Expand Down
Loading