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 CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ $ uv add gp-sphinx --prerelease allow

<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->

### Bug fixes

#### `gp-sphinx`: No more theme flicker on initial load or toggle

The light/dark theme applies cleanly on every page: no flash of the
wrong scheme on initial load, no toggle-icon "pop-in" mid-load, and
no animated mid-blend when toggling at runtime. (#23)

## gp-sphinx 0.0.1a10 (2026-04-25)

### Breaking changes
Expand Down
13 changes: 13 additions & 0 deletions docs/_static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,19 @@ img[src*="codecov.io"] {
animation-duration: 150ms;
}

/* ── Theme-toggle should snap, not animate ────────────────
* Furo's furo.css declares `transition: background .1s
* ease-out` on `.sig:not(.sig-inline)` for hover smoothing;
* that same transition runs on every theme swap and produces
* a visible mid-blend. Override at gp-sphinx scope.
* (Also lives in sphinx-gp-theme/.../custom.css for downstream
* consumers — duplicated here because docs/_static/css/custom.css
* shadows the theme file at the same path.)
* ────────────────────────────────────────────────────────── */
.sig:not(.sig-inline) {
transition: none;
}

.package-demo-grid {
display: grid;
gap: 1rem;
Expand Down
97 changes: 94 additions & 3 deletions packages/gp-sphinx/src/gp_sphinx/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,97 @@ def _inject_copybutton_bridge(
return
selector = getattr(app.config, "copybutton_selector", "div.highlight pre")
snippet = (
f"<script>window.GP_SPHINX_COPYBUTTON_SELECTOR={json.dumps(selector)};</script>"
'<script data-cfasync="false">'
f"window.GP_SPHINX_COPYBUTTON_SELECTOR={json.dumps(selector)};"
"</script>"
)
context["metatags"] = context.get("metatags", "") + snippet


def _inject_fowt_prevention(
app: Sphinx,
pagename: str,
templatename: str,
context: dict[str, t.Any],
doctree: object,
) -> None:
"""Prevent flash of wrong theme (FOWT) on initial page load.

Furo's no-flicker mechanism is an inline script *inside* ``<body>``
that sets ``body.dataset.theme`` from ``localStorage``. Two races
leak through: (1) when the body-script fires after first paint on
slower networks/CPUs, body content is briefly painted with the
light defaults; (2) ``<meta name="color-scheme" content="light
dark">`` defers the html canvas color to OS preference, so the
canvas can paint in the wrong scheme even when localStorage holds
a different value. Behind Cloudflare Rocket Loader the gap widens
further — Rocket Loader rewrites every inline ``<script>`` to a
private MIME type and runs it asynchronously after page load,
which means Furo's body-script is no longer synchronous at all.

This hook addresses all three by injecting a ``<style>`` +
``<script>`` pair into Furo's ``metatags`` slot (rendered in
``<head>`` before stylesheets and the ``<body>`` open). The
``<script>`` carries ``data-cfasync="false"`` to opt out of Rocket
Loader, so it runs synchronously as written. It resolves the
user's effective theme, sets
``document.documentElement.style.colorScheme`` (canvas paints in
the right scheme), and adds the ``gp-sphinx-theme-pending`` class
on ``<html>`` (CSS gate). The style hides body content while that
class is present and ``body[data-theme]`` is unset — body becomes
visible the moment ``data-theme`` is set, with the correct theme
already applied.

Two backups set ``body.dataset.theme`` so we don't rely on Furo's
Rocket-Loader-deferred body-script: a ``requestAnimationFrame``
callback fires before the next paint, and a ``DOMContentLoaded``
listener fires after parse — whichever runs first when
``document.body`` exists wins.

The script also removes the ``no-js`` class from ``<html>``
synchronously. Furo's ``furo.js`` removes it on DCL, but with
Rocket Loader deferring even external scripts, ``furo.js`` runs
well after page load — the ``.no-js .theme-toggle-container
{display: none}`` rule keeps the theme toggle invisible until
then, then the toggle suddenly appears. Removing ``no-js`` here
makes the toggle visible from the first paint.

No-JS users skip the gate entirely (the class is set by JS), so
Furo's existing ``prefers-color-scheme`` fallback at
``_head_css_variables.html`` continues to work. They also keep
the ``no-js`` class, which correctly hides the toggle button.

Parameters
----------
app : Sphinx
The Sphinx application object.
pagename : str
Name of the page being rendered.
templatename : str
Name of the template being used.
context : dict[str, Any]
Rendering context passed to the template.
doctree : object
Doctree for the page (unused).
"""
snippet = (
"<style>"
"html.gp-sphinx-theme-pending body:not([data-theme])"
"{visibility:hidden}"
"</style>"
'<script data-cfasync="false">(function(){'
'var t=localStorage.getItem("theme")||"auto";'
'var r=t==="auto"'
'?(window.matchMedia("(prefers-color-scheme: dark)").matches'
'?"dark":"light"):t;'
"document.documentElement.style.colorScheme=r;"
'document.documentElement.classList.add("gp-sphinx-theme-pending");'
'document.documentElement.classList.remove("no-js");'
"function s(){if(document.body&&!document.body.dataset.theme)"
"document.body.dataset.theme=t;}"
"requestAnimationFrame(s);"
'document.addEventListener("DOMContentLoaded",s);'
"})();</script>"
)
context["metatags"] = context.get("metatags", "") + snippet

Expand All @@ -543,8 +633,8 @@ def setup(app: Sphinx) -> None:
"""Configure Sphinx app hooks for gp-sphinx workarounds.

Registers the bundled ``spa-nav.js`` script, wires the copy-button
configuration bridge, and connects the ``remove_tabs_js`` post-build
hook.
configuration bridge, the FOWT-prevention head snippet, and
connects the ``remove_tabs_js`` post-build hook.

Parameters
----------
Expand All @@ -553,6 +643,7 @@ def setup(app: Sphinx) -> None:
"""
app.add_js_file("js/spa-nav.js", loading_method="defer")
app.connect("html-page-context", _inject_copybutton_bridge)
app.connect("html-page-context", _inject_fowt_prevention)
app.connect("build-finished", remove_tabs_js)
app.add_lexer("myst", MystLexer)
app.add_lexer("myst-md", MystLexer)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ dl.py:not(.fixture) > dt {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
min-height: 2rem;
transition: background 100ms ease-out;
}

dl.py:not(.fixture) > dt:hover {
Expand All @@ -54,7 +53,6 @@ dl.py:not(.fixture) dd dl.py:not(.fixture) > dt {
background: transparent;
border-bottom-color: var(--color-background-border);
padding-left: 0.75rem;
transition: background 100ms ease-out;
}

dl.py:not(.fixture) dd dl.py:not(.fixture) > dt:hover {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,13 @@ a.reference:has(.sd-badge[role="note"][aria-label^="Safety tier:"]):hover code {
::view-transition-new(root) {
animation-duration: 150ms;
}

/* ── Theme-toggle should snap, not animate ────────────────
* Furo's furo.css declares `transition: background .1s
* ease-out` on `.sig:not(.sig-inline)` for hover smoothing;
* that same transition runs on every theme swap and produces
* a visible mid-blend. Override at gp-sphinx scope.
* ────────────────────────────────────────────────────────── */
.sig:not(.sig-inline) {
transition: none;
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ dl.gp-sphinx-api-container:not(.py) > dt.gp-sphinx-api-header {
text-indent: 0;
margin: 0;
min-height: 2rem;
transition: background 100ms ease-out;
}

dl.gp-sphinx-api-container:not(.py) > dt.gp-sphinx-api-header:hover {
Expand All @@ -253,7 +252,6 @@ dl.gp-sphinx-api-container:not(.py) > dd.gp-sphinx-api-content {
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-background-border);
padding: 0.5rem 0.75rem 0.5rem 1rem;
transition: background 100ms ease-out;
}

.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header:hover {
Expand Down