Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
disabled-path test against a second no-thebe fixture served on its own port,
proving the toggle is gated on the project opting in ([#98](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/98)).

### Fixed
- Contents sidebar no longer flashes open on page load. On static builds the
first paint can happen before `app.css` applies, leaving the panel in flow and
fully visible; it then animated itself shut over 300ms because the transition
predated the stylesheet. The inlined critical CSS now parks the panel
off-screen on that first frame, and the transition is withheld until after
mount ([#123](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/123)).

## [2.2.0] - 2026-07-16

> Headline: fancy ordered lists — `(a)` / `(i)` / `B)` markers from the QuantEcon
Expand Down
32 changes: 31 additions & 1 deletion app/components/ContentsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { useSidebarHeight } from '@myst-theme/site';
import classNames from 'classnames';
import { slugToUrl } from 'myst-common';
import { useEffect, useState } from 'react';

type StrictHeading = Omit<Heading, 'level'> & { level: number };
type HeadingGroup = StrictHeading[];
Expand Down Expand Up @@ -43,8 +44,22 @@ function Section({ group }: { group: HeadingGroup }) {
);
}

/**
* True only after the component has mounted on the client.
*
* Both the server render and the first (hydrating) client render return
* `false`, so the markup matches and React does not warn; the effect then
* flips it on the frame after hydration.
*/
function useMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}

export function ContentsSidebar() {
const [open] = useNavOpen();
const mounted = useMounted();
const config = useSiteManifest();
const project = useProjectManifest();
const top = useThemeTop();
Expand Down Expand Up @@ -84,13 +99,28 @@ export function ContentsSidebar() {
<div
ref={toc}
className={classNames(
// Stable hook for the inline critical CSS in app/root.tsx, which parks
// this panel off-screen on the very first paint — before app.css lands.
'qe-contents-sidebar',
'fixed top-0 left-0',
'w-[350px] lg:w-[250px] 2xl:w-[350px]',
'h-screen w-[250px] z-[20] pt-[40px] pb-[90px] px-9',
'bg-qetoolbar-light dark:bg-qetoolbar-dark ',
'border-r-[1px] border-qetoolbar-border',
'transition-all duration-300 ease-in-out',
'overflow-y-auto',
// Belt and braces, not the primary guard. The critical CSS above is
// what actually prevents the flash; because both states are a -100%
// translate there is nothing for a transition to interpolate, so
// removing this gate does not by itself reintroduce it (measured).
//
// It is kept because the transition is only ever wanted in response to
// a click: withholding it until after mount means any correction made
// when app.css lands is applied instantly rather than animated, which
// keeps this component correct even if the critical rule is later
// changed or dropped.
//
// `transform` (not `all`) so only the slide animates, on the compositor.
mounted && 'transition-transform duration-300 ease-in-out',
{ 'translate-x-0': open, '-translate-x-full': !open }
)}
style={{ top: '50px' }}
Expand Down
15 changes: 15 additions & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data }) => {
* arrives. This keeps the inline block from overriding the live cascade despite
* being emitted after <Links /> in the document head.
*
* Because these rules carry no specificity, every property set here MUST also
* be declared by the real stylesheet, otherwise it can never be overridden.
* (E.g. parking the nav panel off-screen with `visibility:hidden` would stick
* forever, since no Tailwind class sets `visibility` — hence the transform.)
*
* Keep the values in sync with their sources of truth:
* - font stack: tailwind.config.js -> theme.extend.fontFamily.sans
* - grid columns: tailwind.config.js -> theme.extend.gridTemplateColumns
Expand All @@ -65,6 +70,15 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data }) => {
* page background; the inner content panel uses `qepage-dark`
* #222, see app/components/Page.tsx — intentionally not set here
* since these rules target <body>.)
* - nav panel: app/components/ContentsSidebar.tsx -> `.qe-contents-sidebar`
* (only the class name needs to stay in sync; see below)
*
* The nav-panel rule deliberately sets no width. `translateX(-100%)` resolves
* against the element's own border box, so its right edge lands at `left + W -
* W` = 0 for **any** width W — it is off-screen before app.css arrives and
* stays off-screen after, even though the resolved width differs between the
* two (350/250/350 across the base/lg/2xl bands). `position:fixed` is set so
* the panel does not push the article down while it waits.
*/
const CRITICAL_CSS = `
:where(html){font-family:"Source Sans 3",sans-serif}
Expand All @@ -74,6 +88,7 @@ const CRITICAL_CSS = `
:where(.simple-center-grid){display:grid;grid-template-columns:[screen-start] 1fr [body-start] minmax(300px,800px) [body-end] 1fr [screen-end]}
:where(.simple-center-grid) > *{grid-column:body-start / body-end}
@media (min-width:1280px){:where(.simple-center-grid){grid-template-columns:[screen-start] 1fr 200px 20px [body-start] 800px [body-end] 20px [margin-start] 200px [margin-end] 1fr [screen-end]}}
:where(.qe-contents-sidebar){position:fixed;left:0;transform:translateX(-100%)}
`;

export const links: LinksFunction = () => {
Expand Down
51 changes: 50 additions & 1 deletion tests/visual/fouc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { test, expect, type Page, type Route } from "@playwright/test";
* grid collapsed to `display: block`. The fix inlines critical CSS into `<head>`
* (see `app/root.tsx`), which parses synchronously and styles that first paint.
*
* The same unstyled frame also exposed the contents sidebar: `-translate-x-full`
* does nothing until app.css lands, so the panel painted in-flow and visible,
* then slid shut once the stylesheet arrived — the "menu opens on load" report.
* It is covered here too, since the cause and the guard are the same.
*
* This test makes the failure mode deterministic by **aborting all external
* stylesheets**, so the only styling that can reach the page is the inline
* `<style>`. If the inline critical CSS regresses, the "styled first paint"
Expand All @@ -21,6 +26,22 @@ import { test, expect, type Page, type Route } from "@playwright/test";

const PAGE = "/";

/**
* Slack, in CSS px, allowed on the parked nav panel's right edge.
*
* `translateX(-100%)` puts that edge at exactly 0 in theory, but the panel is
* unstyled at this point, so its width is shrink-to-fit and lands on a
* fractional value (135.171875px in WebKit here). WebKit snaps the painted
* translate to a whole device pixel while leaving the border-box width
* fractional, so the measured edge comes back at +0.171875 rather than 0 — the
* exact remainder varies with the intrinsic width, hence with platform font
* metrics. A strict `> 0` test therefore passes on the ubuntu CI runner and
* fails on macOS for the same, correct, markup.
*
* 1px is well inside "not visible" and well outside any snapping remainder.
*/
const OFFSCREEN_EPS = 1;

async function isolateInlineCss(page: Page, { stripCritical = false } = {}) {
await page.route("**/*", async (route: Route) => {
const req = route.request();
Expand Down Expand Up @@ -49,6 +70,7 @@ async function firstPaintState(page: Page) {
await page.waitForSelector(".simple-center-grid", { timeout: 5000 }).catch(() => {});
return page.evaluate(() => {
const grid = document.querySelector(".simple-center-grid");
const sidebar = document.querySelector(".qe-contents-sidebar");
const applied = Array.from(document.styleSheets).filter((sheet) => {
try {
return !!sheet.cssRules && sheet.cssRules.length > 0; // applied, not pending
Expand All @@ -59,6 +81,18 @@ async function firstPaintState(page: Page) {
return {
gridDisplay: grid ? getComputedStyle(grid).display : "(absent)",
bodyFont: getComputedStyle(document.body).fontFamily,
// The nav panel is closed on load, so it must start parked off-screen to
// the left: its right edge at (or left of) the viewport's left edge. If
// any real width of it is on-screen here, the menu visibly flashes open.
//
// Reported as the measured edge rather than a boolean so the assertions
// can carry a sub-pixel tolerance — see `OFFSCREEN_EPS` below.
//
// `null` when the element is missing, and the guards below reject it
// explicitly: `null` numerically coerces to 0, which would *pass* the
// off-screen check, so a renamed hook would slip through the main test
// and surface as a confusing failure in the control instead.
sidebarRight: sidebar ? sidebar.getBoundingClientRect().right : null,
// null href == an inline <style>; any string == an external sheet that applied
appliedExternal: applied.some((sheet) => !!sheet.href),
};
Expand All @@ -72,9 +106,15 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain

// No external sheet applied, so anything styled below comes from inline CSS.
expect(state.appliedExternal).toBe(false);
// The two reported FOUC symptoms must be absent on first paint:
// The reported FOUC symptoms must be absent on first paint:
expect(state.gridDisplay).toBe("grid"); // grid not collapsed to block
expect(state.bodyFont).toMatch(/Source Sans 3/); // sans, not the serif default
expect(
state.sidebarRight,
"`.qe-contents-sidebar` not found — the hook the critical CSS targets was renamed or removed"
).not.toBeNull();
// Nav panel parked off-screen (right edge at 0, modulo the sub-pixel slack).
expect(state.sidebarRight).toBeLessThan(OFFSCREEN_EPS);
});

test("control: removing the inline critical CSS reproduces the FOUC", async ({ page }) => {
Expand All @@ -86,5 +126,14 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain
expect(state.appliedExternal).toBe(false);
expect(state.gridDisplay).toBe("block");
expect(state.bodyFont).not.toMatch(/Source Sans 3/);
// Without the inline rule the nav panel lays out in-flow and fully visible —
// this is the "menu flashes open on load" symptom. Measured at ~1272px of a
// 1280px viewport, so the margin over OFFSCREEN_EPS is three orders of
// magnitude: the two states are never in danger of being confused.
expect(
state.sidebarRight,
"`.qe-contents-sidebar` not found — the hook the critical CSS targets was renamed or removed"
).not.toBeNull();
expect(state.sidebarRight).toBeGreaterThan(OFFSCREEN_EPS);
});
});
Loading