Skip to content

fix: stop the contents sidebar flashing open on page load - #123

Merged
mmcky merged 3 commits into
mainfrom
fix/sidebar-flash-on-load
Aug 20, 2026
Merged

fix: stop the contents sidebar flashing open on page load#123
mmcky merged 3 commits into
mainfrom
fix/sidebar-flash-on-load

Conversation

@DrDrij

@DrDrij DrDrij commented Aug 18, 2026

Copy link
Copy Markdown
Member

The contents sidebar flashes open for a moment on page load, then slides shut. Reported against the Netlify preview of QuantEcon/lecture-python-programming#363.

Cause

Not a state bug — the server-rendered markup is correct and already carries -translate-x-full.

On a static build every navigation is a full document load, and the first paint can happen before app.css applies. This is the same FOUC as quantecon-theme-src#66, which the inlined critical CSS already addresses for the font and the content grid — but that critical CSS never covered the sidebar.

So the sequence is:

  1. First paint happens with no app.css. -translate-x-full, fixed and w-[250px] all mean nothing, so the panel lays out as a plain in-flow block, full width and fully visible.
  2. app.css arrives and transform resolves from none to translateX(-100%).
  3. Because transition-all duration-300 had been on the element since that first paint, the browser animates the correction.

The menu is never actually opened. It was simply never hidden, and then took 300ms to put itself away — which is why a one-frame glitch reads as a deliberate animation.

Measured

Two builds of this repo, served with myst start on the visual fixture. app.css is served empty so the first paint happens unstyled — the field condition — then the real stylesheet is applied and the panel traced every frame.

BEFORE (main)
  first paint : x = 0px, width = 1280px   ON SCREEN
  on css apply: 15 properties animate (background-color, border-*, padding-*, tab-size, transform)
                x: 0 -> -2 -> -8 -> -21 -> -44 -> -77 -> -110 -> -148 -> -174 -> -194 ...
                still visible for 214ms

AFTER (this branch)
  first paint : x = -250px, width = 250px   off screen
  on css apply: x holds at -250 across all samples — snapped, no motion
                still visible for 0ms

Note that simply delaying app.css does not reproduce this: Chrome then blocks rendering and never paints, which is the healthy path. The flash needs a paint that beats the stylesheet.

Fix

  • app/root.tsx — a .qe-contents-sidebar rule in the existing critical CSS parks the panel off-screen on the first paint. It deliberately sets no width: translateX(-100%) resolves against the element's own border box, so the right edge lands at left + W − W = 0 for any width. That holds before app.css arrives and after, even though the resolved width differs (350/250/350 across the base/lg/2xl bands). position:fixed is kept so the panel does not push the article down while it waits.
  • app/components/ContentsSidebar.tsx — the transition classes are withheld until after mount. This is insurance rather than the primary guard: because both states are a −100% translate there is nothing for a transition to interpolate, so removing it does not by itself reintroduce the flash (measured at 800px and 1280px — zero on-screen frames either way). It is kept because the transition is only ever wanted in response to a click, which keeps the component correct if the critical rule is later changed.

transition-all is also narrowed to transition-transform. The former animated 15 properties when only the slide was ever wanted; the latter is compositor-only.

Opening and closing the menu is unaffected — it still animates normally once mounted (-250 -> 0 -> -250, animating transform alone).

A trap worth recording

The critical CSS uses :where(), so it carries zero specificity. That means every property set there must also be declared by the real stylesheet, or it can never be overridden. Hiding the panel with visibility: hidden would have pinned it shut permanently, since no Tailwind class sets visibility. Hence the transform. This constraint is now documented in the block comment.

Tests

The existing WebKit FOUC guard covers this, since the cause and the guard are the same. It now also asserts the panel starts off-screen — and its control case asserts the flash does reproduce when the inline rule is removed, so the guard cannot silently rot.

sidebarOnScreen returns null rather than false when the element is missing. false is the value that passes the guard, so a renamed hook would slip past the main test and surface as a confusing control failure instead; both tests now assert presence with a message naming the cause.

Review follow-ups

Applied from @mmcky's review:

  • The original sync-list comment claimed the rule's width had to match the Tailwind classes and that the resolved transform was identical either side of app.css. Neither held — widths are 350/250/350, and the used transform moved −250px → −350px. The rule was still correct, for the better reason now documented, so width and height are dropped.
  • Softened the "two guards, either alone leaves an artefact" framing, which overstated the mounted gate.
  • Fixed the sidebarOnScreen fallback described above.
  • Added the missing CHANGELOG.md entry.

One thing worth filing separately: the intended 350/250/350 only holds because Tailwind emits .w-[350px] just after .w-[250px] in the base layer. Nothing pins that order — if it flips, base and 2xl silently become 250px and no test notices. The element carries duplicate unprefixed w-[350px] and w-[250px], which is the root of it.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-20 02:46 UTC

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  16 passed
flaky  1 flaky
skipped  3 skipped

Details

stats  20 tests across 1 suite
duration  1 minute, 10 seconds
commit  f1659ff

Flaky tests

desktop-chrome › theme.spec.ts › QuantEcon theme — visual regression › sidebar-open

Skipped tests

mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle-absent-without-thebe

@mmcky

mmcky commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Nice piece of debugging — the diagnosis is right and the primary guard demonstrably works. I built each variant and traced the panel frame-by-frame in WebKit (serve app.css empty so the first paint is unstyled, then apply the real sheet):

build first paint after app.css applies
main x=0, width=1280 — fully visible 14 frames on screen, ~275 ms, sliding shut
this PR x=−250, right=0 0 frames on screen
critical CSS only, no mounted gate x=−250, right=0 0 frames on screen

Also green: tsc --noEmit, webkit-fouc, and desktop-chrome 8/8 + mobile-chrome 7/7 against the committed -darwin baselines — no snapshot refresh needed. I re-checked the :where() invariant for all five new properties; transform was the one that could have broken it, and both states are covered (.-translate-x-full sits in a grouped transform rule at class specificity, so it beats :where()).

One thing I'd change before merge — the new sync-list comment in app/root.tsx:

width/height/position must match its Tailwind classes so the resolved transform is identical before and after app.css lands

Neither half quite holds. Resolved widths are 350 / 250 / 350 across the three bands (base w-[350px] wins — it lands after w-[250px] in the compiled base layer), so the rule's width:250px is off at two of three; and the used transform goes −250px → −350px, not "identical". It really is harmless, but for a reason the comment misses and which is the more useful thing to record: translateX(-100%) resolves against the element's own border box, so the right edge sits at left + W − W = 0 for any W. Either drop width from the rule, or keep it and say that instead. (The element also carries duplicate unprefixed w-[350px] and w-[250px] — pre-existing, but deduping would let the comment be true.)

A few smaller notes, take or leave:

  • The mounted gate doesn't look load-bearing: guard 1 alone gave the same 0 on-screen frames (row 3), for the same percentage-transform reason. Very happy to keep it as insurance — I'd just soften "either alone still leaves an artefact". It also has no coverage; I deleted mounted &&, rebuilt, and the suite stayed green.
  • sidebar ? ...right > 0 : false returns the passing value when the element is absent. I renamed the class in the component only: the main test passed and the control failed with Expected: true, Received: false. So the suite does catch it, just in the wrong test with a message that never mentions the missing hook. Returning null and asserting presence would point straight at it.
  • ## [Unreleased] in CHANGELOG.md is still empty — CONTRIBUTING asks for an entry per change.

Two things I hit while reviewing are pre-existing and out of scope here, so I filed them separately: #126 (React hydration failing on every load, which also makes the FOUC control test racy) and #127 (the sidebar toggle icons animate the same first-paint correction).

@mmcky

mmcky commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@DrDrij here is a review pass. Can you let me know if this is helpful or not? Thanks.

DrDrij added a commit that referenced this pull request Aug 19, 2026
The sync-list comment was wrong on both halves. Resolved widths are
350/250/350 across the base/lg/2xl bands, not 250 — `.w-[350px]` lands
after `.w-[250px]` in the compiled base layer — so the rule's width was
off at two of three, and the used transform went -250px to -350px rather
than staying identical.

The rule was nonetheless correct, for a better reason: `translateX(-100%)`
resolves against the element's own border box, so the right edge lands at
`left + W - W` = 0 for any width. `width` and `height` were doing no work
and are dropped; `position:fixed` stays so the panel does not push the
article down while it waits. Verified off-screen with `right = 0` at all
three bands, with no horizontal overflow now that the box is shrink-to-fit.

That same property means the `mounted` gate is not load-bearing: with both
states at -100% there is nothing for a transition to interpolate. Measured
with the gate removed at 800px and 1280px — zero on-screen frames either
way. Reworded from "either alone leaves an artefact" to what it is, insurance
that keeps the component correct if the critical rule is later changed.

Also:
- `sidebarOnScreen` returns null rather than false when the element is
  absent. false is the value that passes the guard, so a renamed hook slipped
  past the main test and surfaced as a confusing control failure; both tests
  now assert presence with a message naming the cause.
- Adds the missing CHANGELOG entry.

Review by @mmcky on #123.
@DrDrij

DrDrij commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

@DrDrij here is a review pass. Can you let me know if this is helpful or not? Thanks.

@mmcky Very helpful. Adjustments applied.

There are a few more changes for PR but dependant on these initial 3 PRs being merged to avoid conflicts.

DrDrij and others added 3 commits August 20, 2026 11:56
On the static build every navigation is a full document load, and the
first paint can happen before app.css applies. Until it does,
`-translate-x-full` means nothing, so the nav panel painted in-flow and
fully visible. When the stylesheet finally arrived the transform
resolved, and because `transition-all` was already on the element the
correction was animated — the menu appeared to open, then slide shut
over 300ms.

Two guards, since either alone leaves a visible artefact:

- Park the panel off-screen in the inlined critical CSS, so it is never
  visible on the first paint. Width/height/position match the Tailwind
  classes so the resolved transform is identical before and after
  app.css lands and nothing animates on arrival.
- Withhold the transition classes until after mount, so any remaining
  correction is applied instantly rather than animated.

Also narrows `transition-all` to `transition-transform`: the former
animated 15 properties (background, borders, padding, tab-size) where
only the slide was wanted.

Covered by the existing FOUC guard, which already isolates the inline
critical CSS and now asserts the panel starts off-screen.
The sync-list comment was wrong on both halves. Resolved widths are
350/250/350 across the base/lg/2xl bands, not 250 — `.w-[350px]` lands
after `.w-[250px]` in the compiled base layer — so the rule's width was
off at two of three, and the used transform went -250px to -350px rather
than staying identical.

The rule was nonetheless correct, for a better reason: `translateX(-100%)`
resolves against the element's own border box, so the right edge lands at
`left + W - W` = 0 for any width. `width` and `height` were doing no work
and are dropped; `position:fixed` stays so the panel does not push the
article down while it waits. Verified off-screen with `right = 0` at all
three bands, with no horizontal overflow now that the box is shrink-to-fit.

That same property means the `mounted` gate is not load-bearing: with both
states at -100% there is nothing for a transition to interpolate. Measured
with the gate removed at 800px and 1280px — zero on-screen frames either
way. Reworded from "either alone leaves an artefact" to what it is, insurance
that keeps the component correct if the critical rule is later changed.

Also:
- `sidebarOnScreen` returns null rather than false when the element is
  absent. false is the value that passes the guard, so a renamed hook slipped
  past the main test and surfaced as a confusing control failure; both tests
  now assert presence with a message naming the cause.
- Adds the missing CHANGELOG entry.

Review by @mmcky on #123.
The new guard asserted the closed nav panel's right edge was not > 0.
That is knife-edge: on the unstyled first paint the panel has no width
class yet, so it is shrink-to-fit and lands on a fractional width
(135.171875px in WebKit on macOS). WebKit snaps the painted
translateX(-100%) to a whole device pixel but leaves the border box
fractional, so the measured edge comes back at +0.171875 instead of 0.
The remainder depends on the intrinsic width, hence on platform font
metrics — which is why the assertion passed on the ubuntu runner and
failed locally on macOS against identical, correct markup.

Report the measured edge instead of a boolean and compare it against a
1px tolerance. The control case measures ~1272px of a 1280px viewport,
so the two states stay three orders of magnitude apart.

The missing-element guard is kept and its rationale updated: `null`
coerces to 0, which would pass the off-screen comparison, so a renamed
hook would still slip past the main test without the explicit check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mmcky

mmcky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Rebased onto main (now at eba18a4b, which picked up the Thebe work in #98) and reviewed. All four checks pass; the PR is MERGEABLE / CLEAN at f1659ff0.

Rebase

The only conflict was CHANGELOG.md, where main had gained the Thebe entry. Both now sit under Unreleased### Added for thebe, ### Fixed for this. Neither app/root.tsx nor app/components/ContentsSidebar.tsx had moved on main, so the code rebased cleanly. Both original commits keep their authorship.

The fix is sound

I checked the mechanism rather than taking the description's measurements on trust. Serving the visual fixture with all external stylesheets aborted, the closed panel's right edge measures:

right edge width
without the inline rule (control) 1272px of a 1280px viewport 1264px
with the inline rule 0.17px 135px

The zero-specificity reasoning holds, and so does the invariant the block comment now records — every property the critical rule sets (transform, position, left) is also declared by a Tailwind class on the element (-translate-x-full / translate-x-0, fixed, left-0), so none of them can get stuck. Styled behaviour is unchanged: closed right: 0 → open 250 → closed 0, with transition-property reporting transform alone at 0.3s. The full Playwright suite passes, sidebar-open snapshot included, so narrowing transition-all moved no pixels.

One defect, fixed in f1659ff0

The new guard assertion was knife-edge, and it fails on macOS. It required the parked panel's right edge to be not > 0. That passed on the ubuntu runner and failed locally against identical, correct markup.

On the unstyled first paint the panel has no width class yet, so it is shrink-to-fit and lands on a fractional width — 135.171875px in WebKit here. WebKit snaps the painted translateX(-100%) to a whole device pixel (-135) while leaving the border box fractional, so the edge comes back at +0.171875 instead of 0. The remainder tracks the intrinsic width, hence platform font metrics, which is why the two platforms disagreed.

firstPaintState now reports the measured edge instead of a boolean, and both assertions compare against a 1px tolerance. Nothing is weakened — the two states sit three orders of magnitude apart (0.17 vs 1272). The missing-element guard stays and is still load-bearing: null coerces to 0, which would pass the off-screen comparison, so a renamed hook would otherwise still slip past the main test. Its comment is updated to say that, since the original wording was about false.

One knock-on for #126: the probe field is now sidebarRight rather than sidebarOnScreen. That issue's analysis is unaffected — it is the sampling window that is racy, not the comparison — but the name in it is now stale.

Not changed

The four touched files fail prettier --check, but so do their counterparts on main and CI does not enforce it, so reformatting would only have buried the diff.

Follow-up

The description's closing note about the duplicate w-[350px] / w-[250px] is a real trap, and is now #130. Two corrections to how it was framed there, both confirmed by experiment: the emission order is not arbitrary but a string sort of the arbitrary values, and the exposed band is snapshot-covered — sidebar-open runs on mobile-chrome at 393px, which is the base band. The cost is that a regression surfaces as an unexplained mobile snapshot diff rather than as anything naming the cause. Setting the base utility to w-[150px] and rebuilding makes the stale class win outright, rendering 250px.

🤖 Generated with Claude Code

@mmcky

mmcky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

thanks @DrDrij -- merging.

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