diff --git a/examples/corpus.html b/examples/corpus.html
index 886f41391..1d1cb43bf 100644
--- a/examples/corpus.html
+++ b/examples/corpus.html
@@ -842,13 +842,31 @@
One unchanged root, replacement calls in context
>
+ The default the picker now opens on
+
+ The one hunk the rest of the change is arranged around: the picker asks for a
+ prepared diff instead of assembling its own git diff arguments.
+
+
+
+diff --git a/src/git/diff.rs b/src/git/diff.rs
+--- a/src/git/diff.rs
++++ b/src/git/diff.rs
+@@ -112,4 +112,3 @@ impl Repository {
+ pub fn picker_preview(&self, worktree: &Worktree) -> Result<Diff> {
+- let args = ["diff", "--patch", "--no-color", "--find-renames", "--diff-algorithm=histogram", "HEAD"];
+- Diff::parse(&self.git(&args, worktree.path())?)
++ PreparedDiff::for_worktree(self, worktree)?.unified()
+ }
+
+
Review invariants
The implementation is expected to preserve these three properties.
-assert_eq!(real_index_after, real_index_before);
+assert_eq!(repo.read_index_bytes(&worktree)?, index_before_preview, "preview preparation rewrote {worktree:?}");
assert!(temp_index.is_operation_scoped());
assert!(offscreen_worktree_preview.is_demand_loaded());
diff --git a/examples/pr-walkthrough.html b/examples/pr-walkthrough.html
index 1433c9b4d..0100e1b88 100644
--- a/examples/pr-walkthrough.html
+++ b/examples/pr-walkthrough.html
@@ -359,13 +359,31 @@ One unchanged root, replacement calls in context
>
+ The default the picker now opens on
+
+ The one hunk the rest of the change is arranged around: the picker asks for a
+ prepared diff instead of assembling its own git diff arguments.
+
+
+
+diff --git a/src/git/diff.rs b/src/git/diff.rs
+--- a/src/git/diff.rs
++++ b/src/git/diff.rs
+@@ -112,4 +112,3 @@ impl Repository {
+ pub fn picker_preview(&self, worktree: &Worktree) -> Result<Diff> {
+- let args = ["diff", "--patch", "--no-color", "--find-renames", "--diff-algorithm=histogram", "HEAD"];
+- Diff::parse(&self.git(&args, worktree.path())?)
++ PreparedDiff::for_worktree(self, worktree)?.unified()
+ }
+
+
Review invariants
The implementation is expected to preserve these three properties.
-assert_eq!(real_index_after, real_index_before);
+assert_eq!(repo.read_index_bytes(&worktree)?, index_before_preview, "preview preparation rewrote {worktree:?}");
assert!(temp_index.is_operation_scoped());
assert!(offscreen_worktree_preview.is_demand_loaded());
diff --git a/skills/leaf/packages/default/theme.css b/skills/leaf/packages/default/theme.css
index c10fdcbab..6eb093791 100644
--- a/skills/leaf/packages/default/theme.css
+++ b/skills/leaf/packages/default/theme.css
@@ -1576,9 +1576,15 @@ lf-chart .lf-chart-swatch { width: 9px; height: 9px; border-radius: 2px;
font-weight: 650; }
.lf-diff-filtered { display: none !important; }
.lf-diff-next:is(:focus-visible, .lf-focus-visible),
-.lf-diff-review:is(:focus-visible, .lf-focus-visible),
.lf-diff-search:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring);
+ --lf-here-ring: diff-tools;
outline-offset: 1px; }
+/* The review press rides the file's own summary row, inside the box that clips it, so
+ it takes the inset the summary below takes and not the toolbar's outward gap: drawn
+ outward its lower run fell past the bottom the details clips at. */
+.lf-diff-review:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring);
+ --lf-here-ring: diff-review;
+ outline-offset: calc(-1 * var(--here-ring-w)); }
/* The file's own row, and the box it opens clips (overflow above), so the ring it wears
is inset — theme.css's rule for a control packed into something rather than standing
on its own. Outside, its top run was drawn into the border the details draws and lost
@@ -1603,6 +1609,12 @@ lf-chart .lf-chart-swatch { width: 9px; height: 9px; border-radius: 2px;
--diffs-bg-separator: var(--code-chrome);
--diffs-fg: var(--code-ink);
--diffs-fg-number: var(--code-muted);
+ /* A changed row's number is drawn in that row's own base by default, which is the
+ ink the fill beside it is mixed from: green on green at 4.03:1 in the light
+ palette. The tinted-surface inks are the layer's answer to exactly that question
+ and keep the number the same statement, legibly. */
+ --diffs-fg-number-addition-override: var(--ok-ink);
+ --diffs-fg-number-deletion-override: var(--danger-ink);
--diffs-addition-base: var(--ok);
--diffs-deletion-base: var(--danger);
--diffs-bg-addition: color-mix(in srgb, var(--ok) 16%, var(--pre-bg));
diff --git a/skills/leaf/packages/pr-review/theme.css b/skills/leaf/packages/pr-review/theme.css
index b2115b591..3e1cdc3be 100644
--- a/skills/leaf/packages/pr-review/theme.css
+++ b/skills/leaf/packages/pr-review/theme.css
@@ -121,10 +121,15 @@ lf-pull-request {
font: var(--t-4) var(--mono);
}
+/* The card is already a tint of the accent over the page's paper, so a second
+ * translucent accent here stacked into a surface the layer's own --muted ink could not
+ * clear: the label and any emphasis inside the author's words read at 4.32:1. The
+ * raised-surface token separates the quoted block from the card by going lighter
+ * instead, which the accent rule beside it was doing the work of anyway. */
.lf-pr-description {
padding: var(--sp-3);
border-inline-start: 3px solid var(--accent);
- background: color-mix(in srgb, var(--accent) 5%, transparent);
+ background: var(--card);
}
.lf-pr-description-label,
@@ -268,7 +273,10 @@ lf-call-diff {
font-size: var(--t-6);
}
-.lf-call-toggle {
+/* Doubled with .lf-ui, which `offer` writes on every control a widget injects: the
+ * runtime's chrome face and ink are stated at one class, so a package rule that names
+ * only its own class loses the face it asked for. */
+.lf-call-toggle.lf-ui {
flex: none;
padding: 3px 9px;
border: 1px solid var(--code-border);
@@ -420,7 +428,9 @@ lf-call-diff {
text-underline-offset: 2px;
}
-.lf-call-location:hover {
+/* Only where there is somewhere to go: a row that names its location without linking it
+ * must not answer the pointer as though it did. */
+.lf-call-location[href]:hover {
color: var(--accent);
}
@@ -450,10 +460,6 @@ lf-call-diff {
background: transparent;
}
- .lf-call-toggle {
- display: none;
- }
-
.lf-call-group:not([open]) > .lf-call-group-body {
display: block;
}
diff --git a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js
index 3eded8775..9e2aba79e 100644
--- a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js
+++ b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js
@@ -5,6 +5,7 @@
import {
announce,
navigateToDatum,
+ offer,
projectData,
watchData,
} from "/runtime/widget-api.js";
@@ -108,10 +109,16 @@ function updateDisclosureControl(owner) {
function buildToolbar(owner) {
const toolbar = make("div", "lf-call-tools");
+ // The counts this widget writes are an account of the tree, not words the page holds,
+ // so `data-lf-gen` takes them out of the version diff and makes each its own passage
+ // cell — the marker `lf-diff` puts on its own injected stat. It does not stop a drag
+ // quoting them: that is `.lf-ui`, which `lf-diff` adds beside it on its line numbers.
const summary = make("p", "lf-call-summary");
- const button = make("button", "lf-call-toggle");
- toolbar.dataset.lfUi = "";
- button.type = "button";
+ summary.dataset.lfGen = "1";
+ // `offer`, not a bare button: the disclosure control is chrome this widget injected
+ // and a handler is all it ever was, so the markers it writes are what tells the
+ // exported copy to take the press away rather than draw a hand over a dead one.
+ const button = offer("button", "lf-call-toggle");
button.addEventListener("click", () => {
const groups = [...owner.querySelectorAll(":scope > .lf-call-group")];
const open = groups.some((group) => !group.open);
@@ -119,8 +126,7 @@ function buildToolbar(owner) {
updateDisclosureControl(owner);
announce(`${open ? "Expanded" : "Collapsed"} all call-tree roots`);
});
- toolbar.append(summary);
- if (!owner.preparingExport) toolbar.append(button);
+ toolbar.append(summary, button);
return toolbar;
}
@@ -180,13 +186,22 @@ function renderLine(record, prior, owner) {
setText(body, record.body);
setText(location, record.location);
location.hidden = !record.location;
- if (owner.preparingExport) location.removeAttribute("href");
- else location.href = `#${owner.getAttribute("diff")}`;
- location.onclick = async (event) => {
- event.preventDefault();
- event.stopPropagation();
- await travelToLine(owner, record);
- };
+ // The header row names no location, so its anchor is hidden — and an `href` on a
+ // hidden anchor is a way in that leads nowhere. Worse, `reachScrollers` reads a
+ // candidate for a focusable descendant before granting the stop, and a hidden
+ // `a[href]` is one: the header's own words run off the side, and the live page
+ // answered "there is already a way in here" with a link nobody can reach.
+ if (record.location && !owner.preparingExport) {
+ location.href = `#${owner.getAttribute("diff")}`;
+ location.onclick = async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ await travelToLine(owner, record);
+ };
+ } else {
+ location.removeAttribute("href");
+ location.onclick = null;
+ }
return line;
}
@@ -220,10 +235,14 @@ customElements.define(
this.stopWatching = null;
}
+ // In a copy the anchor can only reach the patch, never the line it names — and on a
+ // group's root row, which is the disclosure's own ``, it is a focusable
+ // descendant of a disclosure as well. So the copy keeps each location as text.
+ // The toggle needs nothing here: `offer` marked it, and the bake takes a marked
+ // press away on its own. Nor do the counts, which stay, because an account of the
+ // tree is something a reader still wants on paper.
lfPrepareExport() {
this.preparingExport = true;
- this.querySelector(":scope > .lf-call-tools .lf-call-toggle")?.remove();
- for (const count of this.querySelectorAll(".lf-call-group-count")) count.remove();
for (const location of this.querySelectorAll(".lf-call-location"))
location.removeAttribute("href");
}
@@ -322,15 +341,13 @@ customElements.define(
const groupRecords = records.filter((record) => record.groupKey === key);
const root = groupRecords.find((record) => record.root);
const rootNode = nodesByKey.get(root.key);
- if (!this.preparingExport) {
- let count = rootNode.querySelector(".lf-call-group-count");
- if (!count) {
- count = make("span", "lf-call-group-count");
- count.dataset.lfUi = "";
- rootNode.append(count);
- }
- setText(count, groupLabel(groupRecords));
+ let count = rootNode.querySelector(".lf-call-group-count");
+ if (!count) {
+ count = make("span", "lf-call-group-count");
+ count.dataset.lfGen = "1";
+ rootNode.append(count);
}
+ setText(count, groupLabel(groupRecords));
reconcileChildren(
parts.body,
groupRecords
diff --git a/skills/leaf/scripts/leaf/render-checks/standalone.js b/skills/leaf/scripts/leaf/render-checks/standalone.js
index 7c0b2de04..5eda53388 100644
--- a/skills/leaf/scripts/leaf/render-checks/standalone.js
+++ b/skills/leaf/scripts/leaf/render-checks/standalone.js
@@ -26,6 +26,35 @@ export function coveredWords({
}
return false;
};
+ // What a run paints, which is not the whole of its rect. A box that clips its overflow
+ // shows only the part inside it, so a name ellipsised in a narrow column reads as
+ // covering whatever stands beside it while the reader sees the ellipsis and nothing
+ // else — the CallDiff root's own row, where the copy revealed a tab the live page keeps
+ // closed. Every clipping ancestor is intersected in, so the reading is the one the
+ // reader is given. The walk below stays in the light DOM, so the climb does too.
+ const painted = (el, drawn) => {
+ let box = drawn;
+ for (let ancestor = el; ancestor && box; ancestor = ancestor.parentElement) {
+ const style = getComputedStyle(ancestor);
+ if (style.overflowX !== "visible" || style.overflowY !== "visible") {
+ const bounds = ancestor.getBoundingClientRect();
+ const left = Math.max(box.left, bounds.left);
+ const right = Math.min(box.right, bounds.right);
+ const top = Math.max(box.top, bounds.top);
+ const bottom = Math.min(box.bottom, bounds.bottom);
+ box =
+ right > left && bottom > top
+ ? new DOMRect(left, top, right - left, bottom - top)
+ : null;
+ }
+ // An out-of-flow box is laid out against its containing block rather than against
+ // the ancestry, so a hidden overflow further out need not reach it at all. Stop
+ // climbing there and keep the rect whole: over-reporting a cover is this reading's
+ // safe direction, and missing one is the fault it was written for.
+ if (style.position === "absolute" || style.position === "fixed") break;
+ }
+ return box;
+ };
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
const el = node.parentElement;
@@ -41,9 +70,11 @@ export function coveredWords({
const range = document.createRange();
range.selectNodeContents(node);
const label = el.closest("text");
- for (const box of range.getClientRects())
- if (box.width > 1 && box.height > 1)
+ for (const drawn of range.getClientRects()) {
+ const box = painted(el, drawn);
+ if (box && box.width > 1 && box.height > 1)
runs.push({ el, label, box, text: node.data.trim().slice(0, 40) });
+ }
}
const found = [];
for (let i = 0; i < runs.length; i++)
diff --git a/tests/interact_support.py b/tests/interact_support.py
index 805fc174c..8587cb61c 100644
--- a/tests/interact_support.py
+++ b/tests/interact_support.py
@@ -24,6 +24,7 @@
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
+import anyio
import pytest
from click.testing import CliRunner
from conftest import LEAF_COMMAND
@@ -73,6 +74,20 @@
"""
+def run_async(entry):
+ """Run an async entry point on a thread of this test's own.
+
+ Playwright's sync API drives an asyncio loop and holds it running in the thread that
+ opened a browser for the whole life of `sync_playwright()`, and the `browser` fixture
+ is session-scoped per xdist worker. So `anyio.run` in a worker that has already run a
+ browser test raises "Already running asyncio in this thread", and the same call in a
+ worker that has not passes — leaving the scheduler to decide whether an MCP test can
+ start a loop at all. A thread with no loop on it answers for every schedule.
+ """
+ with ThreadPoolExecutor(max_workers=1) as loop_thread:
+ return loop_thread.submit(lambda: anyio.run(entry)).result()
+
+
def spawn_probe(spawn, page_dir, body, **environment):
"""Run a deterministic race seam in an isolated Leaf application process."""
env = {name: str(value) for name, value in environment.items()}
diff --git a/tests/render_cases_interaction.py b/tests/render_cases_interaction.py
index 64a2c1c7b..3682fc0cf 100644
--- a/tests/render_cases_interaction.py
+++ b/tests/render_cases_interaction.py
@@ -882,7 +882,7 @@ def backdate_note(page_dir, version, hours):
# log holding one event apiece leaves the whole of it standing at once. Selection and
# completion share one option-group unit but occupy distinct facets, so both stand;
# accept and reject share the settlement facet, so two suggestions let both competing
-# verbs stand. The floor below derives the list from the registry, so a twelfth widget's
+# verbs stand. The floor below derives the list from the registry, so a newly declared
# verb fails here rather than passing unexercised.
STANDING_PAGE = leaf_page(
"standing state",
@@ -917,6 +917,15 @@ def backdate_note(page_dir, version, hours):
Access logs roll off after 30 days.
Access logs are kept for 90 days.
+
+diff --git a/ab/bracket.py b/ab/bracket.py
+--- a/ab/bracket.py
++++ b/ab/bracket.py
+@@ -1,2 +1,2 @@
+ def bracket():
+- return "plastic"
++ return "steel"
+
""",
)
@@ -938,6 +947,7 @@ def backdate_note(page_dir, version, hours):
("ab-email", "edit", {"text": "The words as the reader rewrote them."}),
("ab-sug-410", "accept", {}),
("ab-sug-logs", "reject", {}),
+ ("ab-patch", "review", {"file": "ab/bracket.py", "reviewed": True}),
]
RELATIVE_WIDGET_PAGE = leaf_page(
"relative widget",
diff --git a/tests/test_interact_mcp.py b/tests/test_interact_mcp.py
index a6c190cbf..df034c254 100644
--- a/tests/test_interact_mcp.py
+++ b/tests/test_interact_mcp.py
@@ -1,6 +1,5 @@
"""The bundled MCP server exposes Leaf without becoming another state authority."""
-import asyncio
import json
import os
import shutil
@@ -8,6 +7,7 @@
from pathlib import Path
import pytest
+from interact_support import run_async
from leaf import event_log as events_model
from leaf.mcp_app import APP_MIME, SNAPSHOT_FORMAT, app_snapshot, apply_event
from leaf.mcp_page import PAGE_RESOURCE_URI, ProcessPageServer
@@ -151,7 +151,7 @@ async def exchange():
result = await session.call_tool("leaf_present", {"page": str(page_dir)})
return initialized, tools, resources, resource, result
- initialized, tools, resources, resource, result = asyncio.run(exchange())
+ initialized, tools, resources, resource, result = run_async(exchange)
by_name = {tool.name: tool for tool in tools.tools}
assert initialized.protocol_version == "2025-11-25"
@@ -230,7 +230,7 @@ async def exchange():
)
return rejected, accepted
- rejected, accepted = asyncio.run(exchange())
+ rejected, accepted = run_async(exchange)
assert all(result.is_error is True for result in rejected)
assert all(
@@ -265,7 +265,7 @@ async def exchange():
for name in ("leaf_present", "leaf_present_snapshot")
]
- results = asyncio.run(exchange())
+ results = run_async(exchange)
for result in results:
assert result.is_error is True
@@ -330,7 +330,7 @@ async def exchange():
for name, (path, _) in cases.items()
}
- results = asyncio.run(exchange())
+ results = run_async(exchange)
for name, (path, expected) in cases.items():
details = []
diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py
index befa9c4bd..61a3f1004 100644
--- a/tests/test_mcp_app.py
+++ b/tests/test_mcp_app.py
@@ -6,8 +6,7 @@
import urllib.request
from urllib.parse import urlsplit
-import anyio
-from interact_support import PAGE
+from interact_support import PAGE, run_async
from leaf.event_log import append_event, read_events
from leaf.mcp_page import (
PAGE_APP_RESOURCE,
@@ -31,7 +30,7 @@ def call(server, name, arguments):
async def invoke():
return await server.call_tool(name, arguments)
- return anyio.run(invoke)
+ return run_async(invoke)
def test_process_server_multiplexes_pages_on_one_exact_origin(page_dir, tmp_path):
@@ -151,7 +150,7 @@ def test_registered_server_uses_one_adaptive_resource_for_every_presentation():
async def inspect():
return await server.list_tools(), await server.list_resources()
- tools, resources = anyio.run(inspect)
+ tools, resources = run_async(inspect)
finally:
pages.close()
diff --git a/tests/test_render_commands.py b/tests/test_render_commands.py
index 0b09fb380..6e94614e9 100644
--- a/tests/test_render_commands.py
+++ b/tests/test_render_commands.py
@@ -58,10 +58,11 @@ def test_the_gate_passes_a_page_that_carries_a_comment(browser, serve):
and every page the sweep above renders is a page with no comments on it.
The pass hunting words drawn on other words has to know the same difference, and
- knows it as a float the runtime hangs over the page. The resting control is transparent,
- so the browser correctly omits it from a paint check. This test makes that control
- visible to plant the fault it is about: its characters then fall down the document
- through the paragraphs under the passage. Holding the runtime float out is the only
+ knows it as a float the runtime hangs over the page. The resting control is drawn
+ nowhere twice over — transparent, and clipped to the pixel it is parked on — and the
+ paint check correctly omits it for either reason. This test takes both away to plant
+ the fault it is about: its characters then fall down the document through the
+ paragraphs under the passage, painted. Holding the runtime float out is the only
thing keeping the reading clean, so it is taken twice: once as the gate runs it, and
once with the hold defeated, where it has to report.
@@ -82,8 +83,14 @@ def test_the_gate_passes_a_page_that_carries_a_comment(browser, serve):
"() => document.querySelectorAll('.lf-mark-note').length === 1"
)
# Give the real runtime control paint so this tests the floating exemption rather
- # than passing because the ordinary resting state is not drawn.
- page.locator(".lf-mark-note").evaluate("note => note.style.opacity = '1'")
+ # than passing because the ordinary resting state is not drawn. Both halves of "not
+ # drawn": the transparency, and the one-pixel box whose hidden overflow keeps the
+ # characters off the screen however opaque they are.
+ # The one-pixel box stays: it is what turns the label into a column of characters
+ # falling through the paragraphs, which is the shape of the fault.
+ page.locator(".lf-mark-note").evaluate(
+ "note => Object.assign(note.style, {opacity: '1', overflow: 'visible'})"
+ )
held = render_checks_model.evaluate_probe(page, "coveredWords")
reported = render_checks_model.evaluate_probe(
page, "coveredWords", {"holdFloating": False}
diff --git a/tests/test_render_gate.py b/tests/test_render_gate.py
index 67e13b626..52cd16d3c 100644
--- a/tests/test_render_gate.py
+++ b/tests/test_render_gate.py
@@ -1198,15 +1198,19 @@ def test_the_runtime_holds_a_scroller_the_page_wrote(browser, serve):
example = next(e for e in EXAMPLES if e.stem == "pr-walkthrough")
page, errors = open_page(browser, serve(example))
+ # The lines a diff has drawn, which is not every diff on the page: the shipped patch
+ # stands collapsed and fetches one file when it is asked for, so its shadow tree
+ # holds nothing to mark until a reader opens one. The floor below is what keeps that
+ # from reading as a clean sweep of nothing.
diffed = page.evaluate(
- """() => [...document.querySelectorAll('lf-diff')].map((d) => {
- const pre = d.shadowRoot.querySelector('pre');
- return { scrolls: getComputedStyle(pre).overflowX,
- marked: pre.hasAttribute('data-lf-holds'),
- position: getComputedStyle(pre).position };
- })"""
- )
- assert diffed, "no diff on the page, so nothing here stands in a shadow tree"
+ """() => [...document.querySelectorAll('lf-diff')]
+ .map((d) => d.shadowRoot.querySelector('pre'))
+ .filter(Boolean)
+ .map((pre) => ({ scrolls: getComputedStyle(pre).overflowX,
+ marked: pre.hasAttribute('data-lf-holds'),
+ position: getComputedStyle(pre).position }))"""
+ )
+ assert diffed, "no diff drew its lines here, so nothing stands in a shadow tree"
assert all(
d == {"scrolls": "auto", "marked": True, "position": "relative"} for d in diffed
), f"the mark did not reach the diff's lines: {diffed}"
diff --git a/tests/test_render_pages.py b/tests/test_render_pages.py
index 4adefc6c5..8474351da 100644
--- a/tests/test_render_pages.py
+++ b/tests/test_render_pages.py
@@ -2156,10 +2156,38 @@ def test_a_left_sidebar_uses_the_margin_until_the_page_needs_it_back(browser, se
resized(page, 1400, 900)
- page.evaluate(
- "document.scrollingElement.style.scrollBehavior = 'auto'; document.scrollingElement.scrollTo(0, 900)"
+ # Halfway through the stretch where the box stands on its own offset: past the
+ # scroll that lifts it off where it was authored, and short of the one where main's
+ # own end starts carrying it back up. Both edges are the page's, so they are read
+ # off it — a named scroll fell past the far one when the release page grew shorter.
+ parked = page.evaluate(
+ """() => {
+ const scroller = document.scrollingElement;
+ scroller.style.scrollBehavior = 'auto';
+ const sidebar = document.querySelector('aside.sidebar');
+ const main = sidebar.parentElement;
+ const box = sidebar.getBoundingClientRect();
+ const style = getComputedStyle(sidebar);
+ const offset = parseFloat(style.top);
+ const stands = scroller.scrollTop + box.top - offset;
+ const carried = scroller.scrollTop + main.getBoundingClientRect().bottom
+ - parseFloat(getComputedStyle(main).paddingBottom)
+ - parseFloat(style.marginBottom) - box.height - offset;
+ const at = Math.round((stands + carried) / 2);
+ scroller.scrollTo(0, at);
+ return { at, stands, carried };
+ }"""
+ )
+ # The stretch has to exist before a point halfway along it says anything. A page
+ # too short to lift the box off where it was authored parks the scroll behind
+ # `stands`, and the ring assertion below would then report a position rather than
+ # the page that made it meaningless.
+ assert parked["carried"] > parked["stands"], (
+ f"the page is too short for the sidebar to stand on its own offset: {parked}"
+ )
+ page.wait_for_function(
+ "at => document.scrollingElement.scrollTop >= at - 1", arg=parked["at"]
)
- page.wait_for_function("() => document.scrollingElement.scrollTop > 800")
stuck = sidebar.evaluate("node => node.getBoundingClientRect().top")
assert 64 <= stuck <= 68, (
f"the sidebar stuck at {stuck:.0f}px, not below the banner"
diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py
index 0c957238f..c349eb78c 100644
--- a/tests/test_render_projection.py
+++ b/tests/test_render_projection.py
@@ -282,6 +282,10 @@ class Limiter:
expect(widget.locator(".lf-call-summary")).to_have_text(
"1 changed root · 1 added · 0 removed · 2 items"
)
+ # The counts are this widget's account of the tree, not words the page holds:
+ # data-lf-gen is what keeps them out of the version diff, which parses the base.
+ expect(widget.locator(".lf-call-summary")).to_have_attribute("data-lf-gen", "1")
+ expect(widget.locator(".lf-call-group-count")).to_have_attribute("data-lf-gen", "1")
group = widget.locator(":scope > .lf-call-group")
expect(group).to_have_count(1)
assert group.evaluate("el => getComputedStyle(el).backgroundColor") == page.locator(
diff --git a/tests/test_site.py b/tests/test_site.py
index 7935e304b..392e0f3aa 100644
--- a/tests/test_site.py
+++ b/tests/test_site.py
@@ -426,7 +426,7 @@ def test_every_example_says_what_it_is_and_links_back(site, hosted, browser):
const field = document.querySelector('.lf-fab-input');
return {{ text: getSelection().toString(),
quote: document.getElementById('lf-composer-quote')?.textContent ?? '',
- fieldFocused: document.activeElement === field,
+ fieldOffered: Boolean(field?.checkVisibility()),
says: document.querySelector('.lf-keyline').textContent }};
}}"""
@@ -457,12 +457,12 @@ def test_the_label_is_chrome_rather_than_words_to_quote(site, hosted, browser):
page, errors = open_page(browser, example_url(hosted, "design-decision"))
try:
control = drag_across(page, "#decision-lede")
- assert control["fieldFocused"], "the page's own words raised no comment field"
+ assert control["fieldOffered"], "the page's own words raised no comment field"
assert "monolith split" in control["quote"]
label = drag_across(page, "main > .sitenote p")
assert "example of a leaf page" in label["text"]
- assert not label["fieldFocused"]
+ assert not label["fieldOffered"]
# The word `c` carries with nothing in hand — it goes to the threads rather
# than opening a box on anything, and "comment on the selection" does not
# contain it, so the two readings still tell each other apart.
@@ -564,8 +564,9 @@ def test_a_comment_lands_in_the_thread_with_its_quote(site, hosted, browser):
(box["x"] + 4, box["y"] + 8),
(box["x"] + box["width"] - 40, box["y"] + box["height"] - 8),
)
- # Selection enters the compact field immediately.
- expect(page.locator(".lf-fab-input")).to_be_focused()
+ # Selection offers the compact field without entering it, so the browser's
+ # own selection is still there for a native copy.
+ expect(page.locator(".lf-fab-input")).to_be_visible()
page.locator(".lf-composer textarea").fill("Does this cover key rotation?")
page.keyboard.press("Enter")