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")