From 73b42394ee474d2a3ed7b7db4d3bbd97821ba9e2 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Sat, 5 Sep 2026 09:59:46 +1000 Subject: [PATCH 1/2] fix: pin "back to top" to opacity 0 in the critical CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BackToTop is hidden by opacity-0 and carries transition-opacity, so on any frame where the stylesheet is absent it paints at full opacity and fades out once the sheet lands. That frame is produced by the React #423 hydration recovery (#126), which re-renders the head and briefly drops the stylesheet after the component has mounted — so gating the transition on mount, as #141 tried, cannot prevent it. Measured in WebKit with the stylesheet delayed: 17-18 animating frames without this rule, none with it. The FOUC guard asserts the rule: the main test expects opacity 0, the control expects 1 with the inline block stripped. Supersedes the Outline.tsx change in #141. Relates to #126. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 15 +++++++++++++++ app/root.tsx | 15 +++++++++++++++ tests/visual/fouc.spec.ts | 22 +++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb5948a0..eeaf6b209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- The "back to top" button no longer fades out of the page margin on load. It + is hidden by `opacity-0` and carries a `transition-opacity`, so on any frame + where the stylesheet is absent it painted at full opacity and then animated + away once the sheet landed. That frame is not the first paint alone: the + React hydration recovery ([#126](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/126)) + re-renders the head and briefly drops the stylesheet after the component has + mounted, which is why gating the transition on mount cannot stop it. The + critical CSS now pins the button to `opacity: 0` on the same zero-specificity + terms as the toggle's close icon, and the WebKit FOUC guard asserts it — with + the rule the button never paints, without it the control sees it at full + opacity. Measured with the stylesheet delayed: 17–18 animating frames before, + none after. Supersedes the `useMounted` approach in + [#141](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/141). + ## [2.4.0] - 2026-09-04 > Headline: lecture content now matches the typography of the existing Sphinx diff --git a/app/root.tsx b/app/root.tsx index fa43d3281..e12076e54 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -88,6 +88,9 @@ export const meta: V2_MetaFunction = ({ data }) => { * * - toggle icon: styles/app.css -> `.qe-toc-toggle__close` (only the class * name needs to stay in sync) + * - back to top: app/components/Outline.tsx -> `.qe-back-to-top`, whose + * Tailwind `opacity-0` / `opacity-100` pair this mirrors + * (again only the class name needs to stay in sync) * * The contents drawer itself needs no rule here — as a popover the UA * stylesheet already hides it while closed (see `.qe-toc` in styles/app.css). @@ -95,6 +98,17 @@ export const meta: V2_MetaFunction = ({ data }) => { * width/height attributes, so without this rule the close icon paints beside * the hamburger on that first frame. The `body:has(.qe-toc:popover-open)` rule * in app.css outranks this zero-specificity one, so the open state still swaps. + * + * "Back to top" needs the same treatment for a different reason: it carries a + * `transition-opacity` and is hidden by `opacity-0`, so on any frame where the + * stylesheet is absent it paints at full opacity and then *fades* out when the + * sheet lands, rather than never having been there. That frame is not only the + * first paint — the React #423 hydration recovery (#126) re-renders the head + * and briefly drops the stylesheet, and by then the component is mounted and + * the transition is live, so gating the transition on mount (as #141 tried) + * cannot stop it. Measured in WebKit with the stylesheet delayed: 17–18 + * animating frames without this rule, none with it. Its `opacity-100` utility + * outranks this rule, so the scrolled state still shows. */ const CRITICAL_CSS = ` @font-face{font-family:"Source Sans 3 Fallback";src:local("Helvetica"),local("Arial"),local("Liberation Sans"),local("Arimo");size-adjust:92.25%;ascent-override:111%;descent-override:43.36%;line-gap-override:0%} @@ -104,6 +118,7 @@ const CRITICAL_CSS = ` :where(.dark body){background-color:#1c1917} :where([hidden],.hidden){display:none} :where(.qe-toc-toggle__close){display:none} +:where(.qe-back-to-top){opacity:0} :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]}} diff --git a/tests/visual/fouc.spec.ts b/tests/visual/fouc.spec.ts index 195c5a87b..8c61b8aac 100644 --- a/tests/visual/fouc.spec.ts +++ b/tests/visual/fouc.spec.ts @@ -11,7 +11,9 @@ import { test, expect, type Page, type Route } from "@playwright/test"; * * The contents drawer is checked here too: any panel that relies on author CSS * to stay hidden paints open in that same frame. It is a popover now, so the UA - * stylesheet hides it and the assertions below expect that. + * stylesheet hides it and the assertions below expect that. The toggle's close + * icon and the "back to top" button are the other two things that would paint + * without author CSS; each has a rule in the critical block and is asserted on. * * This test makes the failure mode deterministic by **aborting all external * stylesheets**, so the only styling that can reach the page is the inline @@ -55,6 +57,7 @@ async function firstPaintState(page: Page) { const grid = document.querySelector(".simple-center-grid"); const sidebar = document.querySelector(".qe-toc"); const closeIcon = document.querySelector(".qe-toc-toggle__close"); + const backToTop = document.querySelector(".qe-back-to-top"); const applied = Array.from(document.styleSheets).filter((sheet) => { try { return !!sheet.cssRules && sheet.cssRules.length > 0; // applied, not pending @@ -76,6 +79,11 @@ async function firstPaintState(page: Page) { // both icons paint side by side on the first frame. `null` when missing, // for the same reason as above. toggleCloseRendered: closeIcon ? closeIcon.getClientRects().length > 0 : null, + // "Back to top" is hidden by opacity rather than display, and carries a + // transition — so if it paints visible here it will *fade* out once the + // stylesheet lands. The critical block pins it to 0. `null` when missing, + // as above. + backToTopOpacity: backToTop ? getComputedStyle(backToTop).opacity : null, // null href == an inline