docs: kill install-widget layout flash + first-paint FOUT polish#36
Merged
docs: kill install-widget layout flash + first-paint FOUT polish#36
Conversation
…n cascade The prehydrate <style> was unlayered. 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 rules in any layer outrank !important unlayered rules regardless of selector specificity, so the saved (client, method) panel paints display:none until widget.js mutates [hidden] — the install widget visibly grows on first frame and pushes everything below it down (CLS 0.0299 on /). Wrap the prehydrate rules in @layer mcp-install-prehydrate. The prehydrate <style> sits in Furo's metatags slot, which renders before any <link rel="stylesheet">, so the layer is the first one the browser encounters and therefore the highest-priority layer for !important. Saved panel now paints correctly on the first frame: CLS drops 0.0299 → 0.
…e panel visibility After the @layer mcp-install-prehydrate fix, panel visibility is fully driven by <html data-mcp-install-*> attrs and the prehydrate's _panel_active / _PANEL_HIDE_RULE selectors. widget.js no longer needs to mutate the panels' [hidden] attributes after load. Drop updatePanels() entirely. Move the html data-attr writes out of the opts.persist branch in select() so they run on every selection (applySavedState, onBroadcast, onClick, keyboard nav) and reflect the *current* widget state — both kinds, not just the one that changed. This handles the click-before-saved-state case: a user clicking only a method tab without ever choosing a client now still sets both data-mcp-install-client (from the server-default aria-selected) and data-mcp-install-method, so _panel_active can match. Behavioural net: the panels' server-rendered [hidden] state is never mutated post-load. Visibility is one source of truth — the html data-attrs the prehydrate <script> seeds and select() keeps in sync.
…so layered cascade wins The previous commit (ef9e5b6) wrapped the prehydrate <style> in @layer mcp-install-prehydrate to beat furo-tw's [hidden] preflight on the saved panel. That fix relies on CSS Cascade Level 5's layer-priority *reversal* — but the reversal applies only to !important declarations. Normal layered rules LOSE to normal unlayered rules. The tab active/inactive colour rules in _prehydrate.py were normal declarations. They originally won over widget.css's .lm-mcp-install__tab[aria-selected="true"] purely on specificity, unlayered. Once moved into @layer mcp-install-prehydrate they became powerless and the active-tab indicator flashed from server default to saved state on first paint, even though the panel content (which uses !important) painted correctly. Add !important to every tab declaration in _TAB_DEACTIVATE_RULE and _TAB_ACTIVE_DECL to match the panel rules. Expand _build_style docstring to call out the normal-vs-!important asymmetry so the next person who edits these rules doesn't repeat the trap.
gp-sphinx's DEFAULT_SPHINX_FONT_PRELOAD covers (Sans, 400, normal), (Sans, 700, normal), (Mono, 400, normal). But furo-tw.css renders h1-h6, sidebar labels, and definition list terms at font-weight: 500 (8 occurrences) and blockquote.epigraph at 600 (3 occurrences). With font-display: block, the browser hides text for ~3s waiting for the font, then swaps — so unpreloaded weights don't show fallback first, they pop in late and the headings visibly re-flow when the weight-500 file finally arrives. Extend sphinx_font_preload with (Sans, 500, normal) and (Sans, 600, normal). Both files now ship with the rest of the critical fonts in parallel via <link rel="preload"> at t≈0 and complete before first paint, eliminating the flicker the user reported on bold/weighty text. Mono 500/600/700 stay lazy — they're only used in code blocks which appear below-the-fold on the index. Imported from gp_sphinx.defaults so future upstream changes to the default preload list propagate without manual sync here.
gp-furo-theme's base.html emits <link rel="prefetch" href=".../logo.svg"> for the sidebar logo. Chrome treats prefetch as Lowest priority for a *future* navigation — so the logo doesn't actually start fetching during the critical path; the sidebar <img> first encounter triggers the request and the logo pops in late on first paint. This logo is on the *current* page, not a future one. The right hint is rel="preload". Override the upstream theme's logo_prefetch_links block via _templates/page.html. Bonus: when light_logo == dark_logo (libtmux-mcp uses the same SVG for both), emit a single tag instead of two — the browser would dedupe URL-identical requests anyway, but two tags is template noise. The logo now starts fetching at t=38ms (in parallel with critical CSS/font preloads) and finishes well before first paint at t=108ms.
…uncement bar The announcement bar emits <em>Pre-alpha.</em> on every page (set in conf.py's theme_options.announcement). The <em> resolves to weight 400 italic in IBM Plex Sans, but the previous preload pass (commit 6d6f461) only added 500/600 *normal* — italic faces were left out. Result: Sans 400 italic was loading at t=65ms (CSS-initiated, lazy via @font-face) instead of parallel with the other preloaded fonts at t=11ms. With font-display: block, the announcement glyphs were invisible for those ~54ms then popped in. On uncached visits the gap widens because italic kicks off only after CSS parses the @font-face rule and the announcement DOM is encountered. Add ("IBM Plex Sans", 400, "italic") to sphinx_font_preload. Now fetches at t=11ms parallel with sans 400/500/600/700 normal + mono 400, well before first-contentful-paint at t=52ms — the "Pre-alpha." italic paints on the very first frame. Other italic faces (Sans 300/500/600/700 italic, Mono italics) stay lazy: nothing demands them above-the-fold, and preloading unused bytes contends with critical-path resources.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #36 +/- ##
=======================================
Coverage 83.77% 83.77%
=======================================
Files 40 40
Lines 2132 2132
Branches 270 270
=======================================
Hits 1786 1786
Misses 266 266
Partials 80 80 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
… FOUT Mono 700 is used by ~14 elements on the homepage — bold inline code (<strong><code> and similar combinations from MyST/admonition markup). Without preload it loaded at t=43ms (CSS-initiated) while the other critical fonts arrived at t=11ms, so bold inline code visibly flashed at fallback weight before swap. Add it to sphinx_font_preload to fetch alongside the other critical fonts. Update the CHANGES wording to drop the "headings and announcement bar" specificity — the fix now applies across the docs to any bold or italic text that was lazy-loading. Mono 300 stays out: the only CSS rule that sets font-weight: 300 is .code-block-caption, and zero pages in the build emit that class (verified via grep). Browsers don't fetch declared @font-face files unless an element computes to them, so Mono 300 is dead bytes on disk that never hit the network.
4f06548 to
faa2fde
Compare
Open
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
(client, method)saved inlocalStorage, the widget grew from ~94 px to ~270 px afterwidget.jsran, pushing thesd-container-fluidgrid below it down by ~176 px. CLS on/drops 0.0299 → 0 (verified in Playwright with the worst-case(cursor, pip)saved state).claude-code/uvx) to the saved selection on first paint. Same cascade root cause as the panel flash.h1–h6and sidebar labels render at weight 500, but only weights 400/700 were preloaded. Headings popped in late while weight-500 fetched lazily.<em>Pre-alpha.</em>resolves to weight 400 italic, which was loading at t=65 ms (CSS-initiated) instead of parallel with the rest of the critical fonts at t=11 ms.gp-furo-themeemits<link rel="prefetch">for the logo, which Chrome treats as Lowest priority for a future navigation. Switched torel="preload"since the logo paints on the current page.widget.js: removedupdatePanels()— visibility is now fully CSS-driven by<html data-mcp-install-*>attrs. Server-rendered[hidden]state is never mutated post-load.Root cause: CSS Cascade Level 5 layer ordering
The install-widget had a prehydrate mechanism (
docs/_ext/widgets/_prehydrate.py, introduced 2026-04-27) that injects an inline<head><style>to drive panel visibility from<html>data-attrs before first paint. It stopped working after the docs migrated togp-furo-theme(gp-sphinx 0.0.1a16, #33), which ships Tailwind v4's preflight inside@layer base:Per CSS Cascade Level 5,
!importantrules in any cascade layer outrank!importantunlayered rules — specificity does not enter the comparison. The prehydrate's(0,5,1)selector lost to Tailwind's(0,1,0)purely because Tailwind's was layered and the prehydrate's was not.Verified at runtime: inserting
_panel_activeinto@layer basein DevTools flips the saved panel fromdisplay:nonetodisplay:blockimmediately. The fix wraps the prehydrate rules in@layer mcp-install-prehydrate, which the prehydrate<style>declares first in<head>(it lives inmetatags, before any<link rel="stylesheet">) — making it the earliest layer and therefore the highest-priority layer for!important.Changes by area
Install widget cascade
docs/_ext/widgets/_prehydrate.py: Wrap the joined rules in@layer mcp-install-prehydrate { … }. Mark every tab declaration!important(the layer reversal applies only to!important— normal layered rules LOSE to normal unlayered rules, which is why the unlayeredwidget.css.lm-mcp-install__tab[aria-selected="true"]was beating us on the tabs even after the panel fix landed). Docstring expanded to call out the normal-vs-!importantasymmetry so the next editor doesn't repeat the trap.docs/_widgets/mcp-install/widget.js: DropupdatePanels(). Move the<html data-mcp-install-*>writes out of theif (opts.persist)branch inselect()so html-attrs sync on every selection (applySavedState,onBroadcast,onClick, keyboard nav). For the click-before-saved-state case (user clicks a method tab with no client ever selected),select()now infers the other kind fromselectedValue(widget, otherKind)and writes both attrs —_panel_activealways has both attrs available to match.First-paint resource hints
docs/conf.py: Extendsphinx_font_preloadpast gp-sphinx's defaults (Sans 400/700 + Mono 400) with Sans 500, Sans 600, and Sans 400 italic. ImportedDEFAULT_SPHINX_FONT_PRELOADso future upstream additions propagate without manual sync.docs/_templates/page.html(new): Overridegp-furo-theme'slogo_prefetch_linksJinja block to emit<link rel="preload">instead ofrel="prefetch". Also dedupes whenlight_logo == dark_logo(libtmux-mcp uses the same SVG for both).Documentation
CHANGES: One### Documentationentry under0.1.x (unreleased)describing the cascade-layer fix and its cause (Tailwind preflight + Cascade Level 5 importance reversal).Design decisions
@layerover class-based[hidden]opt-in: Considered swapping the panel template's[hidden]for a custom class to sidestep the Tailwind preflight entirely. Rejected because the@layerfix is one mechanical line and[hidden]is the canonical accessibility primitive for tab panels.updatePanels()removed, not preserved as a no-op: After the layer fix,updatePanels()'s DOM mutation is dead code — server-rendered[hidden]plus html data-attrs is sufficient. Keeping the function as a no-op would only invite a future contributor to "fix" it back to mutating panels and re-introduce the original flash vector.<em>. Sans 300 / Mono 500–700 / italics other than 400 stay lazy because they're either not used above-the-fold or extremely rare. Preloading bytes the page never paints contends with critical-path resources.gp-furo-theme: The fix isn't libtmux-mcp-specific — everygp-furoconsumer with a logo gets the delayed paint. But shipping the upstream PR is a separate effort; the local override unblocks this branch now.Verification
After build, verify the prehydrate snippet ships in
<head>wrapped in the new layer:grep -c '@layer mcp-install-prehydrate' docs/_build/index.htmlVerify the logo is preloaded and no longer prefetched:
grep -c 'rel="prefetch"[^>]*libtmux\.svg' docs/_build/index.htmlgrep -c 'rel="preload"[^>]*libtmux\.svg' docs/_build/index.htmlVerify the font preloads include 500, 600, and 400 italic:
grep -oE 'rel="preload"[^>]*woff2[^>]*' docs/_build/index.htmlReproduce the original shift in the browser to confirm CLS = 0:
Test plan
uv run ruff check . --fix --show-fixes— lint cleanuv run ruff format .— formatting unchangeduv run mypy— types cleanuv run py.test --reruns 0 -vvv— full suite passes (no flake retry)just build-docs— docs build succeeds with no new warnings(cursor, pip)saved state (worst case: 363 px tall pip panel)(claude-code, pip)saved statelocalStorage(default(claude-code, uvx)paints)pip → uvx → pipcycle updates the visible panel correctly<html>,_panel_activematches<img>first encounter), arrives before first-contentful-paintloadedbefore first paint<em>Pre-alpha.</em>paints italic on the very first frame; no plain-roman → italic swap