diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index 1fe630b657..7c48ea4dfd 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -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 diff --git a/skills/leaf/assets/runtime/composing/targets.js b/skills/leaf/assets/runtime/composing/targets.js index b63c8bc072..888c5b0907 100644 --- a/skills/leaf/assets/runtime/composing/targets.js +++ b/skills/leaf/assets/runtime/composing/targets.js @@ -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; @@ -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; @@ -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() @@ -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 && @@ -151,6 +173,7 @@ export function createTargetSelection({ matches = []; active = -1; hintActive = -1; + scrolling = false; selectionInput.value = ""; selectionSearch.hidden = true; if (on && withHints) { @@ -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( @@ -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) { @@ -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]); @@ -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); } @@ -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", diff --git a/skills/leaf/packages/default/theme.css b/skills/leaf/packages/default/theme.css index 579a4d634d..6d8a2b9b9f 100644 --- a/skills/leaf/packages/default/theme.css +++ b/skills/leaf/packages/default/theme.css @@ -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 diff --git a/skills/leaf/references/page-authoring.md b/skills/leaf/references/page-authoring.md index 7a2835e6bb..7c09ee2356 100644 --- a/skills/leaf/references/page-authoring.md +++ b/skills/leaf/references/page-authoring.md @@ -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 diff --git a/tests/test_render_semantic_selection.py b/tests/test_render_semantic_selection.py index 4c79921f75..ee3b35ca01 100644 --- a/tests/test_render_semantic_selection.py +++ b/tests/test_render_semantic_selection.py @@ -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", + '
One visible box.
', + head="", + ) + 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.""" @@ -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'{i}' for i in range(hidden_count)) + html = leaf_page( + "hidden hint targets", + f""" +

Visible targets

+

A boxless visible target.

+
+ Hidden targets + {hidden} +
+ +""", + ) + 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.""" diff --git a/tests/test_render_widgets.py b/tests/test_render_widgets.py index d614f81e33..231419685f 100644 --- a/tests/test_render_widgets.py +++ b/tests/test_render_widgets.py @@ -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()