Skip to content

Give the scroller's gutter one reading, and drop the tolerance the fix retired - #65

Merged
max-sixty merged 9 commits into
mainfrom
fix/one-gutter-reading
Aug 27, 2026
Merged

Give the scroller's gutter one reading, and drop the tolerance the fix retired#65
max-sixty merged 9 commits into
mainfrom
fix/one-gutter-reading

Conversation

@leaf-agent

@leaf-agent leaf-agent commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

main had two independent stale layout assertions:

  • The margin-resident test expects a 720px column minus a 15px scrollbar gutter, although Take the scroller's gutter off the strip floor, not out of the column #64 now correctly preserves the full 720px column at and above the combined strip floor.
  • The option-address test pinned a digit to the old 6px inset, although the status stripe now occupies the start of that gutter and the digit correctly begins at 15px.

#56 has since repaired the stale option-address assertion on main and directly checks the status/address/prose relation for the card form. This branch is therefore back to the scrollbar-gutter repair alone. #66 remains separate: it has been reopened to retain the corresponding row-form and cross-form coverage.

Solution

The runtime now has one scrollerGutter reading beside pageScroller; stateStrip and stateRoom both use it. The browser test asks that production helper for the width it drives, but first cross-checks it against an independent body-box reading. That guard catches a correlated overread before the helper can choose and judge the same viewport. The roomy column is then required to remain the full 720px; the retired gutter tolerance and its prose form are removed.

Testing

  • test_opposite_margin_residents_wait_for_the_room_they_need, the current test_a_questions_digits_are_drawn_whole, and Add flowchart node comment anchors #56's direct option-pointer regression: 3 passed together after merging current main.
  • All four files changed relative to current main pass pre-commit.
  • Gutter overread bug-back: without the independent cross-check, a planted 30px reading passes; with the guard, it fails at 30 == 15.
  • Removing scrollerGutter() from the strip calculation fails at 705 == 720.

Exact-head full CI and review remain the merge gate.

…x retired

main is red on test_opposite_margin_residents_wait_for_the_room_they_need.
#64 made the strip veto take the scroller's gutter off, so the column now
holds a full 720 at and above the combined floor; the tolerance that came in
beside it from the test side, `720 - roomy["gutter"]`, still subtracts a bar
from what the roomy read expects. On a platform that draws one that is
`720 == 705`, and there is no shortfall left for it to express.

The gutter had three spellings across the two files that owe it — the veto
off the scroller's boxes, the room off body's, and the test off the window
against body's padding box — so the claim that they were the same number was
true by inspection in a file whose rule is that one representation answers
one question. It is one reading now, `scrollerGutter`, beside the scroller it
is a fact about; stateStrip and stateRoom both call it, and the test reads
its own `gutter` key rather than a fourth expression. The window spelling was
the one that could drift: it agrees with the other two only while body
carries no margin, and the panel's strip is a body margin.

The veto also gained an input none of its callers announce. It is called
rather than observed, because the strip it vetoes is body's own padding and
syncLayout runs from an observation of that box, so the window and the panel
reach it on occasions of their own while the bar's arrival does not — a
replay retiring a slot or a late widget settlement can put a bar on the page
with no resize behind it. Joining layoutSizes is the one fix the one-writer
rule forbids, and what is left standing is a bar wide in the band where the
floor is met within a bar. Recorded at the function rather than chased with a
call from every path that can change the document's height.
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

ci-fix was dispatched for run 33031047410 (fb03469), which failed on this same test one commit before #64 landed — assert 705 == (720 - 0), the pre-#64 fault. #64 closed that instance; no separate fix is needed for it. Reporting here rather than opening a second PR, since this one is what leaves the test green.

I reproduced both ends on a CI Linux runner (Chrome for Testing 151.0.7922.34, -n0): main at 6e655de fails with assert 720 == (720 - 15), and this branch at 0a1c29f passes. That is a second, independent confirmation of the reproduction in the PR body — main is live-red on test_opposite_margin_residents_wait_for_the_room_they_need right now, and this is the only open change that clears it.

The fb03469 run as evidence for the one-reading half

That run predates a196d93, so its gutter key was still the window spelling, documentElement.clientWidth - document.body.getBoundingClientRect().width. It read 0 on a runner where the bar is 15 — the column in the same reading was 705, i.e. 720 less a bar:

'column': {'left': 288, 'right': 993, 'width': 705}, 'gutter': 0, 'sideways': 0
assert 705 == (720 - 0)

Both terms were wrong at once and they cancelled in the direction of a louder failure rather than a quieter one, which is luck. It is the drift this PR's scrollerGutter argument names, measured rather than reasoned about.

Local runs, for the record:

6e655de  FAILED  assert 720 == (720 - 15)   'column': {... 'width': 720}, 'gutter': 15
0a1c29f  passed

@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.

Reviewing my own PR, so this is a COMMENT rather than an approval. The code change is right and I verified it end to end: test_opposite_margin_residents_wait_for_the_room_they_need fails on main at 6e655de with assert 720 == (720 - 15) and passes here, the whole of test_render_pages.py is green on the merged tree (43 passed), and the floor assertion is non-vacuous — taking scrollerGutter() back out of stateStrip fails it at 705 == 720 exactly as the description says. Both #64 review observations are answered. One finding, on the prose rather than the code.

The freshness caveat documents a hazard the layer already closed. The new paragraph on stateStrip says body "gains or loses its bar whenever the document's height crosses the viewport", and that replay retiring a slot, a late widget settlement, or an arriving image can each leave "a grant made when there was no bar to pay for". None of those can. runtime/chrome-style.js gives body scrollbar-gutter: stable in the same rule that makes it the scroller — body { height: 100%; overflow-y: auto; scrollbar-gutter: stable; … } under :where(html:not(.lf-copy)) — so the room is reserved whether or not a bar is drawn in it, and offsetWidth - clientWidth holds still for the page's life. Measured on this runner: a page shrunk from 1500px of content to 10px reads the gutter at 15 before and 15 after, with document.body.scrollHeight > document.body.clientHeight false at the second read.

That matters because the caveat is load-bearing in the way this repo's comments are — it names a live 15px defect and declines a fix on the strength of it, and the next reader will reason from it. The #64 review that asked for it was wrong on the same point, so the PR answered the observation faithfully; the honest answer to that observation is that the stylesheet had already answered it. The same claim rides in CLAUDE.md's new sentence ("its comment records what the gutter's freshness costs") and in the PR description's second bullet.

I'm pushing the prose correction rather than leaving it — no author but me will act on it, and it is comment-only with no behavioral risk. Revert it if you'd rather keep your own framing of the limit.

How the gutter probe was run

Against this PR's head in a worktree, with the served page's own module:

probe = "() => import('/runtime/scrolling.js').then(m => m.scrollerGutter())"
tall = leaf_page("tall", '<h1>T</h1><div id="pad" style="height: 1500px"></div>')
page, errors = open_page(browser, serve(tall))
resized(page, 1416, 800)
with_bar = page.evaluate(probe)
page.evaluate("() => document.querySelector('#pad').style.height = '10px'")
page.wait_for_timeout(300)
without_bar = page.evaluate(probe)

GUTTER tall=15 short=15 still_scrolls=False.

Non-vacuity check, with scrollerGutter() removed from stateStrip's avail: fails at tests/test_render_pages.py:2006, assert 705 == 720, column {'left': 288, 'right': 993, 'width': 705}.

Comment thread plugins/leaf/skills/leaf/assets/leaf.js Outdated
Comment thread tests/test_render_pages.py Outdated
The freshness note stateStrip carried named a hazard the layer has already
closed: body is given scrollbar-gutter: stable in the same chrome-style.js rule
that makes it the scroller, so the room is reserved whether or not a bar is
drawn in it and the difference between the scroller's two boxes holds still for
the page's life. A document that stops overflowing reads the same gutter it read
while it scrolled — 15 before and 15 after on a Linux runner — so replay
retiring a slot, a late settlement and an arriving image are answered where the
scroller is declared rather than left as a bounded residual.

Record that beside the reading in scrolling.js, at the veto that owes it, and in
the runtime guide's sentence about which of the two geometry readings is
observed.
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

A third ci-fix dispatch, run 33031638136 on e2f579f#64's own merge commit — lands on the same failure this PR fixes, and needs no separate PR either.

It is the post-#64 shape rather than the pre-#64 one the earlier comment recorded: assert 720 == (720 - 15) at tests/test_render_pages.py#L2020, identical to the reproduction at 6e655de in the PR body. So main went red at fb03469 with one fault, #64 closed that one and opened this one at e2f579f, and it has stayed red through 6e655de — one continuous red across three commits, with this PR the only open change that ends it. test is still pending on 88051b6.

The failing run's own output
tests/test_render_pages.py::test_opposite_margin_residents_wait_for_the_room_they_need
E  AssertionError: the two strips cost more than the live page's stable scrollbar gutter:
E  ... 'column': {'left': 288, 'right': 1008, 'width': 720}, 'gutter': 15, 'sideways': 0
E  assert 720 == (720 - 15)
1 failed, 1330 passed, 6 skipped in 1631.13s

lint passed on the same run; test is the only red job.

The runtime half of this change gives the gutter one reading; the test
that drives the page past the floor hand-wrote the same expression in its
own reading dict, which is a second spelling that agrees on inspection
rather than by construction. Ask /runtime/scrolling.js for it instead.

The reading's own gutter key stays: it is what the failure dumps report
the short measure against, which is the first thing to know when this
assertion goes red.
@max-sixty

Copy link
Copy Markdown
Owner

The latest test change makes the production helper both choose the roomy viewport and drive the behavior under test, which leaves a correlated-error blind spot. For example, if scrollerGutter overreads a real 15px gutter as 30px, the test resizes to 1446px and stateStrip subtracts the same wrong 30px to reach its 1416px threshold; every assertion can pass while 1431–1445px viewports remain wrongly cramped and stateRoom is 15px short. Please keep or compare the independent browser measurement already available as reading["gutter"] (for example, assert bar == at_floor["gutter"] before using bar). This should preserve the single production implementation while keeping the test oracle independent.

@leaf-agent

Copy link
Copy Markdown
Collaborator Author

The ci run this branch was pushed ahead of has now finished: run 33031937581 on 6e655de, 2 failed, 1329 passed, 6 skipped. One is this PR's, with the assertion exactly as the body predicts; the other is test_a_questions_digits_are_drawn_whole, a stale pin left by 6e655de's own gutter change, which is #66. No third failure, so those two close out that run between them.

Asking scrollerGutter for the width the page is driven at made the helper
choose the viewport and then be the thing under test, so an error in the
helper landed on both sides and cancelled. A gutter overread as 30 puts
the page at 1446 and has stateStrip take 30 off it: the floor is met on
the nose, every measure passes, and the band from 1431 up is cramped for
nothing. Verified — with the overread planted and no guard, the test
passes green.

Hold the two spellings to each other at the one viewport both are read
at. The reading's gutter key becomes a claim about what the gutter is
rather than a copy nothing checks, and stays what the failure dumps
report a short measure against.
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

You're right, and the blind spot was live rather than theoretical — I reproduced it before fixing it. With scrollerGutter planted to return (offsetWidth - clientWidth) * 2 and no cross-check, the test passes green: at 1446 the real 15px bar leaves body 1431 wide, stateStrip takes the overread 30 off to reach 1416 on the nose so the strips are granted, and column.width reads 720 because main's max-width caps it there anyway. Both at_floor and roomy are satisfied by the same error that chose the viewport.

Fixed in c8d85d9 the way you asked — assert bar == at_floor["gutter"] at the one viewport both are read at, before either drives anything. Same plant now fails at assert 30 == 15.

That also settles what I said in the reply above about keeping the gutter key for the failure dumps: it is a claim about what the gutter is, not a second copy nothing checks, and it still reports the bar a short measure was read against. The driving width stays the module's, so the by-construction point the review was making survives.

tests/test_render_pages.py is green at 43 passed on the new head; test is running.

The two bug-backs

Both against the merged tree on a CI Linux runner, Chrome for Testing 151.0.7922.173, -n0.

The blind spot, with the guard removed and scrollerGutter overreading:

planted overread AND removed the new guard
.                                                                        [100%]
1 passed, 42 deselected in 2.73s

The guard, same plant:

E  AssertionError: the module's gutter and the page's own reading of it have come apart,
E  which would leave the widths below chosen and judged by the same error:
E  ... 'column': {'left': 340.5, 'right': 1060.5, 'width': 720}, 'gutter': 15, 'sideways': 0
E  assert 30 == 15

The dump also shows the consequence directly: padding is {'left': 0, 'right': 0} and both sidebars are static at 1416, so the overread cramped the page there while column.width == 720 went on passing.

And the original gate is still non-vacuous — with scrollerGutter() taken back out of stateStrip's avail, it fails at assert 705 == 720 with 'gutter': 15 in the dump.

@max-sixty

Copy link
Copy Markdown
Owner

The oracle fix on c8d85d9 closes the blocker: the independent body-box reading is now compared with scrollerGutter() before that helper chooses the roomy viewport. Please refresh the PR description before merge, since its Solution section still says the test reads its own gutter key instead of a fourth expression and does not describe the new production-helper call plus independent cross-check. The Testing section should name the overread bug-back as well. Code verdict is otherwise acceptable; exact-head CI remains the final gate.

@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.

Reviewing my own PR, so this is a COMMENT rather than an approval. The increment since my last review is the guard @max-sixty asked for, and it does what was asked: the two spellings are held to each other at the one viewport both are read at, before either drives a resize, and the bug-back in the reply above shows the planted overread passing without it and failing at assert 30 == 15 with it. Both earlier threads are resolved. One finding, on prose the increment left behind.

The docstring still carries the tolerance this PR retires. 7f17a60 "Account for stable gutter in sidebar test" introduced assert roomy["column"]["width"] == 720 - roomy["gutter"] and, in the same hunk, rewrote the docstring's third sentence from "both may stand outside a full-width column" to "both may stand outside the ordinary column, less only a live platform's stable scrollbar gutter". This PR deletes the assertion and leaves the sentence standing. It now contradicts the comment eight lines below it in the same function — "neither read subtracts a bar from what it expects ... a measure that fell short of 720 anywhere here would be the fault this floor exists to prevent rather than a tolerance to write down" — and a docstring is where the next author would take the tolerance back from, which is the whole shape this PR exists to close.

What the two assertions now say between them is that at the combined floor of the page's own box both strips stand outside a full 720 column, and a window short of that floor by a bar's width has the veto hand them back — at a 1416 window with a 15px bar, avail is 1401 against --strip-min: 1416, so data-lf-cramped is set, which is why at_floor reads a full column too. That is a stronger sentence than the one 7f17a60 replaced, so a plain revert would undersell it.

I'm pushing the correction rather than leaving it: no author but me will act on it, and it is docstring-only with no behavioral risk. ci.yaml declares no concurrency group, so the test run in flight on c8d85d9 finishes on its own rather than being replaced by the push. Revert it if you'd rather keep the current framing. The description refresh @max-sixty asked for above goes in the same round, wearing the author hat rather than this one.

Nothing else stands. The runtime half is a behavior-identical extraction — pageScroller is document.body, so stateStrip and stateRoom each keep the exact number they computed inline — and I checked the two claims the new comments rest on rather than taking them: scrollbar-gutter: stable is on body in the same :where(html:not(.lf-copy)) rule that gives it overflow-y: auto (runtime/chrome-style.js), and the chrome stylesheet is appended to document.head at module top level, well before the first stateStrip() call, so there is no cold read of 0 before the rule lands.

Where the docstring sentence came from
$ git show 7f17a60 -- tests/test_render_pages.py
-    for prose. At the combined floor, both may stand outside a full-width column. A
-    script-free copy has to make the same choice from its viewport alone.
+    for prose. At the combined floor, both may stand outside the ordinary column, less
+    only a live platform's stable scrollbar gutter. A script-free copy has to make the
+    same choice from its viewport alone.
@@
-    assert roomy["column"]["width"] == 720
+    assert roomy["column"]["width"] == 720 - roomy["gutter"], (
+        f"the two strips cost more than the live page's stable scrollbar gutter: {roomy}"
+    )

One commit, one hunk: the assertion and its sentence form arrived together, so retiring one leaves the other unsupported.

7f17a60 wrote the docstring's "less only a live platform's stable
scrollbar gutter" in the same hunk that wrote the assertion it
describes. The assertion is gone; the sentence said what the two
assertions now say between them is wrong, and a docstring is where a
next author would take the tolerance back from.
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

Description refreshed — both asks are in the body now, since the edit itself is silent and you gated merge on it.

Solution no longer says the test reads its own gutter key instead of a fourth expression. It describes the actual arrangement: the test asks the served page's module for scrollerGutter() and drives the roomy resize at that, the page's independent body-box reading stays on reading as gutter, and the two are held to each other at the one viewport both are read at, before either drives anything. It also says why the window spelling was the one of the three that could drift — it agrees only while body carries no margin, and the panel's strip is a body margin.

Testing names the overread bug-back as its second bullet, both directions: the plant passing green with the cross-check removed, and failing at assert 30 == 15 with it in place.

Head is e6d0a63, which added the docstring correction on top of c8d85d9. ci's test on that exact head is still running and is the final gate — I haven't seen it terminal.

@max-sixty

Copy link
Copy Markdown
Owner

Current head 1b0375c now stacks the independently reviewed #66 commit so one branch closes both failures on red main. That combined code is acceptable, but the PR description still presents only the gutter change and no longer matches the five-file diff. Before merge, please document that #66 is intentionally included to break the two-failure CI cycle, summarize its stale navigation-test repair/testing, and state that #66 will be closed as landed through this PR. I will require the exact combined head CI and review checks to pass; the currently cancelled review check will be rerun rather than waived.

@leaf-agent

Copy link
Copy Markdown
Collaborator Author

Both description asks are already in the body — you made those edits yourself at 03:00:10 and 03:03:57, so I've left the prose alone rather than clobbering it. For the record it now covers all three: the Problem section states that each isolated repair stayed red on the other failure so this branch carries both and gets one exact full-suite gate, and that #66 will be closed as landed through #65; the Solution section's second paragraph summarizes the navigation-test repair as a layout relation rather than duplicated theme arithmetic; and Testing names test_a_questions_digits_are_drawn_whole and its three bug-backs. The diff is six files, not five — plugins/leaf/skills/leaf/CLAUDE.md, assets/leaf.js, assets/runtime/scrolling.js, tests/render_cases_navigation.py, tests/test_render_navigation.py, tests/test_render_pages.py — which matches the body's "all six changed files".

The part that does need you: the review rerun you asked for already happened, and it was the waiver rather than the rerun. You reran it at 03:00:42 (attempt 2 of run 33034993120); it reported review green in 5 seconds having skipped checkout and the agent entirely. The cause is the workflow's own skip gate — the tend-review/65 commit status on 1b0375c was stamped success at 03:00:32 by run 33034092227, the session that started on c8d85d9, and that run was then cancelled at 03:03:27 without ever publishing a review for this head. The PR's review list still ends at c8d85d9, so nothing has examined the combined head. Every rerun would have kept skipping for as long as that stamp stood, so this was not recoverable by repeating what you tried.

I set the stamp to pending and reran, and attempt 3 is now genuinely reviewing 1b0375c — the gate took its should_run=true path, the eyes reaction went on, and max-sixty/tend/claude is running rather than skipped. No ruleset requires that context, so nothing was blocked by the intermediate state, and the real review will re-stamp it. The head is untouched, so the exact-combined-head ci run you're gating on was not restarted; its test job is still in flight and lint has passed.

Worth a look upstream in max-sixty/tend: the stamp is written by a live session as it folds pushes in, but it outlives that session's cancellation, so a cancelled review permanently marks its last-folded head as examined. That converts a cancel into a silent, self-perpetuating waiver on exactly the commit a maintainer is most likely to be gating — which is what happened here.

Evidence

Attempts of run 33034993120 (the synchronize run for 1b0375c):

attempt created outcome agent step
1 02:59:43 cancelled at 02:59:59 never reached
2 03:00:42 success in 5s skipped
3 03:11 (mine, stamp cleared) in progress in_progress

Attempt 2's steps: Skip when the live HEAD is already examined=success, React with eyes=skipped, both checkouts skipped, Run max-sixty/tend/claude@0.1.18=skipped.

The stamp it read:

context:     tend-review/65
state:       success
created_at:  2026-08-27T03:00:32Z
target_url:  .../actions/runs/33034092227     <- conclusion: cancelled, updated 03:03:27
description: examined by tend-review

The gate in .github/workflows/tend-review.yaml skips whenever the live head carries a success status in context tend-review/$PR; it reads the live PR head deliberately, so the staleness is in the stamp's lifetime, not in which commit the gate consults.

Reviews on the PR, newest last — none on 1b0375c:

0a1c29f  COMMENTED  02:18:58
5d7dcfd  COMMENTED  02:30:23
5d7dcfd  COMMENTED  02:30:25
c8d85d9  COMMENTED  02:49:27

@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.

Reviewing my own PR, so this is a COMMENT rather than an approval. This is the first review on the combined head 1b0375c — the earlier ones stop at c8d85d9, for the stamp reason recorded in the comment above. Nothing to raise: no finding survived, and both threads from earlier rounds are resolved.

The increment since c8d85d9 is two commits. e6d0a63 is the docstring correction my last review asked for, and it now says the right thing: at a 1416 window with a 15px bar avail is 1401 against --strip-min: 1416, so the veto fires and at_floor reads a full column, while 1431 clears the floor exactly and grants both strips — which is what the two assertions read. 1c3f14c is #66's commit, and no review on this PR had covered it, so I read it as new rather than taking #66's.

What I checked on it, rather than reasoning from the description. INSIDE_ITS_OPTION now reads status.left/status.width off lf-option::before and paddingInlineStart off the option, and both share an origin with x: lf-option is position: relative (lf-option { display: block; position: relative; }), so the absolutely-positioned status rule's containing block is the option's padding box, which is the same edge opt.x + borderLeftWidth lands on. Both groups on ADDRESS_PAGE are unsettled lf-options[choose], so the rule that generates that pseudo (& > lf-option::before { content: ""; position: absolute; inset-inline-start: var(--sp-2); … }) applies to card and row alike — which is what keeps parseFloat(status.left) off auto, and so off NaN.

I ran the three bug-backs independently, and each new term of the chain fails on its own — the band is not carried by one term with two decorations.

Bug-backs and runs

Merged tree at 1b0375c, -n0, headless shell. Clean: tests/test_render_navigation.py -k digits and tests/test_render_pages.py -k opposite_margin both pass.

afterStatus < x, with --lf-address-start moved back into the status rule's room:

AssertionError: c-heater's digit runs 4…23 in a gutter whose status rule ends at 11 and whose words open at 29, so the gutter is holding one of the three in another's room
assert 11 < 4

ends < opens, with --lf-address-col pulled 10px in so the prose opens under the chip:

AssertionError: c-heater's digit runs 15…34 in a gutter whose status rule ends at 11 and whose words open at 30, …
assert 34 < 30

len(seats) == 1, with the row form given inset-inline-start: calc(var(--lf-address-start) + 2px) — a seat still inside the band, so only the cross-form check can see it:

AssertionError: the digits stand at more than one column: {15: ['c-heater', 'c-cable', 'c-hand'], 17: ['r-now', 'r-later']}
assert 2 == 1

The third is the one worth having: it is the only assertion that would have caught a per-form override of the column, which is the shape the docstring says broke before.

The runtime half, re-read against the combined head

scrollerGutter has exactly two production callers and one test reading — grep -rn "offsetWidth - .*clientWidth\|scrollerGutter" over assets/ and tests/ returns leaf.js:1759, leaf.js:1879, the definition, test_render_pages.py:1981 (reading's own key, deliberately a second copy), and the import('/runtime/scrolling.js') at 2019. So the one-reading claim holds where it is made.

The extraction is behavior-identical including evaluation order: panelStrip and trayStrip are commentsEdge.width() / traysEdge.width(), and width() is held(chosen) — a read of documentElement.clientWidth through cap(), with state() the only writer. So moving the gutter read from before those calls to after them in stateRoom puts no mutation between the reads.

lint is green on this head. ci's test job on 1b0375c is still running; I have not seen it terminal, and it remains the merge gate alongside this review.

# Conflicts:
#	tests/test_render_navigation.py
#	tests/test_render_pages.py

@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.

Reviewing my own PR, so this is a COMMENT rather than an approval. No findings; posting because a self-authored PR has no empty-body approval to say "this head was examined", and that ambiguity is what bit this PR once already.

The increment since my last review at 1b0375c is your merge commit 2bd593b and nothing else — git log --no-merges 1b0375c..2bd593b --not ac88a1a is empty, so no authored line arrived with it. What the merge did change is the branch's composition: it takes main's tests/render_cases_navigation.py and tests/test_render_navigation.py over #66's, and both files are now byte-identical to ac88a1a. So the two asks in your 03:00 comment — document #66's inclusion, summarize its repair — are void rather than outstanding, and the body already reads that way: it presents the scrollbar-gutter repair alone and says #66 is superseded by #56 and can close unmerged. That matches the four-file diff.

The remaining four files are unchanged from what I reviewed at 1b0375c, where nothing survived. I re-checked the one thing the new base could have disturbed — whether #56 reaches the code this PR moves. It does not: scrolling.js on ac88a1a is pageScroller alone, and a grep of the merged tree finds exactly the readings the one-reading claim allows.

leaf.js:251                import { pageScroller, scrollerGutter } from "./runtime/scrolling.js";
leaf.js:1759               scrollerGutter() -            (stateStrip)
leaf.js:1879               scrollerGutter(),             (stateRoom)
runtime/scrolling.js:21    the definition
test_render_pages.py:1981  reading's own key             (the deliberate second copy)
test_render_pages.py:2019  import('/runtime/scrolling.js')

lint is green on this head. ci's test on 2bd593b is still in flight; I have not seen it terminal, and it remains the merge gate.

@leaf-agent

Copy link
Copy Markdown
Collaborator Author

Checked your #66 rationale against the tree rather than taking it, and it holds: test_the_pointer_does_not_take_a_cells_status_with_it does assert left + width < starts < ends < column — the status → address → prose relation, directly and with all three boundaries read. So the resolution at 2bd593b, the four-file description, and closing #66 as superseded are all right, and I'm not proposing anything for this PR.

One residual worth knowing, since it isn't in what you enumerated as preserved. That relation is read on #l-stage, which is a card (<strong> title plus prose) in #live-group; the test never touches #live-rows. The navigation test is the one that walks both forms, and on main its horizontal question is now assert sits["x"] > 0. Between them, the row form's horizontal placement is asserted by nothing — that its digit clears the status rule, that it stops before the prose column, and that it lands on the same column as a card's. Its clipping and vertical placement are still covered on both forms.

That is the form with the history. The navigation test's own docstring says the two forms are exactly what differed — "cards padded clear of their corners, rows with none to spare" — and records the vertical pin already going wrong for rows once, level with a 15px row and then two pixels high at 17px, with the gate green throughout. So the fragile form is the one now unwatched in the axis this whole round was about. Cheapest close is a second read of the existing relation against a row in the options test; no new specimen needed, since #live-rows is already on that page.

What I ran, and one limit on it

On the merged tree (origin/main merged into the branch), tests/test_render_navigation.py and tests/test_render_pages.py in full: 82 passed. On main at ac88a1a, test_a_questions_digits_are_drawn_whole and test_opposite_margin_residents_wait_for_the_room_they_need: 2 passed.

The limit: this sandbox reads the scroller's gutter as 0, so the margin-resident test is vacuous here. Both mutually exclusive forms pass — main's column == 720 - gutter and the branch's column == 720 — which is only possible at gutter == 0. Nothing I ran locally exercises the gutter half of this PR; the CI runner with a 15px bar is the only real gate for it, and test is still in flight on 2bd593b.

I also reached the wrong conclusion first here and want it on the record so it isn't re-derived: I grepped for afterStatus, found nothing outside the branch, and had "the comment points at a test that doesn't exist" written up. The relation is there under different names in a different file. The grep was the mistake, not the comment.

# Conflicts:
#	tests/test_render_pages.py
@max-sixty
max-sixty enabled auto-merge (squash) August 27, 2026 03:58
@max-sixty
max-sixty merged commit f154596 into main Aug 27, 2026
4 checks passed
@max-sixty
max-sixty deleted the fix/one-gutter-reading branch August 27, 2026 04:23
max-sixty pushed a commit that referenced this pull request Aug 30, 2026
…draws in (#121)

## Problem

`ci` has been red on `main` since
[26feec8](26feec8)
"Use the browser root as Leaf's document scroller" — four consecutive
runs, the same 13 tests each time, [the run this was dispatched
for](https://github.com/max-sixty/leaf/actions/runs/33296140347)
included (its commit only edited `TODO.md`). The root scroller took
`scrollbar-gutter: stable`, and Chromium honours that by holding a
classic bar's width open even where the platform draws overlay bars,
which take no room at all. That width comes out of the page while every
window-relative coordinate keeps the whole window, so the layout and
everything that measures against the window disagree by exactly one bar.

Measured here, at a 1200px viewport: the html box is 1185px wide,
`documentElement.clientWidth` is 1200, and `innerWidth -
documentElement.clientWidth` — the reading the suite and `stateStrip`
use to find the bar — is 0. The 15px is reserved and nothing reports it.
The three shapes the suite caught: a drawn edge landed a bar's width off
the hand that drew it (`the edge did not follow the hand: … 'edge': 590
… 'chosen': '595'`), a covering sheet anchored to the initial containing
block's right edge left a blank strip at the window's (`'panel':
{'left': -15, … 'right': 305}` on a 320px window, with the toast inside
it off screen), and a page with nothing overflowing measured a bar's
width narrower than the box it scrolled in (`assert -15 == 0`).

<details><summary>The browser behaviour, isolated</summary>

Standards mode, this suite's pinned Chromium (151.0.7922.34), 1200×800:

| `html` rule | `innerWidth` | `de.clientWidth` | `de.scrollWidth` |
`de.getBoundingClientRect().width` |
| --- | --- | --- | --- | --- |
| `overflow-x: hidden; overflow-y: auto` | 1200 | 1200 | 1200 | 1200 |
| `overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable` |
1200 | 1200 | 1185 | 1185 |
| `overflow-x: hidden; overflow-y: scroll` | 1200 | 1200 | 1200 | 1200 |

The bars here are overlay ones — a nested `overflow: auto` container
with vertical content keeps `clientWidth === offsetWidth`, and the same
container with `scrollbar-gutter: stable` loses 15px. So the gutter is
reserved for a bar that will never stand in it, and `scrollWidth -
clientWidth` — the suite's "does the page scroll sideways" reading —
sits at `-15` before anything overflows.

</details>

## Solution

`overflow-y: scroll` asks for the same standing bar that the `stable`
gutter was there to get. It is a no-op where bars overlay, and where
they take room it takes exactly the room the bar does — the room
`innerWidth - documentElement.clientWidth` already reports, which is
what [#64](#64) and
[#65](#65) established as the one
reading. It also makes true again the sentence `skills/leaf/CLAUDE.md`
states about the new scroller, that `documentElement.clientWidth` is
already the root scrollport's usable width; that comment in
`chrome-layout.js` is reworded to say the standing bar rather than the
reserved gutter.

Two tests carried a stale premise from before the scroller moved, and
neither is loosened here:

- the render gate's off-window float is still required to be named,
against `<html>` — the scrollport since the move — rather than `<body>`.
The gate already reports it that way; the same stale sentence in
`render-checks/layout.js` is corrected beside it.
- `test_covering_panel_takes_the_page_scroll_with_it` now waits for the
glide that centres a quoted passage to be over before it wheels. The
glide reaches its destination a frame or more before the browser fires
its `scrollend`, and a wheel sent inside that window cancels the
animation instead of scrolling, so the reader's notch is spent stopping
a glide that had already stopped moving. Measured: at the moment the
mark is centred one `scrollend` has fired, at 3442 — the instant
scroll's — and the glide's own lands at 3669 afterwards.

## Testing

All 14 tests the failed run reported now pass, and each was reproduced
first at `-n0` on `main` at `01ec4f4`. Also green: the everyday suite
(`uv run pytest tests`, 723 passed), and `test_render_controls.py`,
`test_render_pages.py`, `test_render_widgets.py`, `test_render_gate.py`,
`test_render_options.py`, `test_render_margin.py`,
`test_render_navigation.py` with `--run-nightly` (362 tests). `ruff
check`, `ruff format --check`, `prettier --check`, and `node --check`
pass on the touched files.

## One failure in that run this does not fix


`test_render_gate.py::test_a_reader_arrives_at_what_they_left_rather_than_watching_it_arrive`
is intermittent and predates this branch: on `main` with these changes
stashed it failed 2 runs in 6, and with them applied 1 in 3 — the same
rate, and the arrangement it names differs every time (`the leaves tray
standing` here, `the tray panel at the width the reader drew it to` and
`the thread panel open` in the two CI runs).

<details><summary>What it is, for whoever picks it up</summary>

The extra motion is `CSSTransition box-shadow 120ms` on 57–83
`lf-option`s, and it is not hover —
`document.querySelectorAll(':hover')` is empty when the finding lands.
`theme.css`'s arrival guard is `body:not([data-lf-presented])` and
`.lf-ui`, and its own comment names the trade: "`transition` does not
inherit, so what an unreached descendant can still play is a transition
of its own declaring; that is the trade, and the check that finds one is
the suite's". `lf-options[choose] … > lf-option` in the default package
theme declares `transition: box-shadow .12s ease, transform .12s ease`,
and its `box-shadow` is `var(--lf-ring), var(--lf-lift)` where
`--lf-ring` moves when replay writes `[chosen]`. So it is an unreached
descendant with a transition of its own, and the coverage that was
supposed to find one has. Left out of this PR because it is a separate
cause in a separate layer and would be revertable on its own.

</details>

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


## Rebased onto `d8d1f1e`

`main` has since absorbed three of the five files this branch touched,
so the diff is now the `chrome-style.js` rule and the `scrollend` wait
alone.
[7336a14](7336a14)
landed the render gate's `<root scrollport>` naming and its own wording
of the `layout.js` comment, and
[335bf9a](335bf9a)
deleted the `chrome-layout.js` sentence reworded above. Everything the
**Solution** section says about those three files is now true of `main`
rather than of this diff; the CSS change and its reasoning are
unchanged.

The **Testing** section describes the tree as it stood at `01ec4f4`.
Re-measured on `d8d1f1e`, over the 57 tests the latest red run
([33325826683](https://github.com/max-sixty/leaf/actions/runs/33325826683))
reported plus 7336a14's new
`test_the_render_gate_measures_sideways_room_at_the_root_scrollport`: 28
red before this branch, 20 after. The nine it fixes include that new
gate test, which asserts on a non-overflowing page exactly the `-15`
this PR is about. Two of the twenty it leaves red are reached only
because it gets past an earlier assertion, and neither is its doing —
the thread comment below carries them.


## What the covering lock costs

The two covering locks spell themselves `overflow-y: hidden`, which
draws no bar, while `scrollbar-gutter: stable` held its room open under
`hidden` too. So where the platform draws classic bars, the strip of
page still visible beside a covering sheet now widens by a bar's width
as the sheet opens and narrows back as it closes — one more rewrap on a
window that was already rewrapping. Holding that room across the lock
means reserving it on every platform, overlay-bar ones included, which
is the `485` box inside a `500` `clientWidth` this PR exists to end, and
the locked state is where it cost the most: that is the covering sheet
landing at `left: -15` with its toast off screen. So the rewrap stays,
and it is recorded beside the lock rules rather than only in the thread.
No test can see it — every browser the suite launches carries
`--hide-scrollbars`.

---------

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