Skip to content

Fix margin rail, float placement, thread marks, and preview tab order after the chrome change - #138

Merged
max-sixty merged 2 commits into
mainfrom
fix/ci-33327310444
Aug 30, 2026
Merged

Fix margin rail, float placement, thread marks, and preview tab order after the chrome change#138
max-sixty merged 2 commits into
mainfrom
fix/ci-33327310444

Conversation

@leaf-agent

Copy link
Copy Markdown
Collaborator

Problem

Run 33327310444 on d2dd825 failed with 34 tests red. Most of them are owned: #121 has the scrollbar-gutter set, #132 the generic CONTROL contract, #134 the catalog stills, #136 the sidebar width, #137 the versions menu, and #133 tracks the digest. What was left unowned is five tests that all fail on one surface — what the panel's opening does to the page around it — and each is a reading 335bf9a "Delegate page chrome to browser standards" moved without a second reading moving with it. All five pass at 335bf9a's parent; each was reproduced at -n0 before it was touched.

Solution

Four readings, each corrected where it is taken. No assertion is loosened and no test is changed.

The margin's 900px floor asks the window again. That number is half of a pair: @container lf-shell (min-width: 900px) grants the rail and the marker's strip, and @media screen and (max-width: 899px) stops drawing the living margin at all. 335bf9a converted the grant to a container query and left the hide a media query, so the two came apart the moment the panel narrowed body: at 1200px with the panel open the shell is 765px, the grant lapsed, and the markers stayed on screen with no strip reserved for them. Measured on SUGGESTION_PAGE, --strip-r resolved to max(0px, 0px, 0px, 0px) and every suggestion's control row docked beside an empty rail. The floors below it stay container queries: 1152 and 1416 ask how much room is left beside the panel, which is the question the runtime's old data-lf-cramped veto asked, and their answer is supposed to move with the panel.

Body's inline size is back under the layout observer. 335bf9a dropped layoutSizes.observe(document.body), and with it the only thing that re-ran syncLayout while the panel's eased margin was still narrowing the page. setPanel runs one synchronous pass at the press, reads the wide box, and returns; nothing reads the narrow one. So a composer placed in a wide window's margin kept a place the narrowed page no longer had — an absolute child past body's client box, which is sideways-scrollable overflow, with the box standing on the panel that displaced it. Restored with the content-box width filter the removed code carried, so ordinary document-height growth still does not feed back into a writer that reserves flow content.

The panel layer is shown before anything is rendered into it. The panel is a <dialog> now, and a dialog nobody has shown is display: none — but setPanel called renderPanel() first and syncPanelLayer() (which calls show()) second. renderPanel is where the anchor pass runs for the threads it draws, and a mark hangs on the boxes its element shows through, so a widget an agent sent in a reply resolved to an element measuring 0×0, took no mark, and never got one afterwards: the thread stayed open in the panel pointing at nothing on either side. Swapping the two lines is the whole fix; the focus target is threadsBox, which the render fills rather than replaces.

The margin preview shows without naming the marker as its source. showPopover({source}) puts the card in the sequential focus order directly after the control that invoked it — but this card is not invoked. It opens on the marker merely taking focus, so a reader walking the margin was made to Tab through a close button and two preview actions they had not asked for, three stops between one suggestion's controls and the next, with the card's contents changing under them as they went. Its position comes from the anchor-name the code already writes on the button, so the placement asks nothing of source.

Testing

  • Everyday suite green on this branch: uv run pytest tests — 723 passed, 6 skipped.
  • The five: test_suggestion_controls_stay_out_of_the_column, test_suggestions_sharing_a_block_keep_source_and_keyboard_order, test_a_settlement_in_a_reply_leaves_its_own_anchor_on_the_page, test_a_mark_in_the_layer_promises_no_press_the_layer_will_not_take, test_a_float_the_panel_displaces_hands_the_page_no_sideways_scroll — all pass with --run-nightly.
  • Whole files: test_render_margin.py, test_render_conversations.py, test_render_widgets.py, test_render_outbox.py, test_render_navigation.py — the only failures left in them are ones that also fail on origin/main unchanged, at identical values.
  • prettier --check and node --check pass on both JavaScript files; CSS is prettier-excluded here by .pre-commit-config.yaml.
How each was measured

The margin floor, on SUGGESTION_PAGE at 1200×900, before and after pressing Threads:

BEFORE  bodyRight 1185  main 768  --strip-r "max(0px, 250px, 0px, 0px)"  --claim-rail 250px
AFTER   bodyRight  765  main 765  --strip-r "max(0px, 0px, 0px, 0px)"    --claim-rail ""

The same probe at 335bf9a's parent reports --claim-rail: 250px in both postures, with data-lf-cramped standing — d226ee2 had already taken that veto off the rail claim, so the 900 floor was the window's question and nothing else.

The float, on LONG_PAGE at 1600×900 with the composer in the margin and the panel opened:

body.scrollWidth 1487   body.clientWidth 1165   composer.left 1166.9   composer.right 1486.9

The mark, on REPLY_TRAVEL_PAGE with an lf-options frozen into a reply: #tv-decision is present and checkVisibility() is true once the panel is open, its box 355×32, and .lf-mark-el is on nothing — at +6s as much as at +0. The same probe at 335bf9a's parent has the mark the moment the panel opens.

The tab walk from a suggestion's Accept, before: accept → reject → marker → preview close → preview action → preview action → next row's accept.

What this does not fix

Two of the four groups #127 recorded are still open, and both are a product decision rather than a stale reading.

  • A covering panel is a modal dialog now, so the document behind it is inert: test_selection_hints_do_not_name_page_content_behind_a_covering_panel presses s at 700×900 and gets no hints because the page cannot hear the key. Whether a covering panel should take the page out of reach entirely, or cover it and leave it working, is the call — test_coarse_pointer_chrome_gives_its_compact_controls_humane_aims, test_workspaces_replace_each_other_instead_of_stacking[500] and test_coarse_pointer_resize_reach_stays_reachable_without_trapping_scroll fail as <dialog … class="lf-ui lf-panel open"> intercepts pointer events on the same fact.
  • Escape belongs to whichever layer the browser is holding — the four test_render_anchors.py failures and test_esc_hands_the_page_back_after_it_has_closed_the_last_panel. Give the versions menu back its way in, its way out, and the reader's place #137 is open on this.

Separately, 0d7e0d0 "Open threads beside their source" landed while this was being verified and brought two of its own new tests red on maintest_the_shipped_long_thread_opens_beside_its_source_in_the_right_margin and test_the_full_thread_posture_follows_the_page_container_and_left_claims. They fail identically with and without this branch's changes; noted here so the next run does not re-derive them.


Automated fix for failed run

Four readings the chrome change (335bf9a) left stale, each corrected where
it is taken:

- the margin's 900px floor asks the window again, so the grant and the
  rule that hides the living margin cannot come apart when the panel
  narrows body;
- body's inline size is back under the layout observer, so floats are
  placed again as the panel's eased margin narrows the page;
- the panel layer is shown before anything renders into it, so the anchor
  pass measures boxes rather than a display:none dialog;
- the margin preview shows without naming the marker as its source, so it
  does not join the focus order between one suggestion's controls and the
  next.

@leaf-agent leaf-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the claimed path on the merge ref: the five named tests pass with --run-nightly, and the five failures left in test_render_margin.py, test_render_widgets.py and test_render_navigation.py reproduce at 0d7e0d0 unchanged with the same assertions, so none of them is this branch's.

One finding, inline: skills/leaf/CLAUDE.md still carries the ownership sentence 335bf9a wrote alongside the code this PR restores, so the contract file and the runtime now disagree about who measures the shell.

Comment thread skills/leaf/assets/runtime/chrome-layout.js
335bf9a wrote "No JavaScript measures that shell or mirrors a cramped state"
in the same commit that dropped layoutSizes.observe(document.body) and moved
the living margin's 900 floor to @container lf-shell. This branch restores
both halves, so the contract file and the runtime disagreed about who
measures the shell — and the sentence, left standing, is the instruction that
removes the fix a second time.

State the distinction the restored code actually keeps: JavaScript may hear
the shell's inline size as a signal to re-run syncLayout, and still derives
no posture from it. Record which floors ask the window (the 900/899 pair the
living margin's presence hangs on) and which ask the container (1152, 1416,
1208, 1472), since that split is now the rule rather than an exception.
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

The gate on addfc26 went red on test_the_stop_hook_records_the_ending_of_the_turn_behind_a_claim — not this branch's, and not a browser test. It is a second-boundary race written into the test itself, so I reran the job (green on attempt 2) and am recording the mechanism here rather than opening an eleventh bot PR against a queue this deep.

The test's second Stop for s1 is allowed to move the stamp — >= closed — because close_turn writes now_iso() on every accepted call. The assertion after the s2 call then compares against closed, the value from before that permitted move. Cross a one-second boundary between the two s1 calls and the third assertion fails on a stamp s2 never touched, which is exactly what attempt 1 reported: assert '2026-08-30T20:36:19+00:00' == '2026-08-30T20:36:18+00:00'. The behaviour under test — another session's ending saying nothing about this claim — held; the reading of it did not. The fix is to re-read the stamp after the second s1 call and compare s2's result against that, so the assertion names the fact the comment claims.

Evidence

Attempt 1 of run 33333878596, job test, 1 failed, 722 passed, 6 skipped:

>       assert service_model.page_claim(claimed)["turn_closed"] == closed
E       AssertionError: assert '2026-08-30T20:36:19+00:00' == '2026-08-30T20:36:18+00:00'
tests/test_interact_session.py:2906: AssertionError

The branch does not touch tests/test_interact_session.py or anything under skills/leaf/scripts/, and test was green on the prior head 741fe2a (check run 99313101250). addfc26 changes skills/leaf/CLAUDE.md alone. Attempt 2 of the same run passed, and every gating check on addfc26 is green.

@max-sixty
max-sixty merged commit 25b8d27 into main Aug 30, 2026
7 of 8 checks passed
@max-sixty
max-sixty deleted the fix/ci-33327310444 branch August 30, 2026 20:56
max-sixty pushed a commit that referenced this pull request Aug 30, 2026
#141)

## Problem

[`ci` run
33327519405](https://github.com/max-sixty/leaf/actions/runs/33327519405)
on e7299eb failed with 33 tests red. One of them is
`test_render_controls.py::test_esc_hands_the_page_back_after_it_has_closed_the_last_panel`,
which #133 lists among the unowned remainder ("`esc` handback"). It
still reproduces deterministically on `main` at 1ca4f40, in about 7
seconds.

The test's first assertion after a pointer open is the one that fails:
the panel is visible and the toggle is no longer focused.

```
FAILED tests/test_render_controls.py::test_esc_hands_the_page_back_after_it_has_closed_the_last_panel
E       AssertionError: Locator expected to be focused
E       Actual value: inactive
E         - waiting for locator(".lf-threads-toggle")
E           14 × locator resolved to <button aria-expanded="true" class="lf-btn lf-threads-toggle" ...>
tests/test_render_controls.py:2089
```

`syncPanelLayer` raises the panel with `panel.show()` beside the page
and `panel.showModal()` over it. Both run the dialog focusing steps, so
the browser moves focus into the dialog whichever one is called — the
panel takes the focus off whatever raised it. The function already knew
to put focus back, but only for a focus it found *inside* the panel:
`active` is `null` whenever the reader was standing outside it, and the
restore arm reads `active`. So the pointer route lost the toggle, and
what the test's docstring bills for lands: the button holds
`aria-expanded` with no ring on it, and the reader's next Space is that
button rather than the page's scroll — the panel they dismissed comes
back and nothing says why.

## Solution

Carry the element that was standing outside the layer alongside the one
found inside it, and restore that too when nothing else claims the
focus. Beside the page a panel is a column that arrived, so the reader
stays where they were; a covering panel is a mode and still takes them
in, which is the existing `focusEnteredLayer` arm and is unchanged. Body
is not somewhere to put anyone back, so it is not carried.

This is `syncPanelLayer`'s own restore, extended to the case it did not
name — not a second focus policy beside it, and not a call at the press
site, which would have to be repeated at every door the panel has.

## Scope

Not the other 32. Measured against the current tip, they are owned or
out of reach here:

| | |
| --- | --- |
| #121 | 8 of them (`scrollbar-gutter: stable` on the root scroller) |
| #132 | `test_example_renders[command-hub]`,
`test_example_renders[corpus]` |
| #137 | the four version-menu cases in `test_render_anchors.py` |
| #134 / #133 | the catalog preview digest |
| this PR | `esc` handback |

I reached the version-menu focus break independently and had the same
one-line fix (`versionBtn.focus()` before `showPopover`) before #137
appeared; #137 covers it and the two contracts around it, so nothing of
it is here.

<details><summary>How the split was measured</summary>

Each row is a local `-n0` or `-n3` run on the named tree, not an
inference from the failure text.

- The 33 node ids from the run were re-run on `main` (26dbf42): 31 still
failed. Applying #121's `chrome-style.js` hunk alone took that to 23;
adding #132's branch took it to 20.
- #138 also edits `chrome-layout.js`, so its hunk was applied to `main`
on its own and the `esc` test re-run against it — still red. The two
changes are in different functions (`setPanel` and the `ResizeObserver`
there, `syncPanelLayer` here) and do not overlap.
- Four failures that appeared only under `-n3` —
`test_render_navigation.py::test_a_label_press_keeps_the_controls_keyboard_standing`,
`test_render_outbox.py::test_a_withdrawal_restores_what_still_stands_not_what_stood_then`,
`test_render_startup.py::test_a_fast_first_replay_does_not_flash_the_waiting_surface`
and
`test_render_controls.py::test_covering_panel_takes_the_page_scroll_with_it`
— each pass at `-n0` on both trees, or fail on both. None of them is
this change.

</details>

## Testing

`test_esc_hands_the_page_back_after_it_has_closed_the_last_panel` is the
regression: it asserts both halves the docstring names — the ring the
pointer reader never asked for, and the Space that reopens what they
just dismissed — and it goes from red to green here. No new test,
because that one already owns the behaviour.

<details><summary>Runs on this branch, against the same files on clean
1ca4f40</summary>

```
uv run pytest tests -q -n3                      723 passed, 6 skipped
uv run pytest tests/test_render_controls.py \
             tests/test_render_conversations.py 10 failed, 101 passed
uv run pytest tests/test_render_outbox.py \
             tests/test_render_margin.py \
             tests/test_render_drafts.py         3 failed, 108 passed
```

Every remaining failure above reproduces on clean `main`;
`test_esc_hands_the_page_back_after_it_has_closed_the_last_panel` is the
only test whose result this change moves. Earlier on 26dbf42 the wider
render set (`anchors`, `gate`, `navigation`, `widgets`, `projection`,
`startup`, `drafts`, `outbox`, `options`, `pages`, `aim`, `reactions`,
`margin`, `semantic_selection`, `commands`, `export`, `site`) ran the
same way with the same result.

</details>

---

Automated fix for [failed
run](https://github.com/max-sixty/leaf/actions/runs/33327519405)

Co-authored-by: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
max-sixty pushed a commit that referenced this pull request Aug 30, 2026
)

## Problem

`test_render_gate.py::test_example_renders[corpus]` has been red on
every `ci` run since
[26dbf42](26dbf42)
"Add external link treatment", in both colour schemes, and it still
reproduces deterministically on `1ca4f40` in 11 seconds. #132 diagnosed
it as "a different finding, from 26dbf42's external-link treatment
rather than from 335bf9a" and left it; no PR or issue owns it.

```
assert ['[light] <lf-tab id=corpus-pr-walkthrough> puts "opens in a new tab" under .lf-ui, where no comment can reach it',
        '[dark] ...same...'] == []
```

The subject is not the example. `render_version` is the pass `version
check --render` runs on an agent-authored page, so any page with a
cross-origin link inside a widget now fails handover on a word the
reader is never shown.


[`unreachableWords`](https://github.com/max-sixty/leaf/blob/03b4a0b2776102041a2d48580046fbf03b5e3446/skills/leaf/scripts/leaf/render-checks/reachability.js#L208)
walks every text node under a widget's `.lf-ui` and reports the ones no
declaration claims. Its excuse list held `.lf-quiet` and nothing else.
26dbf42's `renderExternalLinks` writes a note beside each external link
— `class="lf-ui lf-external-note"`, `hidden`, textContent "opens in a
new tab" — as the `aria-describedby` target for the link, and the walk
found it: not `data-lf-said`, not inside the link's own `CONTROL` box (a
describedby target is referenced by id, so it sits beside the link
rather than in it), not `.lf-quiet`.

## Solution

Excuse `[hidden]` beside `.lf-quiet`, one line and its comment. The
check's own opening sentence is "Words the page shows that no user can
select", and a `[hidden]` subtree shows none.

The note cannot simply become a `.lf-quiet` word instead — which would
have been the tidier answer, since `quietWord` is the repo's one idiom
for "a word for a reader listening, silent on screen".
`renderExternalLinks` is enrolled on declared shadow roots as well as
the document, and the note is placed inside whatever root its link
stands in, while `.lf-quiet`'s clip is a rule in the document stylesheet
that no shadow tree adopts (only `MARK_RULES` is adopted, in
`shadow.js`). A `.lf-quiet` note in an `x-shadow` widget would render as
visible text next to the link. `hidden` is the silence available in
every root.

`[hidden]` is safe for this reading to trust rather than a hole for
hidden-but-revealable words: the browser removes the attribute when it
reveals `hidden="until-found"` content, so a word the reader finds is a
word this check sees again, at the moment it is on screen. The two
sibling gate readings that ask what a box shows already spell the same
pair —
[`widgets.js`](https://github.com/max-sixty/leaf/blob/03b4a0b2776102041a2d48580046fbf03b5e3446/skills/leaf/scripts/leaf/render-checks/widgets.js#L146)
and
[`standalone.js`](https://github.com/max-sixty/leaf/blob/03b4a0b2776102041a2d48580046fbf03b5e3446/skills/leaf/scripts/leaf/render-checks/standalone.js#L34).

No assertion is loosened and no runtime file is touched.
`skills/leaf/CLAUDE.md`'s row for this gate already reads "**visible**
page words remain in reachable flow".

## Testing

- `test_example_renders[corpus]` and
`test_example_renders[pr-walkthrough]` — pass with `--run-nightly`; the
first fails on `origin/main` unchanged at the two findings above.
- `test_render_reports_words_a_widget_puts_out_of_reach` gains the pair:
the same `.lf-ui` note inside `lf-option` reports nothing when `hidden`
and is still reported when shown. Bug-back: with the `[hidden]` excuse
reverted, the hidden half fails with exactly the corpus's finding.
- `uv run pytest tests` — 723 passed, 6 skipped, on this branch rebased
onto `c7b3706`.
- `uv run pytest tests/test_render_commands.py tests/test_render_gate.py
--run-nightly` — 77 passed, 4 failed, and all four are in the failed
run's own list and owned elsewhere:
`test_the_render_gate_measures_sideways_room_at_the_root_scrollport`,
`test_the_reader_draws_an_edge_to_the_width_they_want[comments]` and
`test_a_window_with_no_room_for_a_chosen_width_does_not_un_choose_it[comments]`
are #121's `-15`, and
`test_a_reader_arrives_at_what_they_left_rather_than_watching_it_arrive`
is the intermittent one #121 documents.
- `node --check`, pinned `prettier --check`, `ruff check` and `ruff
format --check` pass on the two touched files. `pre-commit` is not on
this sandbox's PATH, so its remaining hooks went unrun here.

The preview digest is unmoved: `capture_input_files()` hashes
`examples/`, `skills/leaf/assets`, the selected packages and three site
scripts, and `skills/leaf/scripts/` is in none of them.

## What this does not fix

The failed run had 32 red. Everything else in it sits with an open PR or
issue — #121, #132, #134, #136, #137, #138, #140, #141, and issues #133
and #139 — with two exceptions I did not reach:

-
`test_render_controls.py::test_every_ring_the_layer_draws_is_shown_whole_somewhere_in_the_corpus`
reports one unlit ring, `version-diff
(.lf-version-diff:is(:focus-visible, .lf-focus-visible))`. That control
is inside the versions menu #137 is rewriting, so it is likely
downstream of that branch rather than its own cause; I have not verified
it against #137's tree.
-
`test_render_navigation.py::test_a_widget_that_renames_its_role_keeps_the_press_offer_gave_it`
is the generic-press question #132 raised and deliberately left as a
product decision.

---
Automated fix for [failed
run](https://github.com/max-sixty/leaf/actions/runs/33330752582)

Co-authored-by: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
max-sixty pushed a commit that referenced this pull request Aug 30, 2026
…p's own press (#140)

## Problem

Two leftovers from
[335bf9a](335bf9a)
"Delegate page chrome to browser standards", which moved control
activation off a page-wide `CONTROL` scope and into rows declared where
each control is. Both are on `ci`'s red list for [the run this was
dispatched
for](https://github.com/max-sixty/leaf/actions/runs/33328596524) or
reachable from the same change, and both still reproduce at `1ca4f40`.

**`lf-worktree`'s head names fewer keys than work it.** The commit gave
the head a row binding `PRESS` — Enter and Space — over a button wearing
`aria-expanded`, which is ARIA's disclosure pattern.
`skills/leaf/CLAUDE.md` states what that costs: "every row over a
disclosure reads [`DISCLOSE`] — this scope's, and a widget's own row
re-wording the same press. Two rows naming different sets is not two
promises but one: `lineRows` prints the nearer row and drops the other
whole, so a widget naming one key fewer takes the rest off the line".
The widget row is nearer, so the arrow that opens the evidence went on
working and stopped being named — on the key line and in
`aria-keyshortcuts`, which is written once when the scope is declared
and so froze at `Enter Space`. Nothing tested it; the identical bug in
`lf-options` is what [#132](#132)
is for, and that PR does not reach this package.

**`test_a_widget_that_renames_its_role_keeps_the_press_offer_gave_it`
still asks for the deleted scope's word.** It asserts the key line reads
`press it` over a focused tab. `press it` was `CONTROL`'s `line`, and
335bf9a removed the scope in the same commit that gave `lf-tabs` its own
`tab.activate` row reading `open the tab`. The commit updated several
tests in that file and dropped
`test_the_reference_names_the_space_that_works_a_control` outright; this
assertion was missed, so the test fails before reaching the two facts it
exists to hold.

## Solution

`lf-worktree`'s head takes its keys from `DISCLOSE(head)`, the way
`lf-diff` and `lf-draft` already do, and keeps its own `run`. `DISCLOSE`
hands over only the arrow that changes the state, so the press stays a
direction rather than a second toggle; it also answers for where the
head stands, and the runtime's disclosure scope stops at the chrome. The
head is a span from `selectableOffer`, so a copy frozen into thread
markup has no platform pair underneath it the way a `details > summary`
does — the row's own press is the only thing there, which is the shape
[#132](#132) keeps for the sibling
widget. Its `line` reads the way the tree is standing, and
`aria-expanded` is written at birth so the head is never briefly a
control the runtime cannot place — without that write `DISCLOSE` hands
back both arrows, and that is the value `aria-keyshortcuts` is written
with when the scope is declared. The repaint that keeps the line and
`aria-keyshortcuts` together is the document's disclosure watch, which
[#132](#132) moved from
`paintHere()` to `paintKeys()` while this branch was open; the widget
owes none of its own, which is what `skills/leaf/CLAUDE.md` now says in
as many words.

The tabs test asserts `open the tab` — the strip's own word for the same
press — and its docstring now describes where that press is declared
rather than the scope that used to supply it. Everything after the
assertion is unchanged: Enter still has to re-select the focused tab,
and Space still has to be consumed rather than scrolling the page out
from under it.

New coverage in `test_render_projection.py` for the worktree head, since
nothing exercised it. It holds the row rather than the repaint: on the
page, both surfaces naming the row's keys — the line and
`aria-keyshortcuts` — plus a second `→` on an open tree, so a scope
answering nothing at all could not pass it. In a message: the same head
in the threads panel, where `aria-keyshortcuts` reads `Enter Space`, the
arrow moves nothing, and Enter and Space fold it. The repaint behind
both surfaces is held by
`test_a_widgets_native_control_names_the_press_the_platform_makes`,
which #132 added.

## Testing

`uv run pytest tests` at `c292244` — 723 passed, 6 skipped. After
merging `main` (which brought #132 and #143) and taking the repaint out
at `890e7ff`, `test_render_projection.py`, `test_render_navigation.py`,
and `test_render_anchors.py` — 193 passed, 4 failed, all four the
version-menu group [#137](#137)
owns and all four failing identically on `origin/main` in a clean
worktree. With `--run-nightly`, `test_render_projection.py` and
`test_render_navigation.py` — 137 passed, 1 failed, that one being
`test_the_arrows_say_which_way_the_section_under_the_reader_goes`, which
is #132's settled ask and untouched here; it fails the same way at the
merge base `1ca4f40`.

Each half of the new test was run against the code it holds and fails
there: the page half at `assert 'Enter Space' == 'Enter Space
ArrowRight'` on `main`'s `lf-worktree.js`, and the thread-markup half at
`aria-expanded` staying `"false"` under Enter with the row's `run`
removed.

<details><summary>What this does not fix in that run</summary>

The run reported 34 failures. Most of the rest already have a PR open:
the `-15` root scrollbar-gutter family is
[#121](#121) (`assert -15 == 0`,
the drawn edge landing a bar's width off the hand, the covering panel's
blank strip), the version-menu group is
[#137](#137), the settled ask's
disclosure keys were [#132](#132),
now merged, the margin and float placements are
[#138](#138), the gallery stills
are [#134](#134), and the
sidebar's stated window is
[#136](#136).

No guard stops the third widget from declaring a fixed key pair over a
disclosure. `DISCLOSE` exists so the two surfaces cannot come apart, but
a widget has to remember to call it, and two of the four widgets over a
disclosure did not. A corpus-wide check — every focusable disclosure
names the keys `DISCLOSE` gives it — would catch the next one, and is
left out of this PR because it belongs beside the corpus checks rather
than in a fix for two widgets.

</details>

---
Automated fix for [failed
run](https://github.com/max-sixty/leaf/actions/runs/33328596524)

---------

Co-authored-by: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
max-sixty pushed a commit that referenced this pull request Aug 30, 2026
## Problem


[335bf9a](335bf9a)
made `.lf-panel` a `<dialog>` and `syncPanelLayer` raised it with
`showModal()` whenever it covers the page. A modal dialog makes the rest
of the document inert, and covering is the posture in which the page
most needs to stay live — so the banner toggle that opened the panel
stopped closing it, the decisions toggle beside it stopped switching
workspace, and the strip of page still showing beside the sheet stopped
answering a selection hint. Separately, both `show()` and `showModal()`
run the browser's dialog focusing steps, so opening the panel took focus
off the control that opened it.

Those are the six unowned tests in #139. Five are the inertness; the
sixth is the focus.

## Solution

The panel is shown, never shown modally, at either posture, and the
invoker gets its focus back. What modality was carrying is already owned
elsewhere and stays: the covering sheet's root scroll lock is the
stylesheet's `overflow-y: hidden` under `COVERING`, and Escape is the
ladder's — `browserDismissesTopLayer` hands Escape to the platform only
for layers the platform really owns, which the keyboard reference and
the page map sheet still are. `skills/leaf/CLAUDE.md` already states the
same reading from the other direction: `presentPage` demotes authored
`showModal()` calls to non-modal dialogs precisely so a modal's
top-layer inertness cannot disable the recovery chrome.

That deletes rather than adds machinery: `panelModal`,
`ignoreNextClose`, the close-and-reopen across the covering breakpoint,
the `resize` and `change` re-syncs that existed only to switch modality,
the `panelFocusTarget` wiring, and the `close` listener that only a
native modal dismissal could reach. `syncPanelLayer` becomes
`showPanelLayer`, called once from `setPanel` — still before
`renderPanel`, for the reason #138 gave it.

The one user-visible consequence is the scrim: a non-modal dialog paints
no `::backdrop`, so the `.lf-panel::backdrop` rule goes with it. That
reads as the point rather than a cost — a scrim over a page the reader
can now press would be saying the opposite of what the page does.

## Which of the six this closes

Measured on this branch, rebased on `main` at 0b7f148 (so #121, #138 and
#141 are in the base).

| test | |
| --- | --- |
|
`test_selection_hints_do_not_name_page_content_behind_a_covering_panel`
| green |
| `test_workspaces_replace_each_other_instead_of_stacking[500]` | green
|
| `test_coarse_pointer_chrome_gives_its_compact_controls_humane_aims` |
green |
| `test_esc_hands_the_page_back_after_it_has_closed_the_last_panel` |
green — already fixed on `main` by #141, kept green here |
| `test_the_responsive_action_shelf_keeps_primary_actions_in_reach` |
green |
|
`test_coarse_pointer_resize_reach_stays_reachable_without_trapping_scroll`
| past its modal failure, red on one that is now `main`'s — see below |

## What this does not fix


`test_coarse_pointer_resize_reach_stays_reachable_without_trapping_scroll`
needed both this and #121, and with both it is still red — on a
different, earlier failure that #121 brought and this change does not
touch. Walking it forward, each row a local `-n0` run:

| tree | first failure |
| --- | --- |
| `main` at 1ca4f40 (pre-#121) | line 1017, `.lf-decisions` click —
`<dialog … class="lf-ui lf-panel open"> intercepts pointer events` |
| 1ca4f40 + this | line 1077, `assert edge["left"] >= -0.1` → `-12` |
| 1ca4f40 + #121 | line 1006, `wait_for_function` on `.lf-threads`
`scrollTop` after a touch swipe |
| `main` post-#121 | line 1006, same |
| `main` at 0b7f148 + this | line 1006, same |

Line 1006 is *earlier* than the failures the modal caused, so it was
masked until #121 landed; it is green on 1ca4f40 and on 1ca4f40 + this.
It belongs to #121's `overflow-y: scroll`, is live on `main` now, and
needs its own fix.

## Relation to #141

#141 landed while this was being measured. It fixed the focus half by
extending `syncPanelLayer`'s restore arm while keeping `showModal()`, so
`test_esc_hands_the_page_back_after_it_has_closed_the_last_panel` is
already green on `main`; this rebases onto it and replaces that arm
rather than competing with it. With modality gone the restore has one
case instead of four, and `focusEnteredLayer`, `panelFocusTarget` and
`returning` all go with it.

## Testing

<details><summary>Runs</summary>

On `main` at 0b7f148:

```
uv run pytest tests -q                                     724 passed, 6 skipped
uv run pytest --run-nightly -n2 <the six, 7 node ids>      6 passed, 1 failed
```

The one failure is the row above. Earlier, on the pre-rebase base
(1ca4f40), the owning files ran `5 failed, 77 passed` for
`test_render_controls.py` + `test_render_semantic_selection.py` and `2
failed, 45 passed` for `test_render_conversations.py`; every one of
those seven reproduced identically on clean `main` — five of the
reserved-gutter shape #121 has since taken, plus
`test_a_settlement_in_a_reply_leaves_its_own_anchor_on_the_page` and
`test_a_mark_in_the_layer_promises_no_press_the_layer_will_not_take`,
which are unrelated and untouched here.

No new test: the six already own the behaviour, and each names it in its
own docstring.

</details>

<details><summary>The visual delta, measured rather than
described</summary>

Same fixture, same viewport, same state — `LONG_PAGE` at 700×900 with
the Threads panel open, which is the covering posture. Panel geometry is
identical across the two (`x=265 y=96 w=420 h=804`); `dialog:modal`
reads `true` before and `false` after.

Every pixel outside the panel changes, and only by the scrim coming off:

```
left-of-panel differing px: 238500 of 238500   (222,221,219) -> (250,249,246)
panel-region differing px:   43380 of 391500   the banner band above the panel, same two colours
```

`(222,221,219)` is paper under `color-mix(in srgb, var(--ink) 12%,
transparent)`; `(250,249,246)` is paper. The banner was inside the
dimmed area too, which is the visible half of the bug: the chrome the
reader closes the panel from was drawn as if it were unavailable, and it
was.

</details>

---
Closes #139

Co-authored-by: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants