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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,27 @@ export const meta: V2_MetaFunction<typeof loader> = ({ 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).
* Its toggle does: both icons are always in the DOM and lucide emits real
* 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%}
Expand All @@ -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]}}
Expand Down
22 changes: 21 additions & 1 deletion tests/visual/fouc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <style>; any string == an external sheet that applied
appliedExternal: applied.some((sheet) => !!sheet.href),
};
Expand Down Expand Up @@ -109,6 +117,11 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain
"`.qe-toc-toggle__close` not found — the toggle's close icon was renamed or removed"
).not.toBeNull();
expect(state.toggleCloseRendered).toBe(false); // hidden by the inline rule
expect(
state.backToTopOpacity,
"`.qe-back-to-top` not found — the back-to-top button was renamed or removed"
).not.toBeNull();
expect(state.backToTopOpacity).toBe("0"); // pinned by the inline rule
});

test("control: removing the inline critical CSS reproduces the FOUC", async ({ page }) => {
Expand All @@ -134,5 +147,12 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain
"`.qe-toc-toggle__close` not found — the toggle's close icon was renamed or removed"
).not.toBeNull();
expect(state.toggleCloseRendered).toBe(true);
// Same for "back to top": with the inline rule gone it paints at full
// opacity, which is the state the transition would then animate away from.
expect(
state.backToTopOpacity,
"`.qe-back-to-top` not found — the back-to-top button was renamed or removed"
).not.toBeNull();
expect(state.backToTopOpacity).toBe("1");
});
});
Loading