diff --git a/CHANGES b/CHANGES
index ba3b372..bf06065 100644
--- a/CHANGES
+++ b/CHANGES
@@ -11,6 +11,11 @@ _Notes on upcoming releases will be added here_
- Bump gp-sphinx docs stack to v0.0.1a16 — docs site now renders
via `gp-furo-theme`, a Tailwind v4 respin of Furo, with
`sphinx-vite-builder` handling theme-asset builds (#33)
+- Eliminate first-paint jank on the docs site: `{mcp-install}` no
+ longer flashes when restoring a saved client/method from
+ `localStorage` (CLS drops to 0), and the sidebar logo plus all
+ bold and italic text no longer pop in late as fonts arrive.
+ (#36)
## libtmux-mcp 0.1.0a4 (2026-05-02)
diff --git a/docs/_ext/widgets/_prehydrate.py b/docs/_ext/widgets/_prehydrate.py
index 6e11ef2..74674dc 100644
--- a/docs/_ext/widgets/_prehydrate.py
+++ b/docs/_ext/widgets/_prehydrate.py
@@ -26,20 +26,29 @@
from sphinx.application import Sphinx
+# Every prehydrate declaration is ``!important``. The whole block lives in
+# ``@layer mcp-install-prehydrate`` (see :func:`_build_style`) and per CSS
+# Cascade Level 5 only ``!important`` declarations get the layer-priority
+# *reversal* that makes a layered rule outrank an unlayered one. Normal
+# (non-``!important``) rules in a layer LOSE to unlayered rules of the same
+# specificity — which is what bit the original tab rules: they were
+# specific enough to beat ``widget.css``'s ``.tab[aria-selected="true"]``
+# unlayered, but became powerless once we wrapped the prehydrate in a layer
+# to fix the panel cascade against ``furo-tw``'s ``[hidden]`` preflight.
_TAB_DEACTIVATE_RULE = (
"html[data-mcp-install-client] .lm-mcp-install__tab"
'[data-tab-kind="client"][aria-selected="true"],'
"html[data-mcp-install-method] .lm-mcp-install__tab"
'[data-tab-kind="method"][aria-selected="true"]'
- "{color:var(--color-foreground-muted);"
- "border-bottom-color:transparent;"
- "background:transparent}"
+ "{color:var(--color-foreground-muted) !important;"
+ "border-bottom-color:transparent !important;"
+ "background:transparent !important}"
)
_TAB_ACTIVE_DECL = (
- "{color:var(--color-brand-primary);"
- "border-bottom-color:var(--color-brand-primary);"
- "background:var(--color-background-primary)}"
+ "{color:var(--color-brand-primary) !important;"
+ "border-bottom-color:var(--color-brand-primary) !important;"
+ "background:var(--color-background-primary) !important}"
)
_PANEL_HIDE_RULE = (
@@ -88,6 +97,27 @@ def _build_style() -> str:
Selectors are enumerated from :data:`CLIENTS` / :data:`METHODS` so adding
a client or method auto-extends the prehydrate rules — no second source of
truth to drift from.
+
+ Rules are wrapped in ``@layer mcp-install-prehydrate``. ``gp-furo-theme``
+ ships Tailwind v4's preflight inside ``@layer base``, including
+ ``[hidden]:where(:not([hidden="until-found"])){display:none!important}``.
+ Per CSS Cascade Level 5, important-rule layer ordering is reversed:
+ rules in *any* cascade layer outrank ``!important`` unlayered rules
+ regardless of specificity. An unlayered prehydrate ``"
+ return ""
def _snippet() -> str:
diff --git a/docs/_templates/page.html b/docs/_templates/page.html
new file mode 100644
index 0000000..73c673d
--- /dev/null
+++ b/docs/_templates/page.html
@@ -0,0 +1,23 @@
+{# Project-local override of gp-furo/base.html's logo prefetch hints.
+ gp-furo's default emits for logos, which Chrome
+ treats as Lowest priority for a future navigation — so the sidebar
+ first encounter triggers a fresh network request and the logo
+ pops in late. We use this logo on the current page, not a future one,
+ so rel="preload" is the correct hint. Also: light_logo and dark_logo
+ are the same file in this project, so emit a single tag (the browser
+ would dedupe URL-identical requests anyway, but two tags is noise).
+#}
+{% extends "!page.html" %}
+
+{% block logo_prefetch_links %}
+{%- if logo_url -%}
+
+{%- elif theme_light_logo and theme_dark_logo -%}
+{%- set light_path = pathto('_static/' + theme_light_logo, 1) -%}
+{%- set dark_path = pathto('_static/' + theme_dark_logo, 1) -%}
+
+{%- if dark_path != light_path %}
+
+{%- endif -%}
+{%- endif -%}
+{% endblock %}
diff --git a/docs/_widgets/mcp-install/widget.js b/docs/_widgets/mcp-install/widget.js
index ae93c5b..9f08b22 100644
--- a/docs/_widgets/mcp-install/widget.js
+++ b/docs/_widgets/mcp-install/widget.js
@@ -5,6 +5,12 @@
* localStorage state is re-applied on DOMContentLoaded and on every
* gp-sphinx:navigated event (see sphinx-gp-theme's README for the contract).
*
+ * Visibility is fully CSS-driven by attrs and the
+ * @layer mcp-install-prehydrate rules in docs/_ext/widgets/_prehydrate.py.
+ * This script never mutates the panels' [hidden] attributes — it only
+ * keeps tab aria-selected and the data-attrs in sync with the
+ * current selection. The CSS handles the rest.
+ *
* Vanilla JS, no deps.
*/
(function () {
@@ -76,13 +82,20 @@
});
if (!found) return; // value not available in this widget — ignore.
- updatePanels(widget);
+ // Mirror current widget state onto so _prehydrate.py's
+ // @layer mcp-install-prehydrate selectors drive panel visibility.
+ // Both attrs must be present for _panel_active to match, so set them
+ // unconditionally on every selection — including the click-before-saved
+ // case where the user picks a method without ever choosing a client
+ // (the other kind still has its server-default aria-selected to read).
+ var html = document.documentElement;
+ var clientValue = selectedValue(widget, "client");
+ var methodValue = selectedValue(widget, "method");
+ if (clientValue) html.setAttribute("data-mcp-install-client", clientValue);
+ if (methodValue) html.setAttribute("data-mcp-install-method", methodValue);
if (opts.persist) {
localStorage.setItem(STORAGE[kind], value);
- // Keep attr in sync so the prehydrate CSS in _prehydrate.py
- // continues to drive active state across SPA navigations after a click.
- document.documentElement.setAttribute("data-mcp-install-" + kind, value);
}
if (opts.broadcast) {
window.dispatchEvent(
@@ -93,16 +106,6 @@
}
}
- function updatePanels(widget) {
- var client = selectedValue(widget, "client");
- var method = selectedValue(widget, "method");
- widget.querySelectorAll(".lm-mcp-install__panel").forEach(function (panel) {
- var match = panel.dataset.client === client && panel.dataset.method === method;
- if (match) panel.removeAttribute("hidden");
- else panel.setAttribute("hidden", "");
- });
- }
-
function selectedValue(widget, kind) {
var tab = widget.querySelector(
'.lm-mcp-install__tab[data-tab-kind="' + kind + '"][aria-selected="true"]'
diff --git a/docs/conf.py b/docs/conf.py
index 63dc887..d81649b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -8,6 +8,7 @@
import typing as t
from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config
+from gp_sphinx.defaults import DEFAULT_SPHINX_FONT_PRELOAD
import libtmux_mcp
@@ -64,6 +65,27 @@
rediraffe_redirects="redirects.txt",
copybutton_selector="div.highlight pre, div.admonition.prompt > p:last-child",
copybutton_exclude=".linenos, .admonition-title",
+ # gp-sphinx's default preloads only Sans 400/700 + Mono 400. furo-tw.css
+ # uses Sans 500 (h1-h6, sidebar labels, definition list terms) and 600
+ # (blockquote.epigraph). Without preload, those weights download lazily
+ # after first paint and the heading text visibly re-flows when the
+ # font finally lands (font-display: block hides the text for ~3s
+ # then swaps, so it reads as a flicker rather than fallback-then-real).
+ sphinx_font_preload=[
+ *DEFAULT_SPHINX_FONT_PRELOAD,
+ ("IBM Plex Sans", 500, "normal"),
+ ("IBM Plex Sans", 600, "normal"),
+ # The announcement bar contains Pre-alpha., which
+ # resolves to 400 italic. Without preload, it loads ~54 ms
+ # after the preloaded normal weights and the announcement
+ # italic pops in late on first paint.
+ ("IBM Plex Sans", 400, "italic"),
+ # Bold inline code (, ~14 hits on the index)
+ # uses Mono 700. Without preload, it loads ~30 ms after the
+ # other critical fonts and bold inline code transiently
+ # renders at fallback weight before swap.
+ ("IBM Plex Mono", 700, "normal"),
+ ],
)
conf["myst_enable_extensions"] = [*conf["myst_enable_extensions"], "attrs_inline"]