Withhold an option's box-shadow ease until the page is presented - #122
Conversation
The default package theme grants `lf-option` a .12s box-shadow ease, and the stylesheet that grants it is also the one that first gives the property a value. theme.css is render-blocking but not style-blocking, so on a load where a style recalc falls inside its own fetch the parser has already styled the options with box-shadow's initial `none`; the sheet then lands, states `var(--lf-ring), var(--lf-lift)` — two transparent channels, no pixel different — and the ease it granted in the same breath plays on every option of a page nobody has touched. The returning-reader gate reports exactly that, on whichever arrangement happens to load slowly enough, and reports nothing on the loads where the recalc misses the window. Withhold the transition behind the presentation stamp, spelled as theme.css's own arrival guard is, so a page with no runtime behind it keeps the ease it always had.
leaf-agent
left a comment
There was a problem hiding this comment.
The fix reads correctly and the claimed path reproduces here: the named gate test went 13 for 13 at -n0 on the merge ref, and the four failures I hit running test_render_gate.py and test_render_options.py in full reproduce with theme.css reverted to 01ec4f4 — the gutter findings #121 owns, not anything this rule touches. The specificity is comfortable (0,3,4 against the granting rule's 0,1,2, once :where() zeroes its guard), and the rule sits in the package rather than in assets/theme.css, which keeps lf-option out of the layer.
One thing to surface rather than to fix here: the comment leans on a check that is narrower than it sounds. assets/theme.css's guard leaves the trade to "the render suite's returning-reader coverage" and this comment restates it, but that test compares a return against a first visit — extra = {k: v for k, v in arrive().items() if k not in first_visit} — and its own docstring says "a return may move less than a first visit; it may not move more". It reports a transition only where the two loads differ. This box-shadow surfaced because it fired intermittently; one that fires the same way on every arrival passes it silently. The theme's three remaining transitions are in that position — lf-toc's top/height, whose --lf-toc-window-start the widget writes during upgrade (lf-toc.js), its label's color/opacity, and lf-old, lf-new { transition: padding .22s ease; } in the layer, whose trigger lf-suggestion[data-lf-state] replay paints. I have not confirmed any of them fires. The point is that a green suite would not tell us either way, so "the check that finds one is the suite's" is doing more work in that comment than the check can carry.
The other half is that the incantation now stands in two places with nothing naming it. data-lf-presented appears nowhere under skills/leaf/references/, and packages.md describes theme.css as "rules appended to the cascade" and stops, so a package declaring a transition on its own element walks into this exact flake with only assets/theme.css to copy from. Separate change, but the seam is either a line in packages.md or a property the layer publishes for the arrival window, so a package can ask whether the page is still arriving without knowing how the layer spells its own script link.
|
Both points hold, and the first one has an instance. I put a One of the three fires: What that changes here is the comment, which is pushed as cb1234f: it now says what the gate reports and what it does not, rather than resting on it. The claim it was leaning on is assets/theme.css:439 — same overclaim, one layer down, and worth the same sentence when something else touches it. I am not opening the second change in this run. The bot has five PRs open, so this is recorded rather than filed: the seam is one named thing — a line in How it was measuredA temporary test using this suite's own document.addEventListener('transitionrun', (e) => {
window.__lfMoves.push({
t: Math.round(performance.now()),
tag: e.target.tagName.toLowerCase(),
cls: String(e.target.className),
prop: e.propertyName,
presented: document.body.dataset.lfPresented === '1',
upgraded: document.body.dataset.lfUpgraded === '1',
});
}, true);Each load waits for The Gallery carries an
|
Problem
tests/test_render_gate.py::test_a_reader_arrives_at_what_they_left_rather_than_watching_it_arriveis one of the thirteen failures in the run this was dispatched for, and the one #121 leaves out. It is intermittent and it predates the scroller commit: measured at d226ee2a, 26feec8's parent, it failed 3 runs in 18 at-n0; atmain1dd6976it failed 5 in 17.The extra motion is
CSSTransition box-shadow 120mson the page'slf-options, and it is not replay writing[chosen]— the keyframes areboxShadow: "none"→"rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px". Both channels are transparent, so the transition eases from nothing to nothing and no pixel differs at either end. It fires at ~105–155ms, beforedata-lf-upgradedand long beforedata-lf-presented, on whichever arrangement's load happens to hit it.The cause is the stylesheet's own arrival.
theme.cssis render-blocking but not style-blocking, so on a load where a style recalc falls inside its fetch (measured at 86–135ms for the composed 230KB sheet) the parser has already styled the options with box-shadow's initialnone. The sheet then lands and statesbox-shadow: var(--lf-ring), var(--lf-lift)in the same breath as thetransition: box-shadow .12s easetwo rules below it, and the value change plays. Whether a recalc falls in that window is what makes the failure intermittent, and it is why the arrangement named in the message differs every time.Solution
Withhold the transition until the page says it is presented.
theme.css's arrival guard already says "arriving is not a gesture" forbodyand for.lf-ui, and its own comment names this as the trade it leaves to the suite — "transitiondoes 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". This is the finding, answered in the package that declares the transition rather than by widening the layer's guard into the universal selector that comment measured and rejected.Spelled as that guard is —
html:has(script[type="module"][src="/leaf.js"]) body:not([data-lf-presented])— so a page with no runtime behind it, which never stamps and never held its options back, keeps the ease it always had. A copy has no script link and is out by the same clause. Nothing visible changes: the suppressed transition ran between two transparent shadows, and it ran whilemainwas stillvisibility: hiddenbehind the presentation gate.Testing
The named test, 15 consecutive runs at
-n0: all green, against 3-in-18 at the commit before the scroller moved and 5-in-17 atmain.uv run pytest tests— 721 passed, 6 skipped.test_render_options.py,test_render_gate.py,test_render_widgets.py,test_render_pages.py,test_render_margin.pywith--run-nightly— 237 passed, and the only failures are the seven gutter findings #121 fixes, unchanged in shape.pre-commit run --files skills/leaf/packages/default/theme.csspasses.How the diagnosis was taken
A
transitionrunlistener installed throughpage.add_init_scriptbefore each arrival, recordingperformance.now(), the readiness stamps, the computed--lf-ring/--lf-lift, andgetAnimations()[0].effect.getKeyframes():themeis theme.css'sstartTime-responseEnd,navthe navigation'sresponseEnd/domContentLoadedEventStart: the HTML was complete at 4ms and the sheet landed at 86ms, with the transition starting at 117ms. On the runs where the test passes no such entry exists at all.Hoisting the two shadow channels onto
lf-optionitself, so the:has(> lf-option > strong)card rule could not be what flips the value mid-parse, was tried first and changed nothing — the keyframes still started atnone, which is what pointed at the sheet rather than at a selector inside it.Independent of #121 and based on
main, not on its branch: a separate cause in a separate layer, revertable on its own. Together the two take the failed run to green.Automated fix for failed run