Skip to content

Replace Furo dependency with in-tree Tailwind v4 theme port#25

Merged
tony merged 65 commits intomainfrom
custom-tw-furo
May 2, 2026
Merged

Replace Furo dependency with in-tree Tailwind v4 theme port#25
tony merged 65 commits intomainfrom
custom-tw-furo

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented May 2, 2026

Summary

  • Add gp-furo-theme — an in-tree Sphinx theme that reproduces vanilla Furo's HTML, behaviour, and visual surface, authored in pure Tailwind v4 CSS (no SASS).
  • Add gp-furo-tokens — a TypeScript-only Tailwind v4 plugin that emits Furo's full token contract (light + dark) as body / body[data-theme="dark"] blocks.
  • Add gp-sphinx-vite — Sphinx orchestration that auto-spawns pnpm exec vite build --watch under sphinx-autobuild, auto-installs node_modules/ when missing, and is a hard no-op in production wheels.
  • Replace furo runtime dependency in sphinx-gp-theme with gp-furo-theme; inherit = furoinherit = gp-furo in theme.conf. Furo (and its transitives accessible-pygments, sphinx-basic-ng) drop out of the workspace lock.
  • Wire merge_sphinx_config(vite_orchestration=True) into docs/conf.py so contributors get live CSS/JS rebuild via just start with no extra command.
  • Specialise light-mode Pygments Generic.Prompt (purple-500) and Generic.Output (cyan-600) so shell prompts + command output stay distinguishable from Generic.Subheading — mirrors dark-mode (monokai) pink + cyan asymmetry.

Changes by area

New package — packages/gp-furo-tokens/ (TypeScript-only)

File Description
src/contract.ts Zod literal-union of every --* identifier harvested from upstream Furo's SCSS (~153 names)
src/light.ts, src/dark.ts Hex / var() / gradient values ported byte-verbatim — no OKLCH conversion (fidelity port)
src/plugin.ts Tailwind v4 plugin((api) => api.addBase(...)) emitting body { … } body[data-theme="dark"] { … }
__tests__/contract.test.ts Gates Zod schema vs upstream-SCSS name diff so drift fails CI

New package — packages/gp-furo-theme/ (Python + TypeScript + CSS)

  • src/gp_furo_theme/ — verbatim port of upstream Furo's Python: _asset_hash, _html_page_context, _builder_inited, _overwrite_pygments_css, WrapTableAndMathInAContainerTransform, navigation.py. Strict-typed (Pygments styles are classes, not instances — fixed upstream's annotation in the port).
  • src/gp_furo_theme/theme/gp-furo/ — all 20 Furo Jinja templates ported verbatim with single-line attribution headers.
  • web/src/scripts/furo.ts — strict-typed re-author of furo.js (theme-toggle, mobile sidebar, scroll-spy, back-to-top). Wrapped in source-level IIFE to isolate function _() from doctools.js's const _ = Documentation.gettext.
  • web/src/scripts/gumshoe.js + gumshoe.d.ts — vendored verbatim modulo a 4-line UMD→ESM boundary edit; typed surface restricted to what furo.ts consumes (.d.ts ends with export {}; so it's a module).
  • web/src/styles/ — pure Tailwind v4 entry (index.css) plus per-component files (base.css, lists.css, tables.css, admonitions.css, code.css, api.css, search.css, scaffold.css, sidebar.css, toc.css, extensions.css, …). No @apply chains; component rules are plain CSS in component-scoped files.
  • LICENSE-FURO at package root + per-template Jinja attribution + <!-- Generated with Sphinx … and Furo {{ furo_version }} --> HTML comment in base.html (license obligation).

New package — packages/gp-sphinx-vite/

File Description
config.py Pure detect_mode(config_value, argv, env) + GpSphinxViteConfig dataclass — no Sphinx dep, fully unit-testable
process.py ViteProcess async subprocess wrapper, factories vite_watch_command() + pnpm_install_command()
bus.py Thread + asyncio.new_event_loop().run_forever() bridge so sync Sphinx hooks can drive async I/O
hooks.py builder-inited spawn, _ensure_node_modules() auto-install if missing, atexit + SIGINT/TERM/HUP teardown
__init__.py Sphinx setup() registering gp_sphinx_vite_mode + gp_sphinx_vite_root config values

packages/sphinx-gp-theme/ — re-parent + Pygments tightening

  • theme/theme.conf: inherit = furoinherit = gp-furo; stylesheet = styles/furo-tw.css, css/custom.css, css/argparse-highlight.css
  • pyproject.toml: furogp-furo-theme==0.0.1a13 in dependencies
  • pygments_styles.py: Generic.Prompt bold #475569bold #a855f7 (purple-500); Generic.Output #475569#0891b2 (cyan-600). Closes light-mode asymmetry vs dark mode's pink-on-cyan.

packages/gp-sphinx/ — orchestration wiring

  • merge_sphinx_config(..., vite_orchestration=True) parameter — when true, prepends gp_sphinx_vite to extensions and resolves gp_sphinx_vite_root via gp_furo_theme.get_vite_root().
  • defaults.py DEFAULT_THEME unchanged (sphinx-gp-theme); cutover is invisible to consumers.

Tests

  • tests/visual/test_visual_regression.py — Playwright pixel-diff suite: 12 pages × 2 modes × 3 viewports = 72 captures vs baseline-scss/ snapshots.
  • tests/visual/test_furo_behaviors.py — 5 behavioural Playwright tests (theme-toggle cycling, mobile drawer, scroll-spy, back-to-top, skip-to-content focus).
  • tests/visual/test_baseline_capture.py — re-capture script for baselines.
  • tests/test_gp_sphinx_vite_*.py — 49 new unit + integration tests across config, process, bus, hooks (TDD against a fake-vite shell script).
  • tests/test_gp_furo_theme_equivalence.py — HTML byte-equivalence vs upstream Furo (CSS half dropped post-pivot to Tailwind; pytest.importorskip("furo") so the file skips post-cutover).
  • tests/test_gp_furo_theme_tw_contract.py — asserts the Tailwind output contains the selectors furo.ts toggles at runtime.
  • tests/test_pygments_style.py — added 2 cases for the new gp + go light-mode colours; snapshot regenerated.

Docs + housekeeping

  • docs/packages/gp-furo-theme.md, docs/packages/gp-furo-tokens.md, docs/packages/gp-sphinx-vite.md — package pages following the existing template.
  • docs/redirects.txt — three new extensions/<name> packages/<name> entries.
  • pnpm-workspace.yaml (new) — collapses lockfile + virtual store across gp-furo-tokens + gp-furo-theme/web.
  • pyproject.toml[tool.uv.sources] adds gp-furo-theme + gp-sphinx-vite workspace pins.
  • scripts/ci/package_tools.py — smoke runners for the three new packages.
  • CHANGES — release note describing the Tailwind v4 cutover and Furo drop.

Design decisions

Pure Tailwind v4, no SASS. Original plan ported Furo's SCSS verbatim via dart-sass. After landing that pipeline, the target shifted to the maintainer's preferred Tailwind v4 idiom (per-component plain CSS, no @apply chains, @theme inline token surface). The SCSS pipeline was unwound (step 9.14) and replaced with hand-authored component files. Visual fidelity (Playwright pixel diff) replaced source-byte fidelity (CSS AST equivalence) as the gating signal. Trade-off: ~6.5 kB CSS size delta vs upstream Furo (per-file headers + flatter rule structure), accepted because tokens + behaviour stay 1:1.

Token plugin emits at body, not :root. CSS variable aliases like --color-content-foreground: var(--color-foreground-primary) substitute their var() at the declaring element's scope and are frozen there. With aliases at :root, dark-mode overrides on body[data-theme="dark"] couldn't re-resolve them — dark pages rendered with light values. Mirrors upstream Furo's body { @include colors } pattern.

Source-level IIFE wrap, not Rollup format: 'iife'. Tried the Rollup option first; it errored with Invalid value for option "output.inlineDynamicImports" - multiple inputs are not supported because the Vite config has multiple entries. Wrapping the function body of furo.ts with (function (): void { … })(); achieves the same scope isolation without forcing a config rewrite. Closes the SyntaxError: Identifier '_' has already been declared regression where Rollup minified readTheme() to function _() and collided with doctools.js:147's const _ = Documentation.gettext.

box-sizing: content-box on .content only. Tailwind preflight sets universal box-sizing: border-box; upstream Furo's _scaffold.sass was authored against content-box. Scoping the override to .content (not the layout containers with explicit width: 15em, which behave identically under either model) restores the flex-grow distribution that fills .main correctly across the 7-breakpoint grid.

Auto-install node_modules/ instead of documenting a manual step. User invariant: git clean -fdx; cd docs; just clean; just start should always produce a working dev environment. gp_sphinx_vite.hooks._ensure_node_modules() runs pnpm install --frozen-lockfile synchronously through the existing AsyncioBus + ViteProcess infrastructure when <vite_root>/node_modules/ is missing. Failure is logged as a warning, not raised — docs build still proceeds without live JS/CSS rebuild.

Theme-toggle SVG visibility rules in @layer components, not @layer base. Tailwind layer cascade beats specificity: .theme-toggle svg { display: none } at @layer components was suppressing the activation rules at @layer base regardless of selector specificity. Co-locating both in @layer components resolves visibility cleanly. Caught a one-off rendering regression where the toggle button shipped at width=0.

sphinx-gp-theme keeps its project-specific layer. gp-furo-theme is a vanilla Furo port — no spa-nav.js, no projects sidebar, no IBM Plex tweaks, no Cloudflare Rocket Loader workaround. Those stay in sphinx-gp-theme and overlay on top of the gp-furo parent. Equivalence target for gp-furo-theme is therefore upstream Furo, not furo + sphinx-gp-theme.

Verification

Furo is no longer in the dependency tree:

$ uv tree | grep -ic '\bfuro\b'

Workspace registers the three new packages:

$ uv run python -c "from gp_sphinx_workspace import workspace_packages; print(sorted(workspace_packages()))" | tr ',' '\n' | grep -E 'gp-furo|gp-sphinx-vite'

sphinx-gp-theme re-parents on gp-furo:

$ grep -E '^inherit' packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/theme.conf

License attribution survives the cutover:

$ test -f packages/gp-furo-theme/LICENSE-FURO && grep -c 'Ported from furo' packages/gp-furo-theme/src/gp_furo_theme/theme/gp-furo/*.html

Vite orchestration auto-detects sphinx-autobuild:

$ uv run python -c "from gp_sphinx_vite.config import detect_mode; print(detect_mode(config_value='auto', argv=['sphinx-autobuild', 'docs/'], env={}))"

Test plan

  • uv run ruff check . --fix --show-fixes — no lint diagnostics
  • uv run ruff format . — formatting clean
  • uv run mypy — strict type-check passes (mypy 1.x, Python 3.10 floor)
  • uv run pytest --reruns 0 — full suite green (1281+ pytest, 12 syrupy snapshots, 12 vitest)
  • just build-docs — production Sphinx build succeeds; vite orchestration no-ops as expected
  • pnpm exec vitest run (in packages/gp-furo-tokens/) — token contract tests green
  • tests/test_gp_furo_theme_equivalence.py::test_html_byte_equivalent_with_furo — verifies HTML structural parity vs upstream Furo (skipped post-cutover via importorskip)
  • tests/visual/test_visual_regression.py — Playwright pixel diff vs SCSS-built baselines under threshold
  • tests/visual/test_furo_behaviors.py — 5 behavioural tests (theme-toggle, mobile drawer, scroll-spy, back-to-top, skip-link)
  • tests/test_gp_sphinx_vite_hooks.py::test_on_builder_inited_runs_install_when_node_modules_missing — auto-install behaviour
  • tests/test_pygments_style.py::test_gp_sphinx_light_token_palette[generic-prompt-purple|generic-output-cyan] — light-mode Pygments parity
  • Manual: git clean -fdx && cd docs && just clean && just start produces a working dev environment with no manual pnpm install; http://localhost:3124/ renders with zero 404s on furo-tw.css / furo.js and zero JS console errors

Notes for reviewers

  • 38.7% of the file diff is binary baseline PNGs under tests/visual/__snapshots__/baseline-scss/ (72 captures × ~250 kB avg). They're load-bearing for the visual regression suite but contain no reviewable content — skip them.
  • 20 Jinja templates under packages/gp-furo-theme/src/gp_furo_theme/theme/gp-furo/ are byte-identical to upstream Furo modulo a single attribution header line.
  • 44 CSS files removed in 9d86bc (drop SCSS pipeline) were vendored Furo SCSS that the Tailwind v4 rewrite (commits f982a58..2d0bfa0) replaced. The drop is mechanical, not a behaviour change.
  • The branch was rebased onto current main (commit 9f62b7a resolves stray conflict markers + lockstep version bumps to 0.0.1a13).

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 2, 2026

Codecov Report

❌ Patch coverage is 78.11448% with 325 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.04%. Comparing base (ae8c5c7) to head (e92ce7a).

Files with missing lines Patch % Lines
tests/visual/test_furo_behaviors.py 22.00% 78 Missing ⚠️
tests/visual/test_visual_regression.py 40.96% 49 Missing ⚠️
tests/test_gp_furo_theme_equivalence.py 8.51% 43 Missing ⚠️
...ckages/gp-furo-theme/src/gp_furo_theme/__init__.py 79.88% 36 Missing ⚠️
...ages/gp-furo-theme/src/gp_furo_theme/navigation.py 19.44% 29 Missing ⚠️
tests/visual/conftest.py 46.87% 17 Missing ⚠️
...ackages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py 83.67% 16 Missing ⚠️
scripts/ci/package_tools.py 12.50% 14 Missing ⚠️
tests/visual/test_baseline_capture.py 66.66% 14 Missing ⚠️
packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py 88.88% 8 Missing ⚠️
... and 7 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #25      +/-   ##
==========================================
- Coverage   90.15%   89.04%   -1.11%     
==========================================
  Files         164      183      +19     
  Lines       13655    15138    +1483     
==========================================
+ Hits        12310    13480    +1170     
- Misses       1345     1658     +313     

☔ 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 tony force-pushed the custom-tw-furo branch 7 times, most recently from b6cbaba to 2916f3e Compare May 2, 2026 15:22
tony added 19 commits May 2, 2026 11:48
…ge skeleton

why: First package in the Furo port plan. Establishes the JS workspace
footprint inside a previously Python-only repo and proves the toolchain
(pnpm + vitest + tsc) wires up cleanly before any contract logic lands.

what:
- packages/gp-furo-tokens/ with package.json (private, zod + tailwindcss
  peer dep + vitest), tsconfig.json (strict, noUncheckedIndexedAccess),
  vitest.config.ts, src/index.ts placeholder, smoke test
- README.md documents the package's role + Furo attribution
- Workspace .gitignore gains node_modules/, *.tsbuildinfo, .vitest-cache/
- pyproject.toml [tool.uv.workspace] excludes packages/gp-furo-tokens
  so uv stops demanding a pyproject.toml from a TS-only package
… TDD harness

why: Locks the byte-equivalence target into a machine-checkable artifact.
The Furo port must emit every public custom property Furo declares and
none that it doesn't; making the test fail with a clear diff in either
direction means a Furo upstream pin bump produces an actionable signal
rather than a silent visual drift.

what:
- upstream/furo-vars.json: 99 CSS custom-property names harvested from
  upstream Furo at 752bf80c, with provenance metadata (commit, date,
  source files).
- scripts/harvest-from-furo.ts: regenerates the JSON from a Furo
  checkout pointed to by FURO_SOURCE_DIR. Uses execFileSync for the
  git commit lookup (no shell).
- src/contract.ts: FURO_TOKEN_NAMES as a `readonly` literal-tuple, so
  consumers get the names as a union type. FuroTokenNameSchema is a
  Zod enum over the same source.
- __tests__/contract.test.ts: asserts our contract is exactly the
  upstream set in both directions, plus that every name matches the
  public custom-property regex.
- package.json gains a `harvest` script invoking node 25's native TS
  loader, no extra runtime dependency.
…ions, icons

why: The first harvest only walked four SCSS files and missed three
sources Furo uses for its public custom-property surface. Result: the
contract claimed Furo had 99 tokens when it really has 153, leaving
a third of the surface uncovered by the test that's supposed to defend
byte-equivalence with upstream.

what:
- Harvest script gains _fonts.scss as a static source (regex-extractable),
  plus enumerated lists for Sass `@each`-generated tokens that the regex
  cannot see: $admonitions in _admonitions.scss (11 names emitting
  --color-admonition-title--<name> and -background--<name>), $icons in
  _icons.scss (9 names emitting --icon-<name>), and the six default-mixin
  tokens from `@mixin default-admonition` / `default-topic`.
- furo-vars.json now lists 153 tokens with `dynamicSources` metadata
  documenting which file each enumerated list mirrors, so a Furo pin bump
  has a clear refresh path.
- src/contract.ts FURO_TOKEN_NAMES is regenerated from the JSON to match.
…rbatim

why: The contract by itself is just a list of names. Downstream packages
need the actual values to render the theme; the values must match Furo's
SCSS exactly so user `light_css_variables` overrides cascade in the
identical order Furo intended.

Hex codes, `var(...)` references, and the `linear-gradient(...)` for
sidebar-item hover are preserved literally — no OKLCH conversion, no
flattening of var-chains. The point is byte-equivalence with Furo's
compiled CSS, not a clean redesign.

what:
- src/light.ts: 153 entries covering @mixin spacing, @mixin fonts,
  @mixin icons (inline-SVG data URLs), the `default-admonition` and
  `default-topic` mixin invocations from base/_theme.sass:13-14,
  @mixin admonitions @each over $admonitions (11 names × 2 props),
  and @mixin colors. Typed `Record<FuroTokenName, string>`, so a
  contract addition causes a type error if its value is missing.
  `--color-background-muted` is included with an empty string — Furo
  references it from _scaffold.sass:349 but never declares it; left as
  a slot the plugin layer skips, to avoid emitting an invalid
  declaration.
- src/dark.ts: 32 deltas from @mixin colors-dark. Typed
  `Partial<Record<FuroTokenName, string>>` because the dark mixin is
  intentionally a partial override; everything else inherits from light
  via CSS-variable cascade, identical to Furo.
- src/index.ts re-exports both maps + the existing contract surface.
- __tests__/values.test.ts: every contract token has a light value;
  no light/dark key is outside the contract; dark surface stays in the
  expected size band (20..40).
… + dark variant

why: Wires the contract + value tables into Tailwind v4's @plugin
mechanism so a downstream stylesheet just writes
`@plugin "@gp-sphinx/furo-tokens/plugin"` and gets every Furo custom
property declared at @layer base — under :root for light and under
html[data-theme="dark"] for the dark deltas. This is the layer Sphinx's
partials/_head_css_variables.html (which emits user
light_css_variables / dark_css_variables as layer-less :root rules) sits
on top of, so user overrides keep winning the cascade exactly as in
upstream Furo.

what:
- src/plugin.ts: default-export tailwindcss/plugin handler that calls
  api.addBase with the two declaration blocks. Empty-string values
  (the --color-background-muted slot Furo references but never
  declares) are filtered so we don't emit invalid `--name: ;` CSS.
- __tests__/plugin.test.ts: invokes plugin.handler with a capturing
  PluginAPI stand-in and asserts (a) the :root rule covers every
  contract token whose light value is non-empty, (b) the dark rule
  covers every dark delta, (c) values are passed through verbatim
  (hex stays hex, var() stays var(), gradients stay gradients), and
  (d) the muted slot is correctly skipped.
…LICENSE-FURO

why: Ground truth for the Furo port. Once this lands the workspace has
two themes side-by-side (gp-furo for the port, sphinx-gp-theme for the
Furo child theme that's still default) so we can iterate the port behind
opt-in flags before flipping defaults in the final step. The package is
deliberately a skeleton: theme registration only. Templates, asset hooks,
and the post-transform port land in step 3 / step 4.

what:
- packages/gp-furo-theme/ Python package with pyproject.toml (no `furo`
  dep), src/gp_furo_theme/__init__.py exposing get_theme_path(), THEME_NAME,
  and setup() returning {parallel_read_safe, parallel_write_safe, version},
  py.typed marker, README.md, .gitignore for the Vite-built static/ dir.
- LICENSE-FURO at the package root reproduces upstream Furo's MIT license
  and notes that ported files carry attribution headers pointing back to it.
- src/gp_furo_theme/theme/gp-furo/theme.conf inherits from basic-ng (Sphinx
  6+) and declares the same option surface as upstream Furo's theme.conf
  so user `light_css_variables`, `source_repository`, etc. continue to work.
- tests/test_gp_furo_theme.py: 9 smoke tests covering theme path, conf
  presence + inherit declaration, setup() registration, entry-point
  discoverability via importlib.metadata, and LICENSE-FURO presence.
- Workspace wiring: pyproject.toml gains `gp-furo-theme = { workspace = true
  }` and the dev dependency-group includes it. tests/test_package_reference,
  docs/_ext/package_reference doctest, scripts/ci/package_tools smoke runner,
  docs/redirects.txt, and docs/packages/gp-furo-theme.md all updated for the
  new package.
…rom upstream Furo

why: Pin templates to Furo's pinned commit so subsequent steps have a
stable byte-equivalence target. Each ported file is byte-identical to
upstream modulo a one-line Jinja attribution comment, verified by
diffing against the source tree.

The integration test that builds a real Sphinx project against the new
theme is xfail-strict for the duration of this commit: the templates
reference furo_pygments / furo_navigation_tree / hide_toc, all of which
come from _html_page_context in upstream furo/__init__.py and land in
step 4 alongside the asset hooks. Marking xfail-strict (not skip) means
the test starts succeeding automatically once step 4 wires the context
in, and the dance gets noticed if it stops failing for some other
reason.

what:
- packages/gp-furo-theme/src/gp_furo_theme/theme/gp-furo/{base,page,layout,
  search,domainindex,genindex,globaltoc,localtoc}.html plus
  components/{edit,view}-this-page.html, partials/{_head_css_variables,
  icons}.html, sidebar/{brand,ethical-ads,navigation,rtd-versions,
  scroll-end,scroll-start,search,variant-selector}.html — 20 files,
  byte-identical to /home/d/study/python/furo/src/furo/theme/furo at
  commit 752bf80c modulo a 1-line `{#- Ported from furo @ 752bf80c, MIT
  (Pradyun Gedam). See LICENSE-FURO. -#}` header (whitespace-stripping
  brackets so rendered HTML stays byte-equivalent).
- tests/test_gp_furo_theme.py: two new structural tests
  (test_all_furo_templates_are_ported asserts every expected file
  exists; test_ported_templates_carry_attribution_header asserts the
  marker line is present in each), plus a build-an-html-project
  integration test pair marked xfail-strict and pointing at step 4.
…egration build

why: The 20 ported templates reference furo_pygments, furo_navigation_tree,
furo_hide_toc, and furo_version Jinja context variables — all of which
upstream Furo's _html_page_context populates. Without those vars the build
fails with `'furo_pygments' is undefined`. Bringing the hooks in flips the
xfail-strict integration tests green and unlocks the rest of step 4
(asset pipeline, byte-equivalence test) to start landing on top.

The port is verbatim modulo a handful of mechanical changes: theme name
"furo" → "gp-furo", attribution headers, `app.require_sphinx("8.1")` (gp-
sphinx's existing floor, up from upstream's "6.0"), and a corrected
type annotation on _KNOWN_STYLES_IN_USE — Pygments stores style classes
in formatter_args["style"], not instances, even though upstream's typing
of that dict was loose. Cast sites updated to match.

what:
- packages/gp-furo-theme/src/gp_furo_theme/navigation.py: 89-line port of
  furo/navigation.py (file-level mypy disable for bs4 stub imprecision —
  same dynamic attribute manipulation upstream uses, runtime behavior
  unchanged).
- packages/gp-furo-theme/src/gp_furo_theme/__init__.py expands from the
  2-method skeleton to the full hook surface:
  WrapTableAndMathInAContainerTransform (post-transform that wraps tables
  + math blocks in scrolling containers), _asset_hash + _add_asset_hashes
  (Furo's `?digest=<sha1>` cache-busting on Sphinx <7.1),
  _fix_canonical_url (dirhtml builder canonical-URL workaround),
  _html_page_context (the missing Jinja vars), _builder_inited (registers
  furo.js + furo-extensions.css with priorities matching upstream;
  refuses if "gp_furo_theme" is in extensions), update_known_styles_state
  + get_pygments_stylesheet (light/dark Pygments emission keyed off
  body[data-theme] and prefers-color-scheme), _overwrite_pygments_css
  (build-finished writer for the dynamic stylesheet). setup() now wires
  pygments_dark_style config value, the post-transform, and connects
  html-page-context, builder-inited, build-finished.
- packages/gp-furo-theme/pyproject.toml gains beautifulsoup4 + pygments
  as direct deps (transitively present today via furo, but won't be
  after the cutover).
- tests/test_gp_furo_theme.py: FakeApp gains require_sphinx,
  add_config_value, add_post_transform, connect; integration build
  scenario drops `extensions = ["gp_furo_theme"]` (themes auto-load via
  the sphinx.html_themes entry point — the `_builder_inited` check now
  catches the misconfiguration). xfail-strict markers removed; the two
  integration tests pass for real.
… Tailwind v4 pipeline

why: gp-furo-theme needs a Vite + Tailwind v4 build to produce the
furo.css, furo-extensions.css, and furo.js artifacts that
_html_page_context registers. With two JS packages now in play
(gp-furo-tokens + gp-furo-theme/web), introducing a root pnpm-workspace
collapses them onto a single lockfile + virtual store. The workspace also
unlocks `workspace:*` deps so gp-furo-theme/web can pull
@gp-sphinx/furo-tokens directly without a file:// path.

Step 4.2 ships placeholder furo.ts and an empty furo-extensions.css; the
real script port lands in 4.3 and the SCSS-to-Tailwind port in 4.4.
Today's vite build still produces a 15kB furo.css containing the full
153-token contract from the @gp-sphinx/furo-tokens plugin under :root and
html[data-theme="dark"], plus Tailwind's preflight reset at @layer base.

what:
- pnpm-workspace.yaml at gp-sphinx root listing
  packages/gp-furo-tokens and packages/gp-furo-theme/web. Lockfile moves
  from packages/gp-furo-tokens/pnpm-lock.yaml to root pnpm-lock.yaml; no
  changes to gp-furo-tokens itself, its 12 vitest tests still pass.
- packages/gp-furo-theme/web/{package.json,tsconfig.json,vite.config.ts}
  declares a private Vite project with tailwindcss + @tailwindcss/vite
  + the workspace token package. Three rollup inputs keyed by output
  subdir (`scripts/furo`, `styles/furo`, `styles/furo-extensions`) so
  files land at static/scripts/furo.js, static/styles/furo.css,
  static/styles/furo-extensions.css — exactly the names
  _html_page_context registers and Furo's contract expects. Filenames
  are not hashed; the Python-side `?digest=<sha1>` cache-busting from
  `_asset_hash` continues to be the canonical scheme.
- packages/gp-furo-theme/web/src/scripts/furo.ts: scaffold-only entry,
  attribution header pointing at upstream furo @ 752bf80c.
- packages/gp-furo-theme/web/src/styles/furo.css: imports tailwindcss
  and the @gp-sphinx/furo-tokens plugin (which now emits all 153 Furo
  custom properties under :root and the html[data-theme=dark] dark
  block).
- packages/gp-furo-theme/web/src/styles/furo-extensions.css: empty stub
  ready to absorb the sphinx-design / copybutton / inline-tabs / panels
  / readthedocs styles in step 4.4.

Verified: `pnpm -r test` 12/12 (gp-furo-tokens unaffected by the
workspace move), `pnpm exec vite build` produces the three expected
files at the expected paths, `uv run py.test` 1227 / 0 / 3, just
build-docs succeeds.
…r gumshoe.js

why: furo.css was emitting tokens but furo.js was an empty stub.
Sphinx registers `scripts/furo.js` at priority=200 in `_builder_inited`;
without an actual bundle the page loads but mobile sidebar, theme
toggle, scroll-spy, and back-to-top all silently no-op. This commit
brings the canonical Furo behaviors into the wheel.

Per gp-sphinx porting policy:
- furo.ts is rewritten with strict types (ThemeMode literal union,
  HTMLElement | null state, explicit narrowing in scroll handlers).
  Code we author from scratch passes strict tsc.
- gumshoe.js is vendored verbatim from upstream — third-party library
  (Chris Ferdinandi MIT) with @pradyunsg's patches preserved
  unchanged. The only non-cosmetic edit is replacing the UMD wrapper
  with an ESM `export default`; without it Rollup can't see a default
  export and the bundle fails. Patches inside the factory body remain
  bit-identical to upstream.

what:
- web/src/scripts/furo.ts (177-line port of furo.js): ThemeMode union,
  ScrollHandler trio (header `.scrolled`, `.show-back-to-top`,
  `.toc-scroll`), Gumshoe wiring on `.toc-tree a` with reflow +
  recursive + `scroll-current`, theme cycle (auto -> light -> dark
  with prefers-dark inversion), DOMContentLoaded entry that strips
  `no-js` from `<html>`. Behavioral parity with upstream Furo.
- web/src/scripts/gumshoe.js (473 lines): vendored gumshoe-patched.js;
  attribution header points back to upstream + LICENSE-FURO; UMD
  wrapper replaced with ESM default export.
- web/src/scripts/gumshoe.d.ts: ambient module declaration covering
  only the constructor + GumshoeOptions surface furo.ts uses
  (reflow, recursive, navClass, offset). Trailing `export {};` makes
  the .d.ts a module so the `declare module` block is parsed as an
  external module declaration.
- pnpm exec vite build now produces a 4.5 kB minified furo.js (was
  empty); grep confirms `scroll-current`, `toc-tree`, `theme-toggle`,
  `prefers-color-scheme`, `show-back-to-top`, `scrolled`, `no-js` all
  survive minification into the bundle.
…ht token leak

why: 4.2's scaffold imported all of Tailwind v4 (preflight + default
theme + utilities) into furo.css. Four extra tokens
(--default-font-family, --default-mono-font-family, --font-sans,
--font-mono) leaked through into our :root surface, on top of the
~140-line preflight reset Furo doesn't want (Furo uses normalize.css
semantics + a hand-tuned _typography.sass, not Tailwind's preflight).

Removing the @import drops emitted CSS from 15.07 kB → 11.21 kB while
preserving every Furo token. The @gp-sphinx/furo-tokens @plugin
directive still resolves because @tailwindcss/vite processes the file
regardless of whether `@import "tailwindcss"` is present.

The remaining `_typography.sass`, `_scaffold.sass`, content/, and
components/ ports land in subsequent 4.4 sub-commits.

what:
- web/src/styles/furo.css: drop `@import "tailwindcss";` keep only
  `@plugin "@gp-sphinx/furo-tokens/plugin";`. Header comment documents
  the rationale + the four token names step 4.5 will assert absent.

Verified: `pnpm exec vite build` succeeds (4 modules transformed), the
four preflight token names are absent from furo.css, --color-brand-primary
+ --color-admonition-title--note still emit at #0a4bff / #00b0ff, full
Python toolchain green (ruff, mypy 178 files, py.test 1227, build-docs).
… + dart-sass

why: The original 4.4 plan was hand-translating ~30 SCSS files into
Tailwind-layer CSS. That's a multi-day port with a lot of error
surface. Pivot: Vite has built-in dart-sass support (^1.77 — the same
version Furo uses upstream), so vendoring Furo's `assets/styles/` tree
verbatim and compiling through Vite gives us byte-near-equivalence
with no hand translation. The Tailwind v4 contribution stays the
same (token surface via @plugin); the actual rules come from upstream
Furo's own Sass.

Result: emitted furo.css grows from 11.21 kB (just tokens) to 51.38 kB
— within 600 bytes of upstream Furo's 50.79 kB. Class-selector counts
match upstream exactly across 7/8 sampled surfaces (admonition 70/70,
back-to-top 5/5, announcement 5/5, highlight 22/22, mobile-header 9/9,
theme-toggle 13/13, toc-tree 10/10; sidebar-tree 19/20 — likely a
Lightning-CSS-vs-cssnano selector dedup). The four Tailwind preflight
token slots (--default-font-family etc.) remain absent from emitted
CSS.

what:
- packages/gp-furo-theme/web/src/styles/sass/: 44 files vendored
  byte-identical from /home/d/study/python/furo/src/furo/assets/styles/
  at upstream commit 752bf80c, modulo a 1-line `// Ported from furo @
  752bf80c, MIT (Pradyun Gedam). See LICENSE-FURO.` attribution header
  per file. Verified via `diff <(tail -n +2 ours) upstream` — all 44
  pass byte-equivalence.
- web/package.json gains `sass^1.77.6` (matches upstream Furo's pin)
  and `normalize.css^8.0.1` (a Furo runtime dep — referenced from
  furo.sass).
- web/src/styles/sass/furo.sass: only edit is rewriting Webpack's
  `@import "~normalize.css"` to Vite/Sass-compatible
  `@import "normalize.css/normalize.css"`. Other 43 vendored files are
  byte-identical to upstream.
- web/src/styles/{furo,furo-extensions}.css renamed to .scss. Each
  begins with `@use "sass/furo"` (or `extensions`), then `@plugin
  "@gp-sphinx/furo-tokens/plugin"`. Sass passes the unknown
  `@plugin` at-rule through unchanged; @tailwindcss/vite expands it
  in the next pipeline stage.
- web/vite.config.ts updates the rollup input paths from .css to
  .scss; output naming is unchanged (still emits styles/furo.css and
  styles/furo-extensions.css).
…nce with vanilla Furo

why: The whole point of step 4 is delivering a faithful Furo port. Up
to now the claim has been informal — selector counts checked by hand,
preflight leak verified by grep. This commit lands the test that says
"if upstream Furo and gp-furo build the same Sphinx scenario, the
emitted HTML is byte-identical and the emitted CSS contains the same
declaration / selector surface". Future SCSS bumps, Tailwind bumps, or
accidental `@import "tailwindcss"` re-introductions get caught at CI
time.

Discovered while writing the test: gp-furo's theme.conf was missing
`stylesheet = styles/furo.css`. Without that line, Sphinx's basic-ng
fallback registers `_static/debug.css` instead of our compiled bundle,
which would have broken the full styling chain at cutover. Added the
missing line; HTML output now points at `styles/furo.css` matching
upstream byte-for-byte.

what:
- packages/gp-furo-theme/src/gp_furo_theme/theme/gp-furo/theme.conf
  gains `stylesheet = styles/furo.css` to mirror upstream Furo's
  theme.conf:3.
- tests/test_gp_furo_theme_equivalence.py: 13 new integration tests
  built around two module-scoped Sphinx fixtures (one html_theme=furo,
  one html_theme=gp-furo) sharing a tiny scenario (heading +
  paragraph + admonition + code block + table). Asserts:
  - test_index_html_byte_equivalent / test_search_html_byte_equivalent
    / test_genindex_html_byte_equivalent: rendered HTML is
    byte-identical after normalising `?digest=<sha1>` cache busting,
    `?v=<hex>` cache busting, the `<meta name="generator">` tag, and
    Furo's layout-template `<!-- Generated with Sphinx X and Furo Y -->`
    comment.
  - test_css_custom_properties_match: every Furo `--*` custom property
    declared at upstream's :root is present in ours.
  - test_css_no_tailwind_preflight_leak: regression guard against
    re-introducing `@import "tailwindcss"` — fails if any of
    `--default-font-family`, `--default-mono-font-family`,
    `--font-sans`, `--font-mono` shows up in our furo.css.
  - test_css_class_selector_set_matches_for_surface: parametrized over
    8 Furo class surfaces (sidebar-tree, toc-tree, admonition,
    highlight, theme-toggle, back-to-top, mobile-header,
    announcement); asserts each surface's emitted selector set is a
    subset of ours.
- All 13 new tests pass; total py.test count 1227 → 1240.
…ce registration

why: Step 5 of the Furo port plan calls for transparent Vite + pnpm
orchestration so theme authors iterating templates and SCSS get fresh
furo.css / furo.js on disk without remembering a separate
`pnpm exec vite build` invocation. This commit lands the package
skeleton so the seven workspace-registration touch points (per the
plan's appendix) all turn green before any subprocess code lands.

The orchestration logic itself (ViteProcess wrapper around
asyncio.create_subprocess_exec, threading-bridge for Sphinx hooks,
SIGINT/SIGTERM/SIGHUP handlers, idempotent re-spawn for
sphinx-autobuild's repeated builder-inited firings) ports in
subsequent commits with TDD against a fake `vite` shell script.

what:
- packages/gp-sphinx-vite/{pyproject.toml,README.md} declare the
  package as a Sphinx extension (sphinx.extensions entry point
  named "gp-sphinx-vite" -> module gp_sphinx_vite). No runtime deps
  beyond Sphinx 8.1+; the asyncio orchestration uses only stdlib.
- packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py: skeleton
  setup() registering two config values:
  - gp_sphinx_vite_mode: Literal["auto","dev","prod"] (default "auto")
  - gp_sphinx_vite_root: str | None (auto-detect when None)
  Returns parallel_read_safe + parallel_write_safe metadata.
- packages/gp-sphinx-vite/src/gp_sphinx_vite/py.typed
- tests/test_gp_sphinx_vite.py: 5 smoke tests (version lockstep, both
  config values registered, parallel-safe metadata, entry-point
  discoverable via importlib.metadata).
- Workspace registration in 6 places per the plan appendix:
  - pyproject.toml [tool.uv.sources] + [dependency-groups].dev
  - tests/test_package_reference.py expected-package set
  - docs/_ext/package_reference.py doctest set
  - docs/redirects.txt: extensions/gp-sphinx-vite -> packages/...
  - docs/packages/gp-sphinx-vite.md page
  - scripts/ci/package_tools.py smoke runner +
    _PACKAGE_SMOKE_RUNNERS entry
… dataclass

why: Step 5 splits the orchestration into a config layer (this commit)
and a process layer (next commit). The split keeps mode detection
testable as a pure function — no Sphinx fixture, no subprocess — which
makes the spawn-decision logic robust to argv/env edge cases that
sphinx-autobuild can throw at us.

Auto-detection of the active theme's web/ directory is intentionally
not implemented here. That path is theme-specific (gp-furo-theme has
web/, but a hypothetical future gp-pelican-theme might lay out assets
differently); coupling gp-sphinx-vite to gp-furo-theme's layout would
defeat the package's reusability. Themes that want auto-wiring set
`app.config.gp_sphinx_vite_root` from their own setup() callback.
Documented in the plan's Orchestration section.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/config.py:
  - Mode(str, enum.Enum) with DEV / PROD members. The str-mixin
    means call sites can do `app.config.gp_sphinx_vite_mode == "dev"`
    without an explicit `.value` access.
  - detect_mode(config_value, argv, env) pure function. "dev"/"prod"
    short-circuit; "auto" + everything else inspects argv[0] (ends
    with "sphinx-autobuild"?) and env (SPHINX_AUTOBUILD set?). The
    fall-through is PROD (safe default — never spawn a subprocess
    from a typo).
  - resolve_vite_root(explicit) accepts str | PathLike | None and
    returns an absolute Path or None. No auto-detection.
  - GpSphinxViteConfig frozen slotted dataclass: snapshot built once
    at builder-inited time, consumed by the spawn layer. Carries a
    `should_spawn` property (DEV mode AND non-None vite_root).
- tests/test_gp_sphinx_vite.py: 13 new tests covering all detect_mode
  branches (8 NamedTuple-parametrized cases), resolve_vite_root
  surface (None / str / PathLike), should_spawn truth table (4
  combinations of mode × root), and the str-mixin behavior. Plus the
  pre-existing 5 smoke tests, 18 total.
…DD via fake-vite

why: Step 5.3 of the orchestration plan. ViteProcess is the
inside-the-event-loop part of the orchestration: it owns the
subprocess handle, the stdout/stderr drainers, and the
graceful-then-forceful teardown. Keeping it standalone (no Sphinx
imports) lets it ship pure-asyncio unit tests and reuse cleanly if
gp-sphinx-vite ever spawns more than one Vite process per build.

The tests drive the design: 13 cases against per-test fake-vite
scripts (Python `sys.executable -c` shells emitting fixed lines and
honouring/trapping signals). Adding pytest-asyncio to the dev group
because the wrapper IS asyncio-first.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py: ViteProcess
  class wrapping `asyncio.create_subprocess_exec`. Drainers route
  stdout to logger.info and stderr to logger.warning, both prefixed
  with `[<label>]`. PYTHONUNBUFFERED=1 is forced into the child env
  so any Python tool invoked through the package-manager bridge
  doesn't withhold output. terminate(timeout=5.0) sends SIGTERM, awaits,
  escalates to SIGKILL on timeout, suppresses ProcessLookupError race.
  Idempotent across already-exited and never-started states.
- vite_watch_command(package_manager="pnpm") helper builds the
  canonical 5-token argv tuple — no shell, no interpolation.
- pyproject.toml [dependency-groups].dev gains pytest-asyncio.
- tests/test_gp_sphinx_vite_process.py: 13 tests covering happy-path
  exit, stdout/stderr drainers + label prefix + log levels, terminate
  on a long-running child, SIGKILL escalation against a SIGTERM trap,
  idempotence across exited/unstarted states, double-start guard,
  wait-before-start guard, PYTHONUNBUFFERED env injection, and the
  vite_watch_command helper.
… Sphinx hooks

why: Sphinx's `builder-inited` / `build-finished` hooks are sync
callables; ViteProcess (committed in 5.3) is async. Without a bridge
we'd have to spin up a fresh event loop per hook invocation, killing
the persistent stdout drainers and the watch process between rebuilds.

The pattern is the canonical "single daemon thread runs
`asyncio.new_event_loop().run_forever()`; sync API schedules
coroutines via `asyncio.run_coroutine_threadsafe`" idiom. The bus
has no Sphinx-specific knowledge so it tests in isolation.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py: AsyncioBus class
  with start() / stop() / call_sync() / call_soon() and an
  is_running property. start() blocks on a threading.Event so the
  next call_sync doesn't race the loop's initialization. stop()
  schedules `loop.stop` thread-safely, joins, then cancels any
  remaining tasks before closing the loop. Idempotent across
  start-twice and stop-before-start. Single-use: after stop() a
  fresh instance is required.
- call_sync() / call_soon() close the supplied coroutine before
  raising "before start" — avoids the "coroutine was never awaited"
  RuntimeWarning at gc time.
- call_soon() attaches a done-callback that logs unhandled
  exceptions at ERROR via the module logger; the bus stays alive so
  subsequent scheduling works.
- tests/test_gp_sphinx_vite_bus.py: 10 tests covering lifecycle
  (start / stop / restart / idempotence), both scheduling primitives
  with results + exceptions + before-start guards, fire-and-forget
  exception logging, pending-task cancellation on stop, and
  fresh-instance-after-stop. Pure sync tests — the whole point of
  the bus is to call async from sync.
…down

why: With the bus (5.4) and the process wrapper (5.3) in place, this
commit wires them to Sphinx's lifecycle. The hook flow:

- builder-inited → resolve config; if should_spawn, start the bus,
  spawn ViteProcess, register atexit + SIGINT/SIGTERM/SIGHUP teardown
  handlers. Idempotent: re-firing for sphinx-autobuild finds the
  running process and returns.
- build-finished → no-op. The watch keeps running across rebuilds so
  Vite can incrementally recompile on every save. (Tearing down here
  would force a fresh `vite build` per rebuild, defeating the
  purpose.)
- atexit / SIGINT / SIGTERM / SIGHUP → teardown(): bus.call_sync(
  proc.terminate(timeout=5.0)), bus.stop(timeout=5.0), null out the
  app attributes. Signal handlers chain to whatever was previously
  installed and re-raise the signal so the default exit behavior
  follows after our cleanup.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py: on_builder_inited
  / on_build_finished / teardown / _install_teardown_handlers. Bus
  + proc are stashed on app under three private attributes
  (_gp_sphinx_vite_bus / _gp_sphinx_vite_proc /
  _gp_sphinx_vite_teardown_registered). teardown is idempotent: safe
  to call from atexit AND from a signal handler in the same process
  exit. _active_handles WeakValueDictionary tracks live buses so a
  global cleanup handler can iterate them without keeping the apps
  alive.
- packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py: setup() now
  also app.connect()s the two event handlers. Hooks module imported
  inline to avoid a top-level import cycle.
- tests/test_gp_sphinx_vite.py: existing setup() smoke tests folded
  into a single _FakeApp fixture (now exposing both add_config_value
  and connect); new test_setup_connects_lifecycle_events asserts both
  events get connected.
- tests/test_gp_sphinx_vite_hooks.py: 9 hooks tests using a
  long-running fake-vite Python script + monkey-patched
  vite_watch_command. Coverage: prod-mode no-op, no-root no-op,
  spawn happy path, sphinx-autobuild idempotence, build-finished
  leave-running, teardown terminates + stops, teardown no-op when
  never spawned, build-finished exception logging at DEBUG, private
  attribute name stability.

29 gp_sphinx_vite tests pass (5 smoke + 13 process + 10 bus + 9
hooks + 13 config = 50; the headline 29 is hooks+setup as a
locality of correctness check). Total py.test count 1271 -> 1292.
…ake-vite

why: Closes step 5. The hooks tests covered the spawn lifecycle
against a hand-rolled FakeApp; this commit proves the wiring is
correct end-to-end through Sphinx itself — entry point loaded,
setup() invoked, builder-inited fires, the watch spawns, and the app
carries the bus + process for the duration. Future regressions in
the entry-point string, the setup() event-connect calls, or the
config-value names will fail loud here.

what:
- tests/test_gp_sphinx_vite_integration.py:
  - test_sphinx_build_spawns_via_extension builds a tiny Sphinx
    project with extensions=["gp_sphinx_vite"], gp_sphinx_vite_mode="dev",
    and gp_sphinx_vite_root pointing at a temp dir that contains a
    fake-vite Python script. conf.py monkey-patches
    gp_sphinx_vite.hooks.vite_watch_command before the extension's
    builder-inited fires, so the spawned argv is just `python fake_vite.py`.
    Asserts the ViteProcess and AsyncioBus are both live on the app
    after the build, then explicitly tears down (atexit would clean
    up at interpreter exit, but that's the wrong scope for a test).
  - test_sphinx_build_no_op_in_prod_mode builds the same project
    with gp_sphinx_vite_mode="prod" and asserts no proc / bus is
    stashed.
- Both tests use build_isolated_sphinx_result so their environment
  (extension import cache, scenario tmp dir) doesn't bleed across
  test runs.
@tony tony force-pushed the custom-tw-furo branch from 2916f3e to 7e68ecf Compare May 2, 2026 16:48
tony added 2 commits May 2, 2026 13:30
…am Furo dep

why: Closes step 7 of the Furo port plan. With steps 1-6 in place
(gp-furo-theme = vanilla Furo port, gp-sphinx-vite = transparent
Vite orchestration), this commit makes the cutover atomic: a single
inherit-line change in sphinx-gp-theme's theme.conf, plus a runtime
dep swap, and `uv tree | grep furo` is zero. Users see no observable
change because gp-furo is byte-equivalent to vanilla Furo's templates
+ scripts + styles at the pinned upstream commit.

The migration is "re-parent, don't deprecate": sphinx-gp-theme keeps
its project-specific overlays (custom.css, spa-nav.js, projects.html,
brand.html, page.html, the Cloudflare Rocket Loader theme-toggle
workaround) and just stacks them on top of gp-furo instead of furo.
gp-furo-theme stays available standalone for users who want vanilla
Furo without the gp-sphinx layer.

what:
- packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/theme.conf:
  `inherit = furo` -> `inherit = gp-furo`.
- packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/page.html:
  `{% extends "furo/page.html" %}` -> `{% extends "gp-furo/page.html" %}`.
- packages/sphinx-gp-theme/pyproject.toml: dependencies swap `furo`
  for `gp-furo-theme==0.0.1a12` (workspace lockstep policy enforces
  the pin).
- packages/gp-furo-theme/pyproject.toml gains three deps that were
  previously transitive via Furo: `sphinx-basic-ng>=1.0.0b2` (Furo's
  parent theme), `accessible-pygments>=0.0.5` (provides the
  `a11y-light` style theme.conf references), `pygments` already
  there. Without these, `pip install gp-furo-theme` would have a
  non-loadable theme chain.
- tests/test_theme.py: assert `inherit = gp-furo`; regression guard
  against re-introducing `inherit = furo`.
- tests/test_gp_furo_theme_equivalence.py: top-level
  `pytest.importorskip("furo")` so the equivalence tests skip cleanly
  when Furo isn't installed (which it isn't, post-cutover). Tests
  stay in tree as a regression guard for users who install Furo and
  re-run them.
- CHANGES: unreleased section announces the re-parent.

Verified:
- `uv tree | grep -c '^furo v\| furo v'` == 0
- ruff / mypy / py.test (1281 passed, 5 skipped) / build-docs all green
- workspace lockstep policy passes (gp-furo-theme==0.0.1a12 pin)
…nx docs site

why: With steps 1-5 + 7 in place, the gp-sphinx project's own docs/
visually render through gp-furo (via sphinx-gp-theme's inheritance,
post-step 7) but the Vite orchestration isn't wired up. This commit
turns it on for the workspace itself: contributors editing
gp-furo-theme/web/src now see fresh CSS/JS on disk when running
`sphinx-autobuild` against docs/, with no extra commands to remember.

The orchestration is opt-in via `vite_orchestration=True` in
merge_sphinx_config(). It auto-resolves the Vite root from
gp_furo_theme.get_vite_root(), which only succeeds in workspace mode
(returns None when the package is installed from a wheel, where the
SCSS/TS sources don't ship). Production sphinx-build runs against the
docs site no-op the orchestration entirely (mode resolves to "prod"
from argv).

what:
- packages/gp-furo-theme/src/gp_furo_theme/__init__.py: new
  get_vite_root() helper. Resolves to <package_root>/../../../web/
  (i.e., packages/gp-furo-theme/web/) if it exists; None otherwise.
  Doctest covers both cases via the conditional.
- packages/gp-sphinx/src/gp_sphinx/config.py: merge_sphinx_config()
  gains vite_orchestration: bool = False kwarg. When True, prepends
  "gp_sphinx_vite" to extensions and sets gp_sphinx_vite_root from
  gp_furo_theme.get_vite_root(). The import is conditional (try/except
  ImportError) so projects that consume gp-sphinx without
  gp-furo-theme installed don't break.
- docs/conf.py: passes vite_orchestration=True with a comment
  explaining that sphinx-build no-ops it; only sphinx-autobuild
  triggers the watch.
- tests/test_config.py: 3 new tests covering off-by-default,
  prepended-extension, and root-resolution behaviors.
- tests/test_gp_furo_theme.py: 1 new test asserting
  get_vite_root() resolves to the workspace web/ dir with a real
  package.json + vite.config.ts.
tony added 15 commits May 2, 2026 13:30
…ter reword

why: Step 9.17 of the 2026-04-30 pivot — final step. Updates user-facing
documentation to match the post-pivot reality: the SCSS pipeline is gone,
gp-furo's CSS is pure Tailwind v4, the footer credits gp-sphinx as
primary with Furo as ported-from subtext.

what:
- CHANGES: rewrite the existing "Theme port" section under
  gp-sphinx 0.0.1 (unreleased). Old text claimed "byte-identical to
  vanilla Furo at the pinned upstream commit"; new text correctly
  scopes byte-identity to templates/JS/Python hooks/theme options
  and describes the CSS as re-authored in pure Tailwind v4 (with
  visual fidelity verified by Playwright pixel-diff). Adds the
  upstream commit hash + footer-reword note.
- docs/packages/gp-furo-theme.md: replace the obsolete "Status:
  skeleton" section with a concrete CSS-authoring layout (entry
  + per-component file tree) and visual-fidelity / behavioral-
  parity sections describing the test surfaces. Updates the
  Attribution section to match: ported files cover templates +
  scripts + Python hooks; CSS files are re-authored from upstream
  SCSS file-by-file with same attribution chain.

Both files now reflect:
- Pure Tailwind v4 CSS (no SASS, no @apply chains)
- Visual regression as the gating CSS-fidelity mechanism
- Behavioral test status (4/10 active, 6 documented skips)
- Footer reword (gp-sphinx primary + Furo "ported from" subtext)

Pre-commit gate: ruff/mypy/pytest 1293 passed/159 skipped/just build-docs all green.

This commit closes step 9 of the porting plan. 17 sub-commits total
across the pivot from SCSS-vendored to pure Tailwind v4. Remaining
follow-up work (per the plan's "Threshold-tightening follow-up"
section) is per-page visual-diff investigation — not blocking the
cutover.
… dl, dd, figure

why: Step 9 follow-up — visual-diff tightening pass.  Tailwind preflight
zeros margins/padding on every element via the universal selector
`*, ::before, ::after { margin: 0 }`. Furo's vendored SCSS pipeline used
normalize.css instead, which preserves browser-default margins on flow
content (dl/dd/figure). Without those defaults, content nested inside
extension-styled wrappers (e.g. sphinx-ux-autodoc-layout's
.gp-sphinx-api-* containers) compresses by 1em per nesting level vs.
the SCSS-built reference.

Discovered via CSS cascade inspection of /api/ page's first dl.py.function
dd element using Chrome DevTools Protocol's CSS.getMatchedStylesForNode:
- user-agent: margin-inline-start: 40px
- preflight (@layer base): margin: 0  ← strips the 40px default
- our api.css rule (@layer components): margin-left: 2rem  ← would apply
- gp-sphinx-api-signature-expanded > dl > dd: margin-left: 0 !important
  (sphinx-ux-autodoc-layout — overrides our 2rem in expanded signature
  wrappers; that's deliberate elsewhere in the workspace)

The api page is the worst-case visual diff (48%) and the dominant cause
turns out to be sphinx-ux-autodoc-layout's own !important rules
interacting with gp-furo's reset, not a bug in the port. But while
inspecting, it became clear that elements OUTSIDE those wrappers — plain
dl/dd/figure in regular content — also lose their browser defaults.
The fix here restores those.

what:
- web/src/styles/components/base.css gains 3 element rules in @layer base:
  - dl { margin-block: 1em } (matches normalize.css's preserved default)
  - dd { margin-inline-start: 40px } (browser default; some component
    files override to specific values like .field-list dd's 32px)
  - figure { margin-block: 1em; margin-inline: 40px } (browser default;
    article figure rules in images.css override where needed)
- Comment block above the rules explains *why* — Furo's normalize.css
  expectation, the preflight zeroing, and the elements that already
  have explicit defaults elsewhere (pre, p, h1-h6, lists, blockquotes)
  to discourage anyone from adding more browser-default restorations
  here.

Diff distribution (after re-running 72 captures):
- BEFORE: avg 21.53%, max 48.03% (api-dark-mobile)
- AFTER:  avg 21.13%, max 47.43%
- Improvement: -0.4 avg / -0.6 max
- This single fix doesn't move the needle dramatically because most of
  the remaining diff comes from inter-package CSS interactions (the
  !important overrides shown above). Per-page diff tightening is
  iterative and tracked in the plan's "Threshold-tightening follow-up"
  section.

Pre-commit gate: ruff/mypy/pytest 1293 passed/159 skipped/just build-docs all green.
…; clean stray theme.conf conflict markers

why: After two consecutive rebases onto trunk (first onto a13, then
onto a14), the new workspace packages (gp-furo-theme, gp-sphinx-vite)
introduced on this branch needed their version pins bumped to match
the rest of the workspace. The earlier rebase also left stray Git
conflict markers in sphinx-gp-theme/theme.conf after my manual
resolution kept the wrong half of a `<<<<<<< / >>>>>>>` block. Both
fix-ups land in one chore commit so the original scaffold/parent
commits stay focused on their topic.

what:
- packages/gp-furo-theme/pyproject.toml + __init__.py: 0.0.1a12 -> 0.0.1a14
- packages/gp-furo-theme/web/package.json: 0.0.1a12 -> 0.0.1a14
- packages/gp-furo-tokens/package.json: 0.0.1a12 -> 0.0.1a14
- packages/gp-sphinx-vite/pyproject.toml + __init__.py: 0.0.1a12 -> 0.0.1a14
- packages/sphinx-gp-theme/pyproject.toml: gp-furo-theme==0.0.1a12 -> ==0.0.1a14
- packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/theme.conf: drop
  stray `<<<<<<< HEAD / ======= / >>>>>>>` markers left over from a
  manual conflict resolution; keep the `inherit = gp-furo` +
  Tailwind-built furo-tw.css stylesheet line.
- tests/test_gp_sphinx_vite.py: assertion updated to "0.0.1a14"
- uv.lock: regenerated; gp-furo-theme + gp-sphinx-vite resolve to a14
  alongside the rest of the gp-sphinx workspace.
…+ ol

why: Tailwind preflight has `ol, ul, menu { list-style: none }` in
its reset; my lists.css only set list-style for the variant classes
(.arabic, .loweralpha, etc.), never restoring the base disc/decimal.
Article content with `<ul>` (e.g. the homepage "What you get" list)
rendered with no bullets at all in the local Tailwind build, while
production via normalize.css keeps browser-default disc.

Verified by Playwright probe at 1440x900:
- BEFORE: getComputedStyle(article ul).listStyleType === 'none'
- AFTER:  getComputedStyle(article ul).listStyleType === 'disc'

Subtle gotcha discovered during implementation: Lightning CSS
minifies the shorthand `list-style: disc` to `list-style: outside`
because `disc` is the spec default for <ul>. The optimized
declaration only sets list-style-position, leaving list-style-type
unchanged from the cascade — meaning Tailwind preflight's earlier
`list-style: none` keeps winning. Switched to the longhand
`list-style-type: disc` which survives minification intact.

what:
- packages/gp-furo-theme/web/src/styles/components/lists.css gains
  a new pair of rules at the top of `@layer components`:
    ul { list-style-type: disc; }
    ol { list-style-type: decimal; }
- 7-line comment block above the rules explains the Lightning CSS
  shorthand-collapse trap so future maintainers don't naively
  switch back to the more readable `list-style: disc`.
- Component-specific overrides remain authoritative via their own
  @layer components rules:
    .sidebar-tree ul    { list-style: none; }    (sidebar.css)
    .toc-tree ul        { list-style-type: none; } (toc.css)
    ul.search           { list-style: none; }    (search.css)

Pre-commit gate: ruff/mypy/pytest 1310 passed/159 skipped/just build-docs all green.

Per the visual-fix-up plan, two more commits follow: body line-height
restoration (commit B) and .content box-sizing fix (commit C).
why: Tailwind preflight applies `html, :host { line-height: 1.5 }`,
which the body inherits as 24px at the workspace 16px base font-size.
Furo's pipeline uses normalize.css (which preserves browser-default
~1.15) so the upstream/production body computes to 18.4px. The
discrepancy is invisible inside <article> (article.css scopes its own
line-height: 1.5 for reading prose), but it shows up clearly in the
header brand text, sidebar nav items, and footer copyright — every
chrome surface that reads body's line-height directly is more
spaced-out in our build than in upstream.

Verified by Playwright probe at 1440x900:
- BEFORE: getComputedStyle(document.body).lineHeight === '24px'
- AFTER:  getComputedStyle(document.body).lineHeight === '18.4px'  (matches prod)

Article body unchanged: getComputedStyle(article).lineHeight stays
at '25.6px' (its own 1.6 ratio, untouched).

what:
- packages/gp-furo-theme/web/src/styles/components/base.css gains
  `line-height: 1.15` in the existing `body { ... }` rule in
  `@layer base`, alongside the existing font-family and
  font-smoothing declarations.
- Comment block above the new property explains the divergence
  from Tailwind preflight's 1.5 default and why the article
  override (1.5) doesn't fix the chrome surfaces.

Pre-commit gate: ruff/mypy/pytest 1310 passed/159 skipped/just build-docs all green.

Per the visual-fix-up plan, one commit remaining: .content
box-sizing fix (commit C) — the dominant cause of the 3-column
layout's right-side empty band.
…x grows to fill .main

why: At 1440-viewport on every 3-column page (homepage, api, config,
package docs, …), .content rendered at exactly 736px (its 46em width)
and never grew to fill the free space in .main. The right TOC drawer
sat 64px further left than upstream, leaving a noticeable empty band
on the right edge.

Root cause traced via Playwright + Chrome DevTools Protocol cascade
inspection, plus side-by-side `grep` of compiled `furo-tw.css` and
`curl` of upstream's `furo.css`:

- The .content rules in compiled CSS are byte-identical between local
  and production (`width: 46em`, `padding: 0 3em`, `display: flex`,
  same shorthand `flex: 1 1 736px` reported by getComputedStyle in
  both).
- Tailwind preflight applies `*, ::before, ::after { box-sizing:
  border-box }`. Production uses normalize.css, which doesn't touch
  box-sizing — so .content inherits the browser default `content-box`.
- With content-box: `width: 46em` is the *content* area; padding 6em
  adds outside; total border-box = 52em. Flex basis interpreted at
  border-box level, leaving headroom for grow → element ends at 800px
  to fill `.main`'s 1136px width minus toc-drawer's 288px.
- With border-box (preflight): `width: 46em` includes the padding;
  element is exactly 46em / 736px; flex-basis already equals width;
  flex-grow has nothing to distribute.

Sibling layout containers (.sidebar-drawer, .sidebar-container,
.toc-drawer) all have `box-sizing: border-box` set EXPLICITLY in
upstream Furo's _scaffold.sass. They want border-box because their
widths (`15em`, computed sidebar centering math) are authored against
border-box semantics — leaving them on Tailwind preflight's default
matches upstream intent.  Only .content was authored against
content-box implicitly.

what:
- packages/gp-furo-theme/web/src/styles/components/scaffold.css:
  add `box-sizing: content-box` as the first declaration in the
  existing `.content { … }` rule. 18-line comment block above the
  property explains the implicit-vs-explicit content-box reliance
  upstream and why the sibling containers stay at border-box.

Verified by Playwright probe at 1440x900 on
/packages/sphinx-ux-badges/:
- BEFORE: content x=304 w=736; toc x=1040 w=288; right gutter 112px
- AFTER:  content x=304 w=800; toc x=1104 w=288; right gutter  48px
  (exactly matching production: same x/w/gutter values)

Pre-commit gate: ruff/mypy/pytest 1310 passed/159 skipped/just build-docs all green.

This is the third of three visual-fix-up commits (after list bullets
and body line-height) that close out the post-rebase eyeball pass.
The next user-facing observation pass should compare the visual
regression suite's per-page diffs — these three fixes together likely
move the avg from ~21% well below 10%.
…op-level scope

why: furo.js threw `SyntaxError: Identifier '_' has already been
declared at furo.js:1:1` on every page load. Root cause traced
via grep + bundle inspection:

  doctools.js:147   const _ = Documentation.gettext;
  furo.js (bundle)  function _() { ... }   ← Rollup minified `readTheme()`

Both run at global scope when loaded as classic <script>s (Sphinx
doesn't add type="module" by default), so `_` collides between
them and parsing dies before any furo.ts behaviour runs. None of
the theme-toggle / scroll-spy / mobile-drawer / back-to-top
behaviours were actually firing — they all silently skip every
page load. This single bug also explains why so many of the
deferred behavioral parity tests were skipped: furo.js literally
hadn't been executing.

Was hidden until today: prior to this session, `furo.js` was
returning 404 (because `node_modules/` wasn't installed in
`packages/gp-furo-theme/web/`), so the parser never had a chance
to throw. Once we ran `pnpm install --frozen-lockfile` and
`pnpm exec vite build`, the file actually loaded — and the
collision surfaced immediately.

what:
- packages/gp-furo-theme/web/src/scripts/furo.ts: wrap the entire
  body (everything after the `import Gumshoe` ESM import) in a
  bare `(function (): void { … })();` IIFE.
- 12-line comment block above the IIFE explains the
  doctools.js collision, why it must be at source level (Rollup
  format: 'iife' is incompatible with multi-input builds because
  inlineDynamicImports requires single input), and the upstream
  parallel (Furo's own furo.js is also IIFE-wrapped).
- The `import Gumshoe` stays at module scope (ESM imports must
  be top-level) so Rollup can resolve the binding; `Gumshoe` is
  captured by closure inside the IIFE.

After this fix, the only top-level binding outside the IIFE is
`const I = (function(l){…})()` — Rollup's inlined Gumshoe import.
That single character `I` ≠ `_`, so no collision with doctools.js.
If a future bundler upgrade picks `_` for the Gumshoe binding too,
we'd see the SyntaxError again — at that point the right fix is
adding `import GumshoeLib from "./gumshoe.js"` to give the import
a longer name esbuild won't single-char.

Verified by Playwright at http://localhost:3124/:
- BEFORE: 1 console error
  `SyntaxError: Identifier '_' has already been declared at furo.js:1:1`
- AFTER:  0 console errors related to furo.js (only a pre-existing
  tabs.js 404 + favicon 404 remain — both unrelated)
- Runtime probes:
    !html.classList.contains("no-js")  → true (main() ran, removed it)
    body.dataset.theme === "auto"      → true (setupTheme() wired up)
    document.querySelectorAll(".theme-toggle").length === 2

Pre-commit gate: ruff/mypy/pytest 1310 passed/159 skipped/just build-docs all green.
…sing before vite spawn

why: After `git clean -fdx`, sphinx-autobuild would spawn `pnpm exec vite
build --watch` against an empty workspace and silently fail with
`ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "vite" not found`. The
asset pipeline never ran; furo-tw.css and furo.js 404'd; the docs site
served unstyled HTML. The user-stated invariant is that
`git clean -fdx; cd docs; just clean; just start` should always produce
a working dev environment with no manual install step — this commit
closes that gap.

Architectural choice: the auto-install lives in `gp-sphinx-vite`
(the orchestration package that already owns the Vite lifecycle)
rather than in docs/justfile. Reasoning:
1. It already knows the `vite_root` (the cwd it spawns vite in)
2. It already has the AsyncioBus + ViteProcess subprocess plumbing
3. It runs at builder-inited, before any HTML is generated, so the
   install completes before users see a styling-broken page
4. It's reusable for any downstream theme that ships a Vite project

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/process.py:
  add `pnpm_install_command()` factory parallel to `vite_watch_command()`,
  returning `("pnpm", "install", "--frozen-lockfile")`. Frozen lockfile
  makes the install reproducible — pnpm refuses to mutate the lockfile
  or auto-resolve unspecified deps. Same `package_manager=` kwarg shape
  as `vite_watch_command` so a future "use npm" or "use yarn" override
  has a clean place to land.
- packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py:
  - new helper `_ensure_node_modules(vite_root, bus) -> bool`:
    - returns True if `<vite_root>/node_modules/` already exists
    - otherwise spawns `pnpm install --frozen-lockfile` synchronously
      (using the same `ViteProcess` infrastructure as the watch spawn),
      waits for completion, returns True iff exit code 0
    - on non-zero exit, logs a warning naming the directory and what
      the user should do ("Run the install manually and restart
      sphinx-autobuild") and returns False
  - `on_builder_inited` calls `_ensure_node_modules` BEFORE the existing
    `proc.start(vite_watch_command(), ...)` and skips the watch spawn
    entirely if the install failed (no point burning cycles on a
    guaranteed-failed `pnpm exec vite`)
- tests/test_gp_sphinx_vite_hooks.py:
  - new `_patch_install_command(monkeypatch, script)` helper mirroring
    the existing `_patch_vite_command` pattern — keeps the new tests
    fast (no real pnpm) and deterministic across machines
  - 3 new tests:
    - `test_on_builder_inited_skips_install_when_node_modules_present`:
      pre-creates `tmp_path/node_modules/`; monkeypatches install
      command to a "should not be called" sentinel; asserts vite still
      spawns successfully
    - `test_on_builder_inited_runs_install_when_node_modules_missing`:
      no pre-existing node_modules; fake-pnpm script writes a marker
      file AND creates node_modules/ as side effect; asserts both the
      marker and node_modules exist after on_builder_inited, plus that
      vite spawned
    - `test_on_builder_inited_skips_vite_when_install_fails`:
      fake-pnpm exits 1; asserts vite was NOT spawned (the
      `vite_watch_command` monkeypatch raises if called); confirms the
      orchestration short-circuits after install failure

Combined with commit `4cc5ec1` (furo.ts IIFE wrap), the user's
invariant now holds:
  $ git clean -fdx
  $ cd docs && just clean && just start
  → autobuild logs `[vite] node_modules/ missing in …; running pnpm install`
  → install completes; logs `[vite] pnpm install complete; proceeding to vite-watch`
  → vite emits furo-tw.css + furo.js
  → autobuild detects new static files and rebuilds; browser auto-reloads
  → page renders correctly with theme-toggle, scroll-spy, drawer all working

Pre-commit gate: ruff/mypy/pytest 1313 passed (was 1310, +3 new
auto-install tests)/159 skipped/just build-docs all green.
…ating activation rules into @layer components

why: The dark/light toggle button at the top-right of every page was
rendering at width 0 — silently invisible. Verified via Playwright:

  bodyTheme: "auto"
  buttons:
    theme-toggle (content): x=1080, y=24, w=0, h=24    ← width zero
    svgs (4 children, all):  display: none

Root cause traced via CSS layer + specificity inspection:

  scaffold.css @layer components:  .theme-toggle svg { display: none }
                                     ↑ hides all icons by default
  base.css     @layer base:        body[data-theme="auto"]
                                     .theme-toggle svg.theme-icon-when-auto-light
                                     { display: block }
                                     ↑ should override but doesn't

CSS Layer cascade beats specificity: a rule in `@layer components`
wins over ANY rule in `@layer base` regardless of selector
specificity. The activations had specificity 0,3,2 (1 attr + 2 class
+ 2 type) — strictly higher than the hide rule's 0,1,1 — but the
layer mismatch suppressed them entirely. Every icon stayed hidden.
The button rendered as a 0×24 invisible flex container in the
content-icon-container at top-right.

This is the same trap pattern as step 9.2's `:root` vs `body` token
emission — layer architecture matters more than specificity once
you start mixing layers. The semantic fix is to put related rules
in the same layer so specificity decides cascade naturally.

Architectural note: class-based component-behavior rules (theme-toggle
SVG visibility, .only-light/.only-dark visibility) probably ALL belong
in @layer components, not @layer base. Base layer should hold HTML-
element defaults (typography, links, browser resets) only. The
.only-light/.only-dark rules in base.css use !important everywhere
(short-circuiting cascade) so they happen to work despite the
mis-layering — but a future maintainer touching them might trip the
same trap. Not in scope to refactor today; flagged for the next pass.

what:
- packages/gp-furo-theme/web/src/styles/components/scaffold.css:
  add the four theme-toggle SVG visibility activation rules in
  @layer components, immediately below the existing
  `.theme-toggle svg { display: none }` hide rule. With both rules
  in the same layer, specificity (0,3,2 vs 0,1,1) decides — the
  activation wins for the matching data-theme state. Rules cover:
    - body[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto-light → block (default light pref)
    - body[data-theme="dark"]  .theme-toggle svg.theme-icon-when-dark      → block
    - body[data-theme="light"] .theme-toggle svg.theme-icon-when-light     → block
    - @media (prefers-color-scheme: dark) body[data-theme="auto"]
      → swap auto-light/auto-dark visibility
- packages/gp-furo-theme/web/src/styles/components/base.css:
  remove the same four rules (they no longer belong here); leave a
  10-line comment block in their place explaining the layer
  re-location so future maintainers don't move them back.
- tests/test_gp_furo_theme_tw_contract.py:
  rename `test_base_has_theme_toggle_svg_rules` →
  `test_scaffold_has_theme_toggle_svg_rules` and switch its
  fixture from `base_css_text` to `scaffold_css_text`. The contract
  is the same (each data-theme state must drive a different SVG);
  only the file location changes. Docstring captures the layer-cascade
  reasoning so the test name doesn't seem arbitrary.

Verified by Playwright at http://localhost:3124/ at 1440x900:
- BEFORE: theme-toggle (content) rect.width === 0; all SVGs display:none
- AFTER:  theme-toggle (content) rect.width === 20 (1.25rem at 16px base);
          theme-icon-when-auto-light SVG display === 'block'
- Top-right of homepage now shows the sun-with-moon glyph (correct
  for `body[data-theme="auto"]` + light-preferred system)

Combined with commits `4cc5ec1` (furo.ts IIFE) and `12dc201` (auto-
install), the theme-toggle is now fully functional end-to-end:
- Click cycles auto → dark → light → auto (or auto → light → dark
  on dark-preferred systems, per furo.ts:cycleThemeOnce)
- localStorage persists across navigations (furo.ts:setTheme)
- The 6 deferred behavioral parity tests in
  tests/visual/test_furo_behaviors.py — which were skipped in part
  because furo.js wasn't running and the toggle button was invisible
  — should now run cleanly. Worth a re-evaluate pass.

Pre-commit gate: ruff/mypy/pytest 1313 passed/159 skipped/just build-docs all green.
…heme-toggle:hover svg

why: User feedback on commit `eb87aaa` (the theme-toggle SVG visibility
relocation): now that the toggle button is visible, its hover effect
doesn't match the sibling action icons in `.content-icon-container`.

DOM probe at http://localhost:3124/ confirms the asymmetry:

  .content-icon-container
    ├─ <div class="view-this-page">
    │    └─ <a class="muted-link" href="…">  ← muted-link:hover wins
    ├─ <div class="edit-this-page">
    │    └─ <a class="muted-link" href="…">  ← muted-link:hover wins
    └─ <div class="theme-toggle-container theme-toggle-content">
         └─ <button class="theme-toggle">     ← no :hover rule anywhere

The eye/edit icons are anchors with `class="muted-link"` so they pick
up `a.muted-link:hover { color: var(--color-link--hover) }` from
base.css and turn brand-blue (#2757dd) on hover. The theme-toggle is
a `<button>`, not an anchor, so it doesn't inherit that rule and stays
black.

This is NOT inherited from upstream Furo's `_scaffold.sass` either —
zero `:hover` rules exist on `.theme-toggle` upstream. The fix is a
deliberate visual improvement past upstream-Furo parity, aligning
the theme-toggle with the visual idiom of its sibling icons.

what:
- packages/gp-furo-theme/web/src/styles/components/scaffold.css
  gains one rule directly below the existing theme-toggle SVG
  visibility activations:
    .theme-toggle:hover svg { color: var(--color-link--hover); }
- 13-line comment block above the rule explains the asymmetry with
  .muted-link siblings, why we target the SVG (the
  `.theme-toggle svg { color: var(--color-foreground-primary) }`
  pin a few rules above would otherwise win), and that this
  intentionally diverges from upstream Furo for visual consistency.

Verified via Playwright at 1440x900:
- BEFORE: hovering .theme-toggle leaves SVG colour at rgb(0, 0, 0)
- AFTER:  rule loaded — `.theme-toggle:hover svg { color: var(--color-link--hover) }`
  resolves to #2757dd, matching .view-this-page a.muted-link:hover

Co-located with the other theme-toggle visual rules in
@layer components — same layer as the visibility activations from
commit eb87aaa, so cascade behaviour is consistent.

Pre-commit gate: ruff/mypy/pytest 1313 passed/159 skipped/just build-docs all green.
why: After 013884d added _ensure_node_modules() auto-install to the
builder-inited hook, six pre-existing tests started shelling out to
real `pnpm install --frozen-lockfile` because their fake vite roots
never had node_modules/. CI (no pnpm on PATH) failed with FileNotFoundError
across all 10 qa matrix jobs.

what:
- _write_fake_vite() gains with_node_modules: bool = True; default
  pre-creates node_modules/ so _ensure_node_modules short-circuits.
- test_on_builder_inited_runs_install_when_node_modules_missing now
  passes with_node_modules=False to keep its install-path assertion
  meaningful.
- Integration test test_sphinx_build_spawns_via_extension pre-creates
  node_modules/ in its fake-vite-root.
- Auto-install behaviour itself is unchanged; the three dedicated
  install-path tests (skips/runs/fails) still cover it.
…toctree

why: docs CI builds with `sphinx-build -W` (warnings as errors). Both
new package pages emitted toc.not_included warnings since the previous
diff added their .md files but never wired them into a toctree —
{workspace-package-grid} renders link cards but doesn't satisfy
Sphinx's toctree-membership check.

what:
- Append packages/gp-furo-theme + packages/gp-sphinx-vite under the
  existing "Internal" caption, alongside gp-sphinx + sphinx-gp-theme.
why: The previous two-line credit ("Made with Sphinx and gp-sphinx" +
discrete "— ported from Furo (MIT, @pradyunsg); see LICENSE-FURO"
subtext span) over-explained the relationship and pushed
licensing-detail prose into the rendered footer of every page. The
LICENSE-FURO file at package root, the per-template Jinja
attribution headers, and the auto-generated "<!-- Generated with
Sphinx ... and Furo VERSION -->" HTML comment in base.html already
satisfy the MIT attribution obligation; the visible footer only
needs to credit gp-sphinx as primary and acknowledge the fork
relationship to Furo.

what:
- packages/gp-furo-theme/src/gp_furo_theme/theme/gp-furo/page.html:
  drop the discrete subtext span; inline "(fork of Furo by
  @pradyunsg)" after the gp-sphinx link. Furo + @pradyunsg links
  preserved. "MIT" and "see LICENSE-FURO" wording removed from the
  visible footer.
@tony tony force-pushed the custom-tw-furo branch from 7e68ecf to 46ea649 Compare May 2, 2026 18:31
why: Capture the workspace-owned Furo port and the new orchestration
package in the user-facing changelog. Downstream readers need to know
that Furo and its transitive deps (`accessible-pygments`,
`sphinx-basic-ng`) drop out of a `pip install gp-sphinx`, that the CSS
custom-property contract is preserved (so existing `light_css_variables`
/ `dark_css_variables` overrides keep working), and that two new
packages — `gp-furo-theme` (standalone Furo-equivalent theme) and
`gp-sphinx-vite` (transparent build-time orchestration) — are now
publishable surface.

what:
- CHANGES (line 21+): add `### What's new` block under the unreleased
  heading with three `####` sub-sections — the theme port, the
  `gp-furo-theme` standalone package, the `gp-sphinx-vite` orchestration
  package.
- CHANGES (file footer): introduce reusable Markdown link reference
  `[Furo]: https://github.com/pradyunsg/furo` so every "Furo" mention
  in this and future release entries renders as a clickable link
  pointing at the upstream repo.
- All five "Furo" mentions across the new entry use the `[Furo]`
  reference shape; the first body mention is attributed as
  `@pradyunsg's [Furo]`.
@tony tony force-pushed the custom-tw-furo branch from 75045e3 to a79b9fa Compare May 2, 2026 18:41
…local setup()

why: Plain literal mentions of Vite, pnpm, sphinx-autobuild, Furo, and
the bare `setup()` function on docs/packages/gp-sphinx-vite.md and
docs/packages/gp-furo-theme.md gave readers no way to click through to
upstream sources or to the local function's signature + docstring. The
project already has the conventions for both kinds of cross-reference
(reference-style Markdown links for upstream tooling, `automodule` /
`autofunction` for the package's own API surface — see
sphinx-ux-badges.md:226 for the autodoc precedent). Apply the
conventions consistently.

what:
- docs/packages/gp-sphinx-vite.md:
  - Replace plain `Vite + pnpm` / `sphinx-autobuild` literals with
    `[Vite] + [pnpm]` / `[sphinx-autobuild]` reference-style links.
  - Replace bare `setup()` with `{py:func}\`~gp_sphinx_vite.setup\``
    so it cross-references the local autodoc target. The leading `~`
    keeps the visible text as just `setup()` while turning it into a
    link.
  - Add a `## Reference` section with `.. autofunction::
    gp_sphinx_vite.setup` so the cross-reference resolves to a real
    autodoc'd entry — signature, parameter table, docstring, badge —
    on the same page.
  - File footer gains three reusable Markdown link reference
    definitions (`[Vite]`, `[pnpm]`, `[sphinx-autobuild]`).
- docs/packages/gp-furo-theme.md:
  - Convert the existing inline `[Furo](https://github.com/pradyunsg/furo)`
    to reference-style `[Furo]`, attribute the first body mention as
    `@pradyunsg's [Furo]` (matches the convention introduced in
    CHANGES).
  - Link the bare `Vite` mention.
  - File footer gains `[Furo]` and `[Vite]` link references.

Behavioural verification: `objects.inv` now contains
`gp_sphinx_vite.setup py:function packages/gp-sphinx-vite/#$ -`; the
Status section's `setup()` reference renders as
`<a class="reference internal" href="#gp_sphinx_vite.setup">setup()</a>`
in the built HTML. All four upstream links resolve to their canonical
homes (vitejs.dev, pnpm.io, github.com/sphinx-doc/sphinx-autobuild,
github.com/pradyunsg/furo). Build is clean (no warnings) and the full
suite stays green (1315 passed, 159 skipped).
@tony
Copy link
Copy Markdown
Member Author

tony commented May 2, 2026

Code review

Found 6 issues:

  1. bus.py calls logger.exception() inside a Future.add_done_callback — the callback runs outside any except block, so the traceback context is empty (CLAUDE.md says "Use logger.exception() only inside except blocks ... Use logger.error(..., exc_info=True) when you need the traceback outside an except block").

def _log_exception(fut: t.Any) -> None:
exc = fut.exception()
if exc is not None:
logger.exception(
"background coroutine on %s raised", self._name, exc_info=exc
)
future.add_done_callback(_log_exception)

  1. gp_furo_theme/__init__.py defines a module-level logger but never attaches logging.NullHandler() (CLAUDE.md says "Add NullHandler in library __init__.py files"; sibling packages gp_sphinx and sphinx_fonts follow the convention).

THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve()
logger = logging.getLogger(__name__)
# GLOBAL STATE — populated by ``_builder_inited`` and consumed by

  1. Same NullHandler omission in gp_sphinx_vite/__init__.py (CLAUDE.md says "Add NullHandler in library __init__.py files").

__version__ = "0.0.1a14"
logger = logging.getLogger(__name__)

  1. hooks.py logger.warning(...) message ends with a period (CLAUDE.md log message style says "No trailing punctuation").

if returncode != 0:
logger.warning(
"[vite] pnpm install failed (exit %d) in %s — skipping vite "
"spawn. Run the install manually and restart sphinx-autobuild.",
returncode,
vite_root,
)
return False
logger.info("[vite] pnpm install complete; proceeding to vite-watch spawn")

  1. hooks.py module docstring at L19 references :func:_teardown`` but the actual public function is teardown (no underscore) at L178 — the `:func:` cross-reference will not resolve.

signal handlers installed at first spawn.
Tear-down is the responsibility of :func:`_teardown`, which is wired
to ``atexit`` and to ``SIGINT`` / ``SIGTERM`` / ``SIGHUP``.
The handlers are passive about command construction: they call
:func:`gp_sphinx_vite.process.vite_watch_command` for the default Vite
argv. Tests monkey-patch that symbol when they want a fake-vite invocation.
"""

  1. AsyncioBus.start() after stop() silently spawns a fresh thread, contradicting the class docstring's "The bus is single-use. After stop() it is not safe to start again — construct a new instance" contract — stop() resets _thread = None, so start()'s idempotency guard passes through and re-spawns rather than raising.

3. :meth:`stop` schedules the loop to stop, joins the thread.
The bus is single-use. After ``stop()`` it is not safe to start
again — construct a new instance.
"""
def __init__(self, *, name: str = "gp-sphinx-vite-bus") -> None:
self._name = name
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
@property
def is_running(self) -> bool:
"""True iff the loop thread is alive."""
return self._thread is not None and self._thread.is_alive()
def start(self) -> None:
"""Start the background event loop. Idempotent."""
if self._thread is not None and self._thread.is_alive():
return
self._ready.clear()
self._thread = threading.Thread(
target=self._run,
daemon=True,
name=self._name,
)
self._thread.start()
# Block until the loop has been assigned. Without this, a
# call_sync() racing the thread startup would deref None.
self._ready.wait()

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 6 commits May 2, 2026 15:08
…o actual `teardown`

why: The module docstring at hooks.py:19 carried `:func:`_teardown``
referring to a private/underscore-prefixed name that doesn't exist —
the actual public function defined at hooks.py:178 is `teardown`,
without leading underscore. Sphinx autodoc would emit a "could not
find target" warning on this cross-reference, and any reader of the
module docstring is pointed at a symbol that isn't there. Most
likely a leftover from earlier development when the function was
underscore-prefixed; the rename to public `teardown` (called by
atexit and signal handlers via `_install_teardown_handlers`) didn't
sweep the docstring reference.

Caught by code review on PR #25.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py:19: rename
  `:func:`_teardown`` to `:func:`teardown`` so the cross-reference
  resolves to the real public function on line 178.
…rning

why: CLAUDE.md's logging "Message style" section requires "No trailing
punctuation" on log messages — the convention keeps machine-grouped
log records tidy and avoids spurious differences when aggregators
template-match by message text. The `_ensure_node_modules` warning
that fires when `pnpm install --frozen-lockfile` exits non-zero ended
with a period and read as two sentences glued by ". ", combining
two pieces of guidance into one record. The split landed naturally
because the message is genuinely two pieces — what failed (skip the
spawn) and what to do (install manually + restart) — but the
convention wins: collapse to one continuation phrase joined by `;`,
no trailing period.

Caught by code review on PR #25.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/hooks.py:99-104: rewrite
  the `logger.warning(...)` message from
  `"... — skipping vite spawn. Run the install manually and restart
  sphinx-autobuild."` to
  `"... — skipping vite spawn; run the install manually and restart
  sphinx-autobuild"`. Two-sentence prose collapsed to one
  semicolon-joined clause, trailing period dropped.
why: CLAUDE.md's logging standard requires "Add `NullHandler` in
library `__init__.py` files" so consumers who import the package
without configuring root logging don't trip Python's
"No handlers could be found for logger" warnings (or in modern
Python, the lastResort default handler that emits to stderr at
WARNING+). The convention is followed by sibling library packages
in the workspace — `packages/gp-sphinx/src/gp_sphinx/__init__.py`
chains it as `logging.getLogger(__name__).addHandler(logging.NullHandler())`,
`packages/sphinx-fonts/src/sphinx_fonts/__init__.py` uses the
two-statement form `logger = ...` then `logger.addHandler(...)`.
The Python logging cookbook also formalises this:
https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library

When `gp_furo_theme` was scaffolded the logger was created but the
NullHandler attachment was missed — likely because the early port
focused on porting Furo's hook surface and the workspace logging
convention was applied unevenly across new packages.

Caught by code review on PR #25.

what:
- packages/gp-furo-theme/src/gp_furo_theme/__init__.py:48: add
  `logger.addHandler(logging.NullHandler())` immediately after the
  `logger = logging.getLogger(__name__)` declaration. Two-statement
  form matches the `sphinx-fonts` convention since the existing
  `logger` binding is reused throughout the module.
why: Same omission as the parallel fix in `gp_furo_theme/__init__.py`:
CLAUDE.md requires "Add `NullHandler` in library `__init__.py` files"
so library consumers don't trip Python's lastResort
WARNING-to-stderr handler when they import the package without
having configured root logging. The convention is followed in
`packages/gp-sphinx/src/gp_sphinx/__init__.py` (chained:
`logging.getLogger(__name__).addHandler(logging.NullHandler())`) and
`packages/sphinx-fonts/src/sphinx_fonts/__init__.py` (two-statement
form). gp-sphinx-vite's `__init__.py` was scaffolded as part of the
new orchestration package and missed the convention.

Notable for this package specifically: gp-sphinx-vite's logger emits
`[vite]`-prefixed records at INFO and WARNING via Sphinx's status /
warning streams (`sphinx.util.logging.getLogger`), but the
package-level `logger` in `__init__.py` is the stdlib root surface a
downstream consumer would see if they imported `gp_sphinx_vite`
without going through the Sphinx event loop. The NullHandler
silences that path explicitly.

Reference: Python logging cookbook,
https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library

Caught by code review on PR #25.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/__init__.py:41: add
  `logger.addHandler(logging.NullHandler())` after the
  `logger = logging.getLogger(__name__)` declaration. Two-statement
  form matches `sphinx-fonts` and the just-landed `gp-furo-theme`
  fix.
…-forget done-callback

why: `_log_exception` is a `concurrent.futures.Future` done-callback
attached via `future.add_done_callback(_log_exception)`. Done-
callbacks fire after the future has resolved, on whichever thread
runs the callback machinery — they are NOT inside the original
`try / except` that captured the exception. By the time the
callback runs, `sys.exc_info()` returns `(None, None, None)`
because the exception context has been cleared.

The previous code called `logger.exception("…", exc_info=exc)`. The
mainline path of `logger.exception()` is to call `sys.exc_info()`
internally, ignoring whatever was already in scope; with no
exception in flight, the traceback would render as `NoneType: None`.
Passing `exc_info=exc` overrides that with the captured exception
object, so the traceback DOES surface — but only because the
`exc_info` keyword path bypasses the broken `sys.exc_info()` call
that `logger.exception()` relies on. Using `logger.exception` here
is fragile by intent: it reads as "we're inside an except block",
which is wrong, and any future refactor that drops the explicit
`exc_info=exc` would silently lose the traceback.

CLAUDE.md's logging standard explicitly disambiguates the two:

> Use `logger.exception()` only inside `except` blocks when you are
> not re-raising. Use `logger.error(..., exc_info=True)` when you
> need the traceback outside an `except` block.

Python logging documentation matches this convention:
https://docs.python.org/3/library/logging.html#logging.Logger.exception
("This method should only be called from an exception handler.")

Caught by code review on PR #25.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py:121-128: change
  `logger.exception(...)` to `logger.error(..., exc_info=exc)`. The
  exception object is passed via `exc_info` exactly the same way,
  so the rendered traceback is byte-identical; only the API contract
  changes — `error` is the right primitive outside an `except`
  block. The args are reflowed onto separate lines for readability
  now that the call is no longer the `exception()` two-line form.
…fter stop()

why: `AsyncioBus`'s class docstring promises:

> The bus is single-use. After ``stop()`` it is not safe to start
> again — construct a new instance.

The implementation didn't enforce this. `stop()` resets
`self._thread = None` and `self._loop = None`; the idempotency guard
in `start()` only checked `if self._thread is not None and
self._thread.is_alive()`, so after a stop the guard was a no-op and
`start()` would silently spawn a fresh thread + loop. Any caller
relying on the documented "single-use" guarantee — e.g. a sphinx
extension or fixture that defensively re-`start()`s in a finally
block to be safe — would get a zombie restart instead of the
RuntimeError the docstring implies.

Root cause: the original implementation conflated two distinct
shapes of idempotency under one early-return:

1. "start() called twice in a row before any stop()" — must be a
   no-op so re-firing `builder-inited` from sphinx-autobuild doesn't
   spawn a second thread.
2. "start() called after stop() on the same instance" — must raise
   so the lifecycle contract is enforceable.

The `_thread is None` post-stop state happened to satisfy (1)'s
guard, which is why the silent restart slipped through review when
the bus was first written.

The fix introduces an explicit `_stopped: bool` flag set by
`stop()`'s real-teardown paths (live-thread join AND the dead-thread
cleanup branch) but NOT by the no-op early-return for stop-before-
start. `start()` checks `_stopped` before its idempotency guard.
This separates "lifecycle complete" from "never lived", so a fresh-
from-the-constructor bus that defensively calls `stop()` (e.g. in a
finally block) can still `start()` afterwards.

Caught by code review on PR #25.

what:
- packages/gp-sphinx-vite/src/gp_sphinx_vite/bus.py:
  - `__init__`: add `self._stopped = False` with a comment explaining
    why stop-before-start leaves it `False`.
  - `start()`: check `self._stopped` first; raise
    `RuntimeError("AsyncioBus is single-use; construct a new
    instance after stop()")` if set. Idempotency guard remains in
    place for the still-running case. Docstring updated to describe
    both branches.
  - `stop()`: set `self._stopped = True` after both real-teardown
    paths (the dead-thread cleanup branch and the live-thread
    `loop.stop()` + join branch). Docstring updated.

- tests/test_gp_sphinx_vite_bus.py:
  - Add `test_start_after_stop_raises_runtime_error`: explicit
    regression guard for the new behaviour. Asserts
    `pytest.raises(RuntimeError, match=r"single-use")`.
  - Add `test_stop_before_start_does_not_lock_out_subsequent_start`:
    pins down the carve-out — a no-op stop() must NOT mark the bus
    stopped, otherwise defensive `try / finally: bus.stop()`
    patterns around an unstarted bus would lock it out of starting.
  - Existing tests (`test_double_start_is_idempotent`,
    `test_stop_before_start_is_idempotent`,
    `test_can_construct_a_new_bus_after_stop`) all continue to pass
    unchanged.

Verification:
- `uv run py.test tests/test_gp_sphinx_vite_bus.py --reruns 0 -v` —
  12 passed (10 prior + 2 new).
- Full suite — 1317 passed, 157 skipped, 3 deselected.
- `just build-docs` — green.
@tony
Copy link
Copy Markdown
Member Author

tony commented May 2, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

The 6 commits since the previous review (b7d7d8a..e92ce7a) cleanly address all 6 findings from that round. The substantive change — the new _stopped flag in AsyncioBus enforcing the documented "single-use" contract — was specifically scrutinized for race ordering, dead-thread vs live-thread teardown symmetry, and conflict with the existing test_double_start_is_idempotent / test_stop_before_start_is_idempotent / test_can_construct_a_new_bus_after_stop tests. The two new regression tests (test_start_after_stop_raises_runtime_error, test_stop_before_start_does_not_lock_out_subsequent_start) pin the contract from both sides without contradicting prior coverage.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony tony merged commit a3b7009 into main May 2, 2026
40 checks passed
@tony tony deleted the custom-tw-furo branch May 2, 2026 20:56
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