Take the scroller's gutter off the strip floor, not out of the column - #64
Conversation
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
left a comment
There was a problem hiding this comment.
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, ( |
There was a problem hiding this comment.
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:
| assert at_floor["column"]["width"] == 720, ( | |
| "() => document.body.offsetWidth - document.body.clientWidth" |
There was a problem hiding this comment.
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.
|
The reserve is The rebase is where this bites: How this was checkedReproduced at Probed at 1416px on the live page: Merge check: |
|
Both observations are answered in #65, against The three spellings are one reading now: 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, #65 also carries something neither of us was looking at: |
|
The merged head still leaves the pre-fix assertion in |
|
Agreed, and that exact change is #65, opened against It also gives the gutter one reading, #65 is based on |
…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>
…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>
Problem
ciis red onmain. The run this was dispatched for (b270391) failed ontest_the_aim_reads_the_pointer_where_the_press_is_dispatched_from, which 842e147 has since fixed — that commit's run is green. Butmainis 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.clientWidthstill reads the whole window — andstateStripasks 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 - 48is 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 onmain, a sidenote-only page reads a 705px column at 1152px and does not reach 720 until 1167, which is 1152 plus the bar.Solution
stateStriptakes the gutter off, the waystateRoomalready 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 paddingstateStripitself writes — padding is inside bothclientWidthandoffsetWidth— 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
stateStripline reverted it fails at the floor with705 == 720and 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-nightlyon 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 countcicollects.pre-commitclean on the changed files.Automated fix for failed run