diff --git a/CHANGELOG.md b/CHANGELOG.md index d7082ef9f..e768cb7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/components/ContentsSidebar.tsx b/app/components/ContentsSidebar.tsx index 8e3fb270b..a22a86815 100644 --- a/app/components/ContentsSidebar.tsx +++ b/app/components/ContentsSidebar.tsx @@ -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 & { level: number }; type HeadingGroup = StrictHeading[]; @@ -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(); @@ -84,13 +99,28 @@ export function ContentsSidebar() {
= ({ data }) => { * arrives. This keeps the inline block from overriding the live cascade despite * being emitted after 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 @@ -65,6 +70,15 @@ export const meta: V2_MetaFunction = ({ data }) => { * page background; the inner content panel uses `qepage-dark` * #222, see app/components/Page.tsx — intentionally not set here * since these rules target .) + * - 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} @@ -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 = () => { diff --git a/tests/visual/fouc.spec.ts b/tests/visual/fouc.spec.ts index 684155523..5e50a620a 100644 --- a/tests/visual/fouc.spec.ts +++ b/tests/visual/fouc.spec.ts @@ -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 `` * (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 * `