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"]