Skip to content

docs: kill install-widget layout flash + first-paint FOUT polish#36

Merged
tony merged 8 commits intomainfrom
installer-layout-flash
May 7, 2026
Merged

docs: kill install-widget layout flash + first-paint FOUT polish#36
tony merged 8 commits intomainfrom
installer-layout-flash

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented May 6, 2026

Summary

  • Fix install-widget layout flash: with a non-default (client, method) saved in localStorage, the widget grew from ~94 px to ~270 px after widget.js ran, pushing the sd-container-fluid grid below it down by ~176 px. CLS on / drops 0.0299 → 0 (verified in Playwright with the worst-case (cursor, pip) saved state).
  • Fix install-widget active-tab indicator flash: tabs visibly switched from server default (claude-code / uvx) to the saved selection on first paint. Same cascade root cause as the panel flash.
  • Fix heading FOUT: h1h6 and sidebar labels render at weight 500, but only weights 400/700 were preloaded. Headings popped in late while weight-500 fetched lazily.
  • Fix announcement-bar italic FOUT: <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.
  • Fix sidebar logo flash: gp-furo-theme emits <link rel="prefetch"> for the logo, which Chrome treats as Lowest priority for a future navigation. Switched to rel="preload" since the logo paints on the current page.
  • Refactor widget.js: removed updatePanels() — 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 to gp-furo-theme (gp-sphinx 0.0.1a16, #33), which ships Tailwind v4's preflight inside @layer base:

@layer base { [hidden]:where(:not([hidden="until-found"])){display:none!important} }

Per CSS Cascade Level 5, !important rules in any cascade layer outrank !important unlayered 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_active into @layer base in DevTools flips the saved panel from display:none to display:block immediately. The fix wraps the prehydrate rules in @layer mcp-install-prehydrate, which the prehydrate <style> declares first in <head> (it lives in metatags, 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 !importantnormal layered rules LOSE to normal unlayered rules, which is why the unlayered widget.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-!important asymmetry so the next editor doesn't repeat the trap.
  • docs/_widgets/mcp-install/widget.js: Drop updatePanels(). Move the <html data-mcp-install-*> writes out of the if (opts.persist) branch in select() 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 from selectedValue(widget, otherKind) and writes both attrs — _panel_active always has both attrs available to match.

First-paint resource hints

  • docs/conf.py: Extend sphinx_font_preload past gp-sphinx's defaults (Sans 400/700 + Mono 400) with Sans 500, Sans 600, and Sans 400 italic. Imported DEFAULT_SPHINX_FONT_PRELOAD so future upstream additions propagate without manual sync.
  • docs/_templates/page.html (new): Override gp-furo-theme's logo_prefetch_links Jinja block to emit <link rel="preload"> instead of rel="prefetch". Also dedupes when light_logo == dark_logo (libtmux-mcp uses the same SVG for both).

Documentation

  • CHANGES: One ### Documentation entry under 0.1.x (unreleased) describing the cascade-layer fix and its cause (Tailwind preflight + Cascade Level 5 importance reversal).

Design decisions

  • @layer over 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 @layer fix 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.
  • Preload list scoped to demonstrably-needed weights: Sans 500/600 normal cover headings and sidebar labels; Sans 400 italic covers the announcement bar <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.
  • Logo override stays local, not pushed upstream into gp-furo-theme: The fix isn't libtmux-mcp-specific — every gp-furo consumer 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.html

Verify the logo is preloaded and no longer prefetched:

grep -c 'rel="prefetch"[^>]*libtmux\.svg' docs/_build/index.html
grep -c 'rel="preload"[^>]*libtmux\.svg' docs/_build/index.html

Verify the font preloads include 500, 600, and 400 italic:

grep -oE 'rel="preload"[^>]*woff2[^>]*' docs/_build/index.html

Reproduce the original shift in the browser to confirm CLS = 0:

localStorage.setItem("libtmux-mcp.mcp-install.client", "cursor");
localStorage.setItem("libtmux-mcp.mcp-install.method", "pip");
location.reload();
const shifts = [];
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) if (!e.hadRecentInput) shifts.push(e.value);
}).observe({ type: "layout-shift", buffered: true });
setTimeout(() => console.log("CLS:", shifts.reduce((a, b) => a + b, 0)), 3000);

Test plan

  • uv run ruff check . --fix --show-fixes — lint clean
  • uv run ruff format . — formatting unchanged
  • uv run mypy — types clean
  • uv run py.test --reruns 0 -vvv — full suite passes (no flake retry)
  • just build-docs — docs build succeeds with no new warnings
  • CLS = 0 with (cursor, pip) saved state (worst case: 363 px tall pip panel)
  • CLS = 0 with (claude-code, pip) saved state
  • CLS = 0 with cleared localStorage (default (claude-code, uvx) paints)
  • Active-tab indicator paints on saved tabs at first frame, never on server default
  • Click pip → uvx → pip cycle updates the visible panel correctly
  • Click a method tab with no client ever clicked — inferred client + new method both write to <html>, _panel_active matches
  • Sidebar logo SVG fetches at t≈11 ms (was lazy on <img> first encounter), arrives before first-contentful-paint
  • Sans 500 / 600 normal and Sans 400 italic fetch parallel with the rest of the preload trio at t=11 ms; FontFace API reports loaded before first paint
  • Announcement <em>Pre-alpha.</em> paints italic on the very first frame; no plain-roman → italic swap

tony added 6 commits May 6, 2026 17:07
…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-commenter
Copy link
Copy Markdown

codecov-commenter commented May 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.77%. Comparing base (3ed02e6) to head (faa2fde).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 2 commits May 6, 2026 18:59
… 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.
@tony tony force-pushed the installer-layout-flash branch from 4f06548 to faa2fde Compare May 6, 2026 23:59
@tony tony merged commit 3d60be1 into main May 7, 2026
9 checks passed
@tony tony deleted the installer-layout-flash branch May 7, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants