diff --git a/skills/leaf/CLAUDE.md b/skills/leaf/CLAUDE.md index 0496d3b3f..562ac9af2 100644 --- a/skills/leaf/CLAUDE.md +++ b/skills/leaf/CLAUDE.md @@ -184,9 +184,12 @@ Layout follows the same ownership rule. CSS owns the document shell: `body` is the `lf-shell` container, `main` composes margin claims, and container queries choose their postures from the room actually left by panels and trays. `syncLayout` measures only chrome whose placement or reservation depends on -rendered chrome, and writes only chrome boxes. A `ResizeObserver` callback must -not resize the box it observes, directly or through a class or attribute that -changes that box. +rendered chrome, and writes only chrome boxes. It hears the shell's inline size +without deriving a posture from it: `layoutSizes` watches `document.body`'s +content-box width so a float placed in the margin is re-placed while a panel's +eased margin is still narrowing the page, and reads nothing else off that box. A +`ResizeObserver` callback must not resize the box it observes, directly or +through a class or attribute that changes that box. ## Startup and presentation @@ -1245,8 +1248,22 @@ status-like item may yield its own width so controls to its right remain fixed. `syncLayout` derives only floating chrome placement and reservations from current chrome boxes. CSS owns the document shell: `body` is the named `lf-shell` inline-size -container, `main` composes its left and right claims, and container queries grant or -withdraw margin postures. No JavaScript measures that shell or mirrors a cramped state. +container, `main` composes its left and right claims, and queries grant or withdraw +margin postures. JavaScript may hear that shell's inline size as a signal to re-run +`syncLayout` — `layoutSizes` watches `document.body`'s content-box width, because a +panel's eased margin goes on narrowing the box a float stands in after `setPanel` has +returned and one synchronous pass at the press reads only the wide box — but it derives +no posture from it and mirrors no cramped state. + +Which question a floor asks belongs to the posture it grants. A floor asking how much +room is left beside a panel or tray is a container query on `lf-shell`: 1152 and 1416 +for the sidebar and sidenote strips, 1208 and 1472 for the thread's beside posture. The +living margin's 900 asks the window instead, because it is half of a pair — +`@media screen and (max-width: 899px)` stops drawing the margin at all, and a marker's +presence is not something a container can be asked about without the answer depending on +the strip the marker is asking for. Both halves of such a pair ask the same medium, or a +panel narrowing `body` under the floor withdraws the strip while the window keeps the +markers on screen with nothing reserved for them. The browser's root is the document scrollport. `pageScroller` is the shared answer for reading position and paging; native fragments, history restoration, wheel/touch input, diff --git a/skills/leaf/assets/runtime/chrome-layout.js b/skills/leaf/assets/runtime/chrome-layout.js index 91a1ba513..eed7f07bc 100644 --- a/skills/leaf/assets/runtime/chrome-layout.js +++ b/skills/leaf/assets/runtime/chrome-layout.js @@ -213,8 +213,16 @@ export function createChromeLayout({ document.body.toggleAttribute("data-lf-panel", open); toggleBtn.setAttribute("aria-expanded", String(open)); if (open) { - renderPanel(); + // The layer before what goes in it. The panel is a dialog, and a dialog nobody has + // shown yet is display:none, so anything rendered into it measures zero — and + // renderPanel is where the anchor pass runs for the threads it draws. A mark hangs + // on the boxes its element shows through (shownParts), so a widget an agent sent in + // a reply resolved to an element with no box, took no mark, and left the thread + // still open in the panel pointing at nothing on either side. Focus is asked for in + // the same call because its target is threadsBox, which the render fills rather + // than replaces. syncPanelLayer({ focus: true }); + renderPanel(); syncGeneral(); // a restored draft has to reach the Send button's disabled state } else if (panel.open) panel.close(); syncLayout(); @@ -250,7 +258,36 @@ export function createChromeLayout({ syncLayout(); }); }; - const layoutSizes = new ResizeObserver(scheduleLayout); + // Body's own inline size is the first of them, because the strip the page yields to the + // panel is an eased margin: setPanel writes the attribute and returns, and the box the + // floats are placed in goes on narrowing for another fifth of a second. One synchronous + // syncLayout at the press reads the wide box, so a composer standing in a wide window's + // margin kept a place the narrowed page no longer has — an absolute child past body's + // client box, which is sideways-scrollable overflow, with the box standing on the panel + // that displaced it. Watched rather than heard through `transitionend` because an + // interrupted slide still reports its last frame, and a strip a margin resident claims + // moves this width with no transition to end. + // + // Filtered to the content box's width: body's block size is the document's content + // height, so hearing every body resize would feed ordinary page growth back into a + // writer that reserves flow content. The width still hears both an outer strip and a + // padding rail. Writes land in the following animation frame, outside ResizeObserver + // delivery, so a reservation changing another watched chrome box cannot create an + // undelivered-notification loop. + let bodyContentWidth = 0; + const layoutSizes = new ResizeObserver((entries) => { + let shellChanged = false; + for (const { contentRect, target } of entries) { + if (target !== document.body) { + shellChanged = true; + continue; + } + if (contentRect.width !== bodyContentWidth) shellChanged = true; + bodyContentWidth = contentRect.width; + } + if (shellChanged) scheduleLayout(); + }); + layoutSizes.observe(document.body); layoutSizes.observe(panelFoot); layoutSizes.observe(keylineEl); // The composer grows under typing (field-sizing), and a box placed above its passage diff --git a/skills/leaf/assets/runtime/living-margin.js b/skills/leaf/assets/runtime/living-margin.js index b8b5c8e9b..eb4d15906 100644 --- a/skills/leaf/assets/runtime/living-margin.js +++ b/skills/leaf/assets/runtime/living-margin.js @@ -1093,7 +1093,17 @@ export function createLivingMargin(dependencies) { if (!preview.matches(":popover-open") && !previewShowing) { previewShowing = true; try { - preview.showPopover({ source: button }); + // Shown without naming the marker as its source, though the marker is what opened + // it. An invoker relationship puts the card in the sequential focus order directly + // after the control that invoked it, and this card is not invoked: it opens on the + // marker merely taking focus, so a reader walking the margin was made to Tab + // through a close button and every action of a preview they had not asked for — + // three stops between one suggestion's controls and the next, with the card's + // contents changing under them as they went. The card stays reachable where it + // stands in the layer, which is where it was reachable before. Its position is + // the anchor-name written above rather than the implicit anchor a source would + // give, so the placement asks nothing of this. + preview.showPopover(); } catch (error) { // Chromium also refuses a second popover operation in the same rendering turn, // even when it belongs to another surface. Keep the requested marker current and diff --git a/skills/leaf/assets/theme.css b/skills/leaf/assets/theme.css index 0fd7a8ed4..8a9386495 100644 --- a/skills/leaf/assets/theme.css +++ b/skills/leaf/assets/theme.css @@ -1405,8 +1405,16 @@ lf-suggestion:not([data-lf-state]) > :is(lf-old, lf-new) > .lf-quiet { font: 600 10px/14px var(--sans); text-align: center; } .lf-margin-target { outline: var(--here-ring); --lf-here-ring: margin-target; outline-offset: calc(-1 * var(--here-ring-w)); } -@media screen { - @container lf-shell (min-width: 900px) { +/* The one floor that asks the window rather than the page's own box, because it is the + * half of a pair: below it the rule further down stops drawing the living margin at all, + * and that rule is a media query because a marker's presence is not a thing a container + * can be asked about without the answer depending on the strip the marker is asking for. + * Two readings of one 900 is the page showing markers it has reserved no room for — the + * panel narrowing body under the floor took the strip away while the window kept the + * markers, and every row docked beside an empty rail. The floors below stay container + * queries: those ask how much room is left beside the panel, which is the question the + * runtime's old cramped veto asked, and the answer moves with the panel by design. */ +@media screen and (min-width: 900px) { body:has(:where(.lf-living-margin:not([hidden]) .lf-margin-marker:not([hidden]))) > main { /* The 30px marker hangs 22px off the column and its complete host keeps the shared focus-ring room at the outer edge. Claim all three, or opening a side @@ -1421,7 +1429,6 @@ lf-suggestion:not([data-lf-state]) > :is(lf-old, lf-new) > .lf-quiet { main:has(.lf-margin-item[data-lf-claims-rail]) { --claim-rail: var(--rail, 54px); } - } } @media screen { /* 1208px leaves the document's 640px evidence floor beside the 520px thread