Skip to content

Withhold an option's box-shadow ease until the page is presented - #122

Merged
max-sixty merged 2 commits into
mainfrom
fix/ci-33295489155
Aug 30, 2026
Merged

Withhold an option's box-shadow ease until the page is presented#122
max-sixty merged 2 commits into
mainfrom
fix/ci-33295489155

Conversation

@leaf-agent

Copy link
Copy Markdown
Collaborator

Problem

tests/test_render_gate.py::test_a_reader_arrives_at_what_they_left_rather_than_watching_it_arrive is 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; at main 1dd6976 it failed 5 in 17.

The extra motion is CSSTransition box-shadow 120ms on the page's lf-options, and it is not replay writing [chosen] — the keyframes are boxShadow: "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, before data-lf-upgraded and long before data-lf-presented, on whichever arrangement's load happens to hit it.

The cause is the stylesheet's own arrival. theme.css is 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 initial none. The sheet then lands and states box-shadow: var(--lf-ring), var(--lf-lift) in the same breath as the transition: box-shadow .12s ease two 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" for body and for .lf-ui, and its own comment names this as the trade it leaves to the suite — "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". 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 while main was still visibility: hidden behind 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 at main.

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.py with --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.css passes.

How the diagnosis was taken

A transitionrun listener installed through page.add_init_script before each arrival, recording performance.now(), the readiness stamps, the computed --lf-ring/--lf-lift, and getAnimations()[0].effect.getKeyframes():

{'t': 117, 'tag': 'lf-option', 'id': 'wait-day', 'prop': 'box-shadow',
 'presented': False, 'upgraded': False,
 'shadow': 'rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px',
 'ring': '0 0 #0000', 'lift': '0 0 #0000', 'joined': '1',
 'settled': False, 'chosen': False,
 'ready': 'interactive', 'sheets': 1, 'theme': '16-86', 'nav': '4/0'}

theme is theme.css's startTime-responseEnd, nav the navigation's responseEnd/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-option itself, 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 at none, 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

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

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.

@leaf-agent

Copy link
Copy Markdown
Collaborator Author

Both points hold, and the first one has an instance. I put a transitionrun listener in through an init script and loaded each of the eleven shipped examples, first visit and every declared arrangement, recording the property, the element, and whether data-lf-presented was set yet.

One of the three fires: lf-toc .lf-toc-window's height (theme.css:1254), on release-notes, before the presentation stamp — eight of nine first-visit loads and six of seven returns. It fires the same way on both sides of the comparison, so extra is empty and the gate passes over it every run. That is the case the review describes, standing in the tree today rather than hypothetically. Its visibility is the same as the box-shadow's — the toc sits inside main, so the motion plays behind the presentation hold — but it is the widget writing --lf-toc-window-start during upgrade, which is a value arriving and not a gesture. The other two reported nothing on any page: the label's color/opacity (:1278) needs a hover, and lf-old, lf-new's padding (assets/theme.css:1301) never ran, on the suggestion pages or elsewhere.

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 packages.md, or a property the layer publishes for the arrival window — and it should land with the toc window's own withhold hanging off it, so the incantation stops being copied a third time. Spelling html:has(script[type="module"][src="/leaf.js"]) body:not([data-lf-presented]) out again in this PR would have made it three.

How it was measured

A temporary test using this suite's own serve and arrangement helpers, with the listener installed through add_init_script so it is in place before the first style recalc:

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 BOTH_STAMPS and then dwells 600ms, so a motion starting after presentation is recorded too rather than being read as absence. A representative release-notes return:

[{"t": 897, "cls": "lf-toc-window", "prop": "height", "presented": false, "upgraded": false},
 {"t": 924, "cls": "lf-toc-window", "prop": "height", "presented": false, "upgraded": false},
 {"t": 984, "cls": "lf-toc-window", "prop": "height", "presented": true,  "upgraded": true},
 {"t": 1020, "cls": "lf-toc-window", "prop": "height", "presented": true, "upgraded": true}]

The presented: true entries are the window tracking the reader's scroll, which is the widget doing its job; the false ones are the arrival.

Gallery carries an lf-toc too and reported nothing, first visit or return, on any arrangement — not chased down, so treat the finding as "release-notes reproduces it" rather than "every toc page does".

tests/test_render_gate.py and tests/test_render_options.py after the comment edit: 115 passed, 4 failed — the same four the review named, which #121 owns. The probe file was removed; only the comment is in the commit.

@max-sixty
max-sixty merged commit 84aef1d into main Aug 30, 2026
4 checks passed
@max-sixty
max-sixty deleted the fix/ci-33295489155 branch August 30, 2026 18:10
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