Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
33 changes: 29 additions & 4 deletions skills/leaf/packages/command-hub/widgets/lf-worktree.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
* rendering; the page binds that input to a source, while Leaf delivers its validated
* snapshot and keeps datum comments attached across replacement. */
import {
PRESS,
DISCLOSE,
ago,
keys,
paintKeys,
projectData,
relabel,
selectableOffer,
Expand Down Expand Up @@ -52,12 +53,28 @@ function renderDatum(tree, record, prior) {
tree.toggleAttribute("data-lf-open");
tree.show(tree.snapshot);
});
// Which way it stands, before the scope below reads it: a button wearing
// aria-expanded is ARIA's disclosure pattern, and that pair is what `DISCLOSE`
// answers from. The line below sets the live value on every render; this is the one
// at birth, so the head is never briefly a control the runtime cannot place.
head.setAttribute("aria-expanded", String(tree.hasAttribute("data-lf-open")));
// The same press the runtime's disclosure scope owns, re-worded in this widget's
// terms. Its keys come from `DISCLOSE` rather than from `PRESS`, which is what that
// primitive is for: a nearer scope keeps only the keys it names, so the pair alone
// took the arrow off the line while it went on opening the tree.
//
// And it keeps its own `run`, because this head is a span. `DISCLOSE` hands over
// only the arrow that changes the state, so a press here is a direction and never a
// second toggle; what it also answers for is where the head stands. In thread
// markup the disclosure scope refuses to reach — its `at` asks `!inChrome` — and a
// span has no platform half to fall back on the way `details > summary` does, so
// without this the frozen head names ⏎ / space and nothing runs them.
keys(head, "On worktree evidence", [
{
id: "worktree.toggle",
keys: PRESS,
keys: () => DISCLOSE(head),
does: "Open or close the worktree evidence",
line: "open or close",
line: () => (tree.hasAttribute("data-lf-open") ? "close" : "open"),
Comment thread
leaf-agent marked this conversation as resolved.
run: () => head.click(),
},
]);
Expand All @@ -70,7 +87,15 @@ function renderDatum(tree, record, prior) {
: `${tree.hasAttribute("data-lf-open") ? "▾" : "▸"} ${summary(record)}`,
{ says: true },
);
head.setAttribute("aria-expanded", String(tree.hasAttribute("data-lf-open")));
// The row's bindings answer from this attribute, and `aria-keyshortcuts` is written
// once when the scope is declared: without a repaint it keeps whichever way the tree
// was standing then, naming the arrow that no longer moves it and withholding the one
// that does. Only on a change, since every data update renders this head again.
const standing = String(tree.hasAttribute("data-lf-open"));
if (head.getAttribute("aria-expanded") !== standing) {
head.setAttribute("aria-expanded", standing);
paintKeys();
Comment thread
leaf-agent marked this conversation as resolved.
Outdated
}

let source = datum.querySelector(":scope > .lf-worktree-source");
if (!source) {
Expand Down
27 changes: 13 additions & 14 deletions tests/test_render_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1906,19 +1906,18 @@ def test_the_reference_reads_the_same_way_twice(browser, serve):


def test_a_widget_that_renames_its_role_keeps_the_press_offer_gave_it(browser, serve):
"""What makes a press is `offer`'s own answer, and nothing that can be overwritten on
the way past. A tab is built by `offer("button", …)` and then wears `role="tab"`,
because that is what its strip is; its own scope declares the arrows and Home/End and
says nothing about Enter or Space, so the control scope is the only thing that gives a
tab a press at all — and the only thing that consumes Space, which is the page's
scroll.

Read off the role, that scope stopped seeing tabs: Enter did nothing and Space threw
the reader down the page from a control that looked like it had answered. Read off the
tabindex, which is the reading before it, the same scope claimed every focus target
`offer` builds, and led with "press it" over a conversation thread that answers
nothing. So the marker is the one `offer` writes for this and a widget has no reason
to touch.
"""A tab is built by `offer("button", …)` and then wears `role="tab"`, because that is
what its strip is. The press it keeps is the strip's own row, declared beside the
arrows and Home/End that walk it, and it is the only thing that consumes Space —
which is otherwise the page's scroll.

That row is what a page-wide control scope used to supply, and the scope was the wrong
place for it: read off the role it stopped seeing tabs, so Enter did nothing and Space
threw the reader down the page from a control that looked like it had answered; read
off the tabindex it claimed every focus target `offer` builds and led with a press over
a conversation thread that answers nothing. Declared where the strip is, the press
cannot be lost to a rename or promised where nothing runs it, and the word on the line
is the strip's own — it names the tab rather than the control.

Asserted from the state where the press is the only way back: the tab strip is walked
with arrows, so a focused tab is usually the selected one. Revealing the *other* panel
Expand All @@ -1940,7 +1939,7 @@ def test_a_widget_that_renames_its_role_keeps_the_press_offer_gave_it(browser, s
expect(tabs.nth(1)).to_have_attribute("aria-selected", "true")

# The line names the press, and the press re-selects the tab the reader is standing on.
expect(page.locator(".lf-keyline")).to_contain_text("press it")
expect(page.locator(".lf-keyline")).to_contain_text("open the tab")
page.keyboard.press("Enter")
expect(tabs.first).to_have_attribute("aria-selected", "true")

Expand Down
94 changes: 94 additions & 0 deletions tests/test_render_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
compare_with,
composer_quote,
drifting_widget,
key_line,
leaf_page,
live_url,
open_page,
Expand Down Expand Up @@ -3210,6 +3211,99 @@ def test_agent_places_its_live_line_before_command_evidence(browser, serve):
page.close()


def test_worktree_evidence_names_the_arrow_that_stands_on_it(browser, serve):
"""The head is a disclosure, so the keys that work it are `DISCLOSE`'s answer and not
a pair the widget picks. A widget row is nearer than the runtime's disclosure scope
and `lineRows` keeps only the keys the nearer row names, so a head binding Enter and
Space alone took the arrow off both surfaces while the arrow went on opening the
tree — the shape `skills/leaf/CLAUDE.md` names as one promise rather than two.

Both surfaces, and both readings of each, because they fail apart: the line has a
Comment thread
leaf-agent marked this conversation as resolved.
Outdated
watch on the attribute and `aria-keyshortcuts` is written once when the scope is
declared, so a head that repaints the line still tells a screen reader about the
arrow that no longer moves anything. Read once and never retried, for the reason
`key_line` is: the heartbeat repaints scopes too, and an assertion that retries goes
green on whichever tick lands inside its budget.

And both places the head stands, because the row's `run` is what carries it from one
to the other. The runtime's disclosure scope stops at the chrome, and this head is a
span, so a message's frozen copy has no platform pair underneath it the way a
`details > summary` does: the row's own press is the only thing there. Reading
`DISCLOSE` for the keys and leaving the press to that scope named ⏎ / space in the
panel over a head that answered neither."""
command = leaf_page(
"worker evidence",
"""
<lf-roster id="team">
<lf-agent id="worker" state="working"><strong>worker</strong> Owns the remit.
<lf-worktree id="proof" source="atlas-worktrees"></lf-worktree>
</lf-agent>
</lf-roster>
""",
)
page, errors = open_page(browser, serve(command))
head = page.locator("#proof > .lf-worktree-snapshot > .lf-worktree-head")
head.focus()

expect(head).to_have_attribute("aria-expanded", "false")
assert head.get_attribute("aria-keyshortcuts") == "Enter Space ArrowRight"
said = key_line(page)
assert re.search(r"⏎ / space / →\s*open", said), said

page.keyboard.press("ArrowRight")
expect(head).to_have_attribute("aria-expanded", "true")
said = key_line(page)
assert re.search(r"⏎ / space / ←\s*close", said), said
assert head.get_attribute("aria-keyshortcuts") == "Enter Space ArrowLeft"

# A direction and not a toggle: the press that must change nothing follows one that
# changed something, so a scope answering nothing at all could not pass this.
page.keyboard.press("ArrowRight")
expect(head).to_have_attribute("aria-expanded", "true")
page.keyboard.press("ArrowLeft")
expect(head).to_have_attribute("aria-expanded", "false")

# The same head frozen into thread markup, where the runtime's disclosure scope does
# not reach: its `at` refuses anything in the chrome, so whatever the widget's row
# does not run there, nothing runs. `DISCLOSE` answers for that too and drops the
# arrow, leaving the pair — and the pair is the half a `details > summary` gets from
# the platform and a span gets from nowhere. So the row keeps its own `run`, and this
# is the surface that says whether it does.
events_model.append_event(
serve.page_dir,
{
"kind": "comment",
"id": "c-tree",
"author": "claude",
"revision": 1,
"text": "The worker's evidence, for the record.",
"markup": '<lf-roster id="msg-team"><lf-agent id="msg-worker" '
'state="working"><strong>worker</strong> Owned the remit.'
'<lf-worktree id="msg-proof" source="atlas-worktrees"></lf-worktree>'
"</lf-agent></lf-roster>",
},
)
told(page)
page.locator(".lf-threads-toggle").click()
panel_settled(page)
frozen = page.locator("#msg-proof > .lf-worktree-snapshot > .lf-worktree-head")
frozen.focus()
expect(frozen).to_be_focused()
expect(frozen).to_have_attribute("aria-expanded", "false")
assert frozen.get_attribute("aria-keyshortcuts") == "Enter Space"

# The arrow the page has and the panel does not, first: it moves nothing here, so the
# press that follows cannot be read as the arrow arriving late.
page.keyboard.press("ArrowRight")
expect(frozen).to_have_attribute("aria-expanded", "false")
page.keyboard.press("Enter")
expect(frozen).to_have_attribute("aria-expanded", "true")
page.keyboard.press(" ")
expect(frozen).to_have_attribute("aria-expanded", "false")
assert errors == []
page.close()


def test_command_goal_can_pause_after_an_ordinary_conversation_started(browser, serve):
"""A normal note does not consume the goal's stronger pause door. The reader
can start a later held thread, whose root remains the one atomic hold fact."""
Expand Down
Loading