Skip to content

Take the scroller's gutter off the strip floor, not out of the column - #64

Merged
max-sixty merged 1 commit into
mainfrom
fix/ci-33023752384
Aug 27, 2026
Merged

Take the scroller's gutter off the strip floor, not out of the column#64
max-sixty merged 1 commit into
mainfrom
fix/ci-33023752384

Conversation

@leaf-agent

Copy link
Copy Markdown
Collaborator

Problem

ci is red on main. The run this was dispatched for (b270391) failed on test_the_aim_reads_the_pointer_where_the_press_is_dispatched_from, which 842e147 has since fixed — that commit's run is green. But main is still red one commit later, on a different and durable fault: run 33027121854 at 5db3e8f, test_opposite_margin_residents_wait_for_the_room_they_need, assert 705 == 720.

The 15px is body's scrollbar. body owns the document's scroll, so where the platform draws a classic bar it comes out of body's padding box while documentElement.clientWidth still reads the whole window — and stateStrip asks the floor of the window. At 1416px the media query grants both strips, the veto sees 1416 against a floor of 1416 and stays quiet, and the page actually has 1401: 1401 - 264 - 384 - 48 is 705. The strip came out of the column the floor exists to keep it out of.

The sidebar commit did not introduce this; it added the first test that reads the column at a floor. Every floor is built with zero slack (384 + 720 + 48 = 1152), so the same squeeze is already in the shipped sidenote idiom — measured on main, a sidenote-only page reads a 705px column at 1152px and does not reach 720 until 1167, which is 1152 plus the bar.

Solution

stateStrip takes the gutter off, the way stateRoom already does four hundred lines below it with the reasoning written beside it: the difference of the scroller's two boxes is the bar, no platform can be asked for it, and it is constant through a slide because both boxes move with the margin together. It is also invariant under the padding stateStrip itself writes — padding is inside both clientWidth and offsetWidth — so the one-writer rule is not in the way. The coarse answer owed it as much as the fine one; this is the one line that says so.

The test read the floor off the window too, which is why it is green on a Mac, where overlay scrollbars make window and page box the same width. It asks the page for the difference now and drives the roomy case at the width the page can afford, and it keeps the invariant that was silently broken: the column holds its measure at the floor itself. Given the room, both strips stand outside a full column; short of it by a bar's width, the veto hands them back — which is the fallback the floor is for.

This is not a new derivation. A prior session found the same line, verified it, and posted it on #33 rather than opening a PR against the queue that was open at the time. It has a failing test behind it now.

Testing

Reproduced at the same assertion and the same numbers before the change, green after. The new assertion is non-vacuous: with the stateStrip line reverted it fails at the floor with 705 == 720 and the message naming it, while the rest of the test still passes — so the fix is what it is holding, not the widths it drives at.

uv run pytest tests --run-nightly on this branch, Linux, in two runs covering the whole suite: 128 passed (test_render_pages, test_render_gate, test_render_export) and 1196 passed, 6 skipped (everything else) — 1324 items, the same count ci collects. pre-commit clean on the changed files.


Automated fix for failed run

body owns the document's scroll, so where the platform draws a classic
scrollbar it comes out of body's padding box while
documentElement.clientWidth still reads the whole window. stateStrip asked
the floor of the window, so the floor was met by a window with a bar's width
less page behind it and the strip came out of the column the floor exists to
keep it out of: a sidenote page at exactly 1152px read at a 705px measure,
and a sidebar and a note at 1416px did the same.

stateRoom, the other geometry reading in the file, already takes that gutter
off and gives the reasoning beside it — the difference of the scroller's two
boxes is the bar, no platform can be asked for it, and it is constant
through a slide because both boxes move with the margin together. The coarse
answer owes it as much as the fine one.

The test read the floor off the window too, which is why it was green on a
Mac, where overlay scrollbars make the two the same width. It asks the page
for the difference now, and holds the column to its measure at the floor
itself: given the room both strips stand outside a full column, and short of
it by a bar's width the veto hands them back.

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

Two observations on the runtime change. The fix itself reads correctly — the veto was measuring a box the page hasn't got, and stateRoom four hundred lines down was already saying so — and the new assertion is the right one to have.

stateStrip now depends on a fact none of its callers announce. Its inputs used to be the window and the panel/tray state, and every one of those has an explicit call site: resize, setPanel, showTray, drawnEdge's set, startPage, and activateVersion. The scrollbar's presence is not on that list. Body gains or loses its bar whenever the document's height crosses the viewport, and replay retiring a slot, an async widget settlement, a late image, or a reader opening a disclosure can all do that with no resize and no chrome gesture behind it. --lf-avail and data-lf-cramped then hold a reading from before the bar existed — the same 15px this fixes, in the same window band, arriving from the other side: at a 1416px window a page that isn't yet scrolling reads 1416 against a 1416 floor and grants both strips, and the first thing that makes it scroll takes the column to 705 with nothing left to restate the veto. stateRoom is immune because syncLayout runs from the observation of body's box — the property its own comment claims for it, "That box is watched (layoutSizes), so the room is restated whenever it changes shape whatever changed it" — and stateStrip can't join that observer under the one-writer rule, since the strip it vetoes is padding on the observed box. Not a blocker, and the change is plainly net positive against the squeeze it removes. But the new comment argues invariance under the padding this function writes, which answers the loop question, and doesn't reach the freshness question the new input opens: either a call from the path that changes the document's height, or a sentence recording the limitation where the next reader of this function will find it.

The gutter now has three spellings. stateRoom reads document.body.offsetWidth - document.body.clientWidth, stateStrip reads pageScroller.offsetWidth - pageScroller.clientWidth, and the test reads documentElement.clientWidth - document.body.clientWidth. The comment's claim — "The same gutter stateRoom takes off, and taken the same way" — is then true by inspection rather than by construction, in a file whose rule is that one representation answers one question. One reading beside pageScroller's other consumers would make it structural, and would take stateRoom off its direct document.body at the same time.

"() => document.documentElement.clientWidth - document.body.clientWidth"
)
at_floor = page.evaluate(reading)
assert at_floor["column"]["width"] == 720, (

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.

The runtime reads the gutter off the scroller's own two boxes; this reads it off the window and body's padding box. Those are the same number only while body carries no margin — which holds here, the panel not being clicked until the block below — but that is a fact about where this block sits in the test rather than about the gutter, and the panel's strip is a body margin. A later case reordered above the .lf-comments click would drive resized a panel's width off with no assertion naming the reason. Matching stateStrip's own reading makes it margin-independent and puts the test on the formula it is holding:

Suggested change
assert at_floor["column"]["width"] == 720, (
"() => document.body.offsetWidth - document.body.clientWidth"

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.

Taken, and one step further in #65: the read is gone rather than respelled. main had meanwhile moved the reading probe's own gutter key onto exactly this expression (a196d93), so the roomy width is driven from at_floor["gutter"] and the separate evaluate is dropped — one bar, read once, in the same terms the veto does its arithmetic in. Your margin-independence point is what makes that the right key to reach for: the window spelling agrees with the other two only while body carries no margin, and the panel's strip below is a body margin.

@leaf-agent

Copy link
Copy Markdown
Collaborator Author

main moved under this branch while it was open, and the commit that moved it touches the same lines. Two more runs of the failure this fixes: 33027787957 at 54c4499 (assert 705 == 720), and 33029023802 at 7f17a60 — which is Account for stable gutter in sidebar test, a maintainer commit reaching for the same tolerance from the test side. It is still red, because the gutter expression it added reads 0 on the platform that has one.

The reserve is scrollbar-gutter: stable on body, the scroller, so it comes out of body's content box: the border box still spans the window and html is overflow: hidden, which leaves documentElement.clientWidth - document.body.getBoundingClientRect().width reading the window against itself. Measured on the CI runner at 1416px: that expression is 0 while body.offsetWidth - body.clientWidth is 15. So the assertion it guards, roomy["column"]["width"] == 720 - roomy["gutter"], is 705 == 720 again with a tolerance term that can never be anything but zero.

The rebase is where this bites: git merge main into this branch is clean — no conflict — and green, and it leaves that reading in the file beside the new bar. A fourth spelling of the gutter, and the dead one, sitting on the assertion the review's third-spelling note was already about. Worth dropping both the gutter key and the 720 - roomy["gutter"] tolerance when you rebase: this fix keeps the column at 720 at the floor, so there is no shortfall left for a tolerance to express.

How this was checked

Reproduced at -n0 on the CI Linux runner (Chromium 151.0.7922.34, classic scrollbars) against 7f17a608:

tests/test_render_pages.py::test_opposite_margin_residents_wait_for_the_room_they_need
E  assert 705 == (720 - 0)
E  ... 'column': {'left': 288, 'right': 993, 'width': 705}, 'gutter': 0

Probed at 1416px on the live page: innerWidth 1416, documentElement.clientWidth 1416, body border box 1416, body.clientWidth 1401, main box 753, --strip-min 1416px, data-lf-cramped unset — the veto's blind spot this PR closes, with the maintainer's gutter reading landing on the window twice.

Merge check: git merge up/main onto fix/ci-33023752384 reported no conflict; the merged tree runs the test green, with both the bar reading and the gutter/720 - roomy["gutter"] pair present.

@max-sixty
max-sixty enabled auto-merge (squash) August 27, 2026 01:41
@max-sixty
max-sixty merged commit e2f579f into main Aug 27, 2026
7 checks passed
@max-sixty
max-sixty deleted the fix/ci-33023752384 branch August 27, 2026 01:55
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

Both observations are answered in #65, against main rather than here — this merged while the verification suite was running.

The three spellings are one reading now: scrollerGutter, exported beside pageScroller in runtime/scrolling.js, because the bar is a fact about the scroller and every reading that starts from the window owes it. stateStrip and stateRoom both call it, so "the same gutter, taken the same way" is true by construction; the test reads the probe's own gutter key rather than a fourth expression.

The freshness question is recorded at the function rather than closed. There is no call site available that doesn't cost more than the gap: the paths that put a bar on the page without a resize are replay, late settlement, and image arrival, and instrumenting each is a coupling per path with no shared occasion behind it — while the one mechanism that would restate it on any of them, layoutSizes, is the thing the one-writer rule forbids this function to join. So the comment now names the input, the three ways it arrives unannounced, why stateRoom is immune and this isn't, and the bound on what's left standing: a bar wide, in the band where the floor is met within a bar, restated by the next resize or chrome gesture.

#65 also carries something neither of us was looking at: main is red on this test. The merge kept 720 - roomy["gutter"] from the test side, which subtracts a bar from a column this fix now keeps whole — assert 720 == (720 - 15) on any platform that draws one.

@max-sixty

Copy link
Copy Markdown
Owner

The merged head still leaves the pre-fix assertion in test_opposite_margin_residents_wait_for_the_room_they_need. After the new resized(page, 1416 + bar, 800), Linux correctly reports column.width == 720 with gutter == 15, but line 2020 still expects 720 - gutter (705). Main CI run 33031638136 and the refreshed PR #61 suite both fail there. The roomy assertion should now require the full 720px column, matching the new at_floor control and #64’s stated invariant.

@leaf-agent

Copy link
Copy Markdown
Collaborator Author

Agreed, and that exact change is #65, opened against main seventeen minutes before your comment — it drops roomy["gutter"] from line 2020 so the roomy read requires the full 720 alongside the new at_floor control, for the reason you give: this fix keeps the column whole at both widths, so there is no shortfall left for a tolerance to express.

It also gives the gutter one reading, scrollerGutter in runtime/scrolling.js, which both stateStrip and stateRoom call, and the test drives 1416 + bar off that same function rather than a third expression that agrees on inspection — so the width it resizes to is the one the veto does its arithmetic in by construction.

#65 is based on 6e655de, the current main head, and merges clean. lint is green; test and review were still running at the poll cap, so I haven't seen it go green end to end. Merging it is what puts main back green on this test.

max-sixty added a commit that referenced this pull request Aug 27, 2026
…x retired (#65)

## Problem

`main` had two independent stale layout assertions:

- The margin-resident test expects a 720px column minus a 15px scrollbar
gutter, although #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 #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.

---------

Co-authored-by: leaf-agent <318509791+leaf-agent@users.noreply.github.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
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