Skip to content

Commit 2b72d9f

Browse files
committed
gp-sphinx(fix[fowt]) Prevent flash of wrong theme on initial load
why: When localStorage.theme and OS prefers-color-scheme disagree (e.g., user toggled to dark on a light-mode OS), the page briefly paints the OS-default scheme before settling on the stored choice. Furo's inline body-script runs after <body> opens, and the meta color-scheme tag defers canvas color to OS, so the wrong scheme can leak into first paint on slower networks/CPUs. what: - Add _inject_fowt_prevention() html-page-context callback that injects a synchronous <head> snippet via metatags - Snippet sets html.style.colorScheme to the resolved theme (canvas paints correctly) and adds a gp-sphinx-theme-pending gating class - New CSS rule hides body until Furo's body-script sets data-theme, so any pre-script body paint is invisible - DOMContentLoaded failsafe sets body[data-theme] from the head-resolved value if Furo's body-script ever stops running
1 parent 05ddc4b commit 2b72d9f

1 file changed

Lines changed: 73 additions & 2 deletions

File tree

packages/gp-sphinx/src/gp_sphinx/config.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,12 +539,82 @@ def _inject_copybutton_bridge(
539539
context["metatags"] = context.get("metatags", "") + snippet
540540

541541

542+
def _inject_fowt_prevention(
543+
app: Sphinx,
544+
pagename: str,
545+
templatename: str,
546+
context: dict[str, t.Any],
547+
doctree: object,
548+
) -> None:
549+
"""Prevent flash of wrong theme (FOWT) on initial page load.
550+
551+
Furo's no-flicker mechanism is an inline script *inside* ``<body>``
552+
that sets ``body.dataset.theme`` from ``localStorage``. Two races
553+
leak through: (1) when the body-script fires after first paint on
554+
slower networks/CPUs, body content is briefly painted with the
555+
light defaults; (2) ``<meta name="color-scheme" content="light
556+
dark">`` defers the html canvas color to OS preference, so the
557+
canvas can paint in the wrong scheme even when localStorage holds
558+
a different value.
559+
560+
This hook addresses both by injecting a ``<style>`` + ``<script>``
561+
pair into Furo's ``metatags`` slot (rendered in ``<head>`` before
562+
stylesheets and the ``<body>`` open). The script synchronously
563+
resolves the user's effective theme, sets
564+
``document.documentElement.style.colorScheme`` (canvas paints in
565+
the right scheme), and adds the ``gp-sphinx-theme-pending`` class
566+
on ``<html>`` (CSS gate). The style hides body content while that
567+
class is present and ``body[data-theme]`` is unset — Furo's
568+
body-script clears the gate the moment it runs, revealing body
569+
content already styled with the correct theme.
570+
571+
A ``DOMContentLoaded`` failsafe sets ``body.dataset.theme`` from
572+
the head-resolved value if Furo's body-script never ran, so a
573+
future Furo refactor can't leave body permanently hidden.
574+
575+
No-JS users skip the gate entirely (the class is set by JS), so
576+
Furo's existing ``prefers-color-scheme`` fallback at
577+
``_head_css_variables.html`` continues to work.
578+
579+
Parameters
580+
----------
581+
app : Sphinx
582+
The Sphinx application object.
583+
pagename : str
584+
Name of the page being rendered.
585+
templatename : str
586+
Name of the template being used.
587+
context : dict[str, Any]
588+
Rendering context passed to the template.
589+
doctree : object
590+
Doctree for the page (unused).
591+
"""
592+
snippet = (
593+
"<style>"
594+
"html.gp-sphinx-theme-pending body:not([data-theme])"
595+
"{visibility:hidden}"
596+
"</style>"
597+
"<script>(function(){"
598+
'var t=localStorage.getItem("theme")||"auto";'
599+
'var r=t==="auto"'
600+
'?(window.matchMedia("(prefers-color-scheme: dark)").matches'
601+
'?"dark":"light"):t;'
602+
"document.documentElement.style.colorScheme=r;"
603+
'document.documentElement.classList.add("gp-sphinx-theme-pending");'
604+
'document.addEventListener("DOMContentLoaded",function(){'
605+
"if(document.body&&!document.body.dataset.theme)"
606+
"document.body.dataset.theme=t;});"
607+
"})();</script>"
608+
)
609+
context["metatags"] = context.get("metatags", "") + snippet
610+
611+
542612
def setup(app: Sphinx) -> None:
543613
"""Configure Sphinx app hooks for gp-sphinx workarounds.
544614
545615
Registers the bundled ``spa-nav.js`` script, wires the copy-button
546-
configuration bridge, and connects the ``remove_tabs_js`` post-build
547-
hook.
616+
configuration bridge, the FOWT-prevention head snippet, and
617+
connects the ``remove_tabs_js`` post-build hook.
548618
549619
Parameters
550620
----------
@@ -553,6 +623,7 @@ def setup(app: Sphinx) -> None:
553623
"""
554624
app.add_js_file("js/spa-nav.js", loading_method="defer")
555625
app.connect("html-page-context", _inject_copybutton_bridge)
626+
app.connect("html-page-context", _inject_fowt_prevention)
556627
app.connect("build-finished", remove_tabs_js)
557628
app.add_lexer("myst", MystLexer)
558629
app.add_lexer("myst-md", MystLexer)

0 commit comments

Comments
 (0)