diff --git a/CHANGELOG.md b/CHANGELOG.md index f39fe1d91..9fbfc66f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 open on keyboard focus of the actual control. Closing the search dialog no longer leaves its tooltip open over the trigger ([#165](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/165)). +- 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 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