Skip to content
Merged
10 changes: 8 additions & 2 deletions plugins/leaf/skills/leaf/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ rendering, and async settlement;
`runtime/widget-elements.js` owns widget-element construction, labels, gesture
guards, deferred measurement, and control sizing;
`runtime/registry.js` owns vocabulary queries;
`runtime/scrolling.js` owns the document scroller identity;
`runtime/scrolling.js` owns the document scroller identity and the gutter its bar
takes;
`runtime/presentation.js` owns runtime paint and the words it projects;
`runtime/reach.js` owns keyboard access to overflow and the containing block a
scroller owes what it scrolls;
Expand Down Expand Up @@ -1124,7 +1125,12 @@ comment panel and tray panel each occupy their own strip when the viewport can
hold it and cover the page under their respective media query otherwise.
`stateStrip` and `stateRoom` are the geometry readings, and both count every
strip the chrome holds and the gutter the scroller's own bar takes — a window is
the page's box on neither count; CSS owns the body's corresponding layout.
the page's box on neither count; CSS owns the body's corresponding layout. The
gutter has one reading, `scrollerGutter`, beside the scroller it is a fact about.
`stateRoom` is restated by the observation of body's box; `stateStrip` writes
that box's padding, so it is called instead, and the gutter it reads cannot go
stale between calls because the stylesheet that makes body the scroller reserves
the room whether or not a bar is drawn in it.

Both regions fixed to a side of the window are drawn by the reader. `drawnEdge`
is the one implementation: each caller supplies the side its region is held to, a
Expand Down
44 changes: 30 additions & 14 deletions plugins/leaf/skills/leaf/assets/leaf.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ import {
renderSaid,
} from "./runtime/presentation.js";
import { reachScrollers } from "./runtime/reach.js";
import { pageScroller } from "./runtime/scrolling.js";
import { pageScroller, scrollerGutter } from "./runtime/scrolling.js";
import {
matchesWhen,
registry,
Expand Down Expand Up @@ -1718,9 +1718,21 @@ const trayStrip = () =>
// Its own function, and not syncLayout's, because the strip it vetoes is body's own
// padding (theme.css) and syncLayout runs from an observation of that box — CLAUDE.md's
// "The one writer may not write the box the layout is measured from", and the same reason
// the strip the panel takes is a rule in the stylesheet above. Moving it costs nothing,
// because neither fact it turns on is a reading of that box: the window states one and the
// panel the other, and each arrives on an occasion of its own.
// the strip the panel takes is a rule in the stylesheet above.
//
// So it is called and not observed, and it is only as fresh as its callers — which is
// enough, because each fact it turns on either arrives on an occasion of its own or does
// not move at all. The window states the cap on a resize, and the panel its strip on the
// gesture that moves it. The scroller's gutter is the one with no occasion to arrive on:
// body gains or loses its bar as the document's height crosses the viewport, and replay
// retiring a slot, a widget settling late, or an image arriving can each do that with no
// resize and no chrome gesture behind it. What answers that is the stylesheet rather than
// a call from every such path — body is given scrollbar-gutter: stable in the same rule
// that makes it the scroller (chrome-style.js), so the room is reserved whether or not a
// bar is drawn in it and the difference between the two boxes holds still for the page's
// life. Joining layoutSizes would be the fix if it did not, and it is the one the rule
// above forbids: the strip this vetoes is padding on the observed box, and stateRoom can
// be observed only because it writes nothing that box is measured from.
//
// The strip is stated rather than measured off body, whose clientWidth is the box itself
// and would be the natural reading. The margin transitions, so a measurement taken during
Expand All @@ -1736,16 +1748,18 @@ const trayStrip = () =>
// scroller's own bar and this can, which is the whole of what the runtime adds; a page with
// no runtime behind it falls back to the viewport in each rule that reads it.
function stateStrip() {
// The same gutter stateRoom takes off, and taken the same way, for the reason it gives
// there: body is the document's scroller, so a classic bar comes out of the room this
// The scroller's gutter, which stateRoom takes off for the same reason and by the same
// reading: body is the document's scroller, so a classic bar comes out of the room this
// page has while the window says nothing about it. The coarse answer owes it as much as
// the fine one. Without it 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. Overlay scrollbars are 0 here, which is why a Mac never saw it.
const gutter = pageScroller.offsetWidth - pageScroller.clientWidth;
// 1416px did the same.
const avail =
document.documentElement.clientWidth - gutter - panelStrip() - trayStrip();
document.documentElement.clientWidth -
scrollerGutter() -
panelStrip() -
trayStrip();
document.body.toggleAttribute("data-lf-cramped", avail < stripMin());
document.documentElement.style.setProperty("--lf-avail", avail + "px");
}
Expand Down Expand Up @@ -1854,14 +1868,16 @@ function stateRoom() {
const body = getComputedStyle(document.body);
const column = getComputedStyle(main);
// The gutter body reserves for its own scrollbar, which the window does not know about
// and the box in front of us has already given up. Read as the difference between the
// two boxes rather than asked of the platform, which has no way to be asked; it is a
// constant through a slide, both boxes moving with the margin together.
const gutter = document.body.offsetWidth - document.body.clientWidth;
// and the box in front of us has already given up. One reading (scrolling.js), because
// the veto above owes the same number and a second spelling of it here would be true by
// inspection rather than by construction.
const room =
Math.min(
document.body.clientWidth,
document.documentElement.clientWidth - panelStrip() - trayStrip() - gutter,
document.documentElement.clientWidth -
panelStrip() -
trayStrip() -
scrollerGutter(),
) -
parseFloat(body.paddingLeft) -
parseFloat(body.paddingRight) -
Expand Down
13 changes: 13 additions & 0 deletions plugins/leaf/skills/leaf/assets/runtime/scrolling.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,16 @@
// SortableJS walks up from the dragged card and, on reaching body, hands back
// document.scrollingElement, so lf-board passes this in rather than letting it guess.
export const pageScroller = document.body;
// The room the scroller's own bar takes out of the page, which is a fact about the
// scroller and so belongs beside it: every geometry reading that starts from the window
// owes it, and each of them differs in what the gutter is coming off rather than in how it
// is found. The difference between the scroller's two boxes is the only way to ask — no
// platform states the width, and the window states nothing about a bar drawn inside it.
// Constant through a panel's slide, both boxes moving with the margin together, and
// invariant under the strip veto's own padding, which is inside both. Constant over the
// page's life as well, and that is the stylesheet's doing rather than this line's: the
// rule that makes body the scroller gives it scrollbar-gutter: stable, so the room is
// reserved whether or not a bar is drawn in it and a document that stops overflowing
// keeps it. A caller reading this off a stale occasion is therefore not a way to be
// wrong. Overlay scrollbars are 0, which is why one platform never sees any of this.
export const scrollerGutter = () => pageScroller.offsetWidth - pageScroller.clientWidth;
43 changes: 32 additions & 11 deletions tests/test_render_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1935,9 +1935,10 @@ def test_opposite_margin_residents_wait_for_the_room_they_need(

At the ordinary 1152px floor, the sidenote keeps its established right margin and
the sidebar remains in flow. Giving both their full strips there leaves only 504px
for prose. At the combined floor, both may stand outside a full-width column once
the window also supplies a live platform's stable scrollbar gutter. A script-free
copy has to make the same choice from its viewport alone.
for prose. At the combined floor of the page's own box, both may stand outside a
full ordinary column; a window short of that floor by a live platform's stable
scrollbar gutter has the veto hand the strips back. A script-free copy has to make
the same choice from its viewport alone.

The second sidebar is the other composition case: only the first direct child of
main may take the sticky page-level slot, so an accidental second one remains in
Expand Down Expand Up @@ -1994,21 +1995,41 @@ def test_opposite_margin_residents_wait_for_the_room_they_need(

# The floor is a fact about the page's box, and the window is not that box wherever
# the platform draws a classic scrollbar: body is the document's scroller, so its bar
# comes out of the room the strips and the column divide between them. So the page is
# asked for the difference rather than a number being written that is right on one
# platform. Either way the column keeps its measure at the floor itself, which is what
# the strip is floored to protect: given the room, both strips stand outside a full
# column; short of it by the width of a bar, the veto hands them back.
# comes out of the room the strips and the column divide between them. The column
# keeps its full measure on both sides of that, which is what the strip is floored to
# protect: given the room, both strips stand outside a full column, and short of it by
# a bar's width the veto hands them back. So neither read subtracts a bar from what it
# expects — the widths the page is driven at are where the bar is accounted for, and 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.
resized(page, 1416, 800)
bar = page.evaluate(
"() => document.documentElement.clientWidth - document.body.clientWidth"
)
at_floor = page.evaluate(reading)
assert at_floor["column"]["width"] == 720, (
"the strip came out of the column at the combined floor, which is the one width "
f"the floor exists to keep it out of: {at_floor}"
)

# The runtime's own reading of the gutter, asked of the module that owns it, so the
# width this drives at is the one the veto is doing its arithmetic in by construction
# rather than by a second spelling that agrees on inspection. Its own evaluate and not
# a key on `reading`, which the script-free copy below shares and which has no module
# to ask; the window against body's padding box, the other candidate, would agree only
# while body carries no margin, and the panel's strip below is a body margin.
bar = page.evaluate(
"() => import('/runtime/scrolling.js').then(m => m.scrollerGutter())"
)
# Driving the page at a width the helper chose and then testing the veto that spends
# the same helper leaves one thing the reads below cannot see: an error in the helper
# itself, which lands on both sides and cancels. A gutter overread as 30 puts the page
# at 1446 and has stateStrip take 30 off it, so the floor is met on the nose and every
# measure here passes while the band from 1431 up is cramped for nothing. So the two
# spellings are held to each other first, at the one viewport both are read at, and
# `reading` keeps the key: it is a claim about what the gutter is rather than a second
# copy nothing checks, and it is what the failure dumps report a short measure against.
assert bar == at_floor["gutter"], (
"the module's gutter and the page's own reading of it have come apart, which "
f"would leave the widths below chosen and judged by the same error: {at_floor}"
)
resized(page, 1416 + bar, 800)
roomy = page.evaluate(reading)
assert [(s["float"], s["position"]) for s in roomy["sidebars"]] == [
Expand Down
Loading