Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:
pull_request:
paths:
- src/scverse_doc/theme/scverse/static/_tokens.css
- src/scverse_doc/theme/scverse/static/styles/_tokens.css
- src/scverse_doc/theme/scverse/static/scverse-fa.svg
- scripts/**

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ __pycache__/

# Distribution / packaging
/dist/
# Built by hatch_build.py
*.min.css*

# Tests and coverage
/data/
Expand Down
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ repos:
- id: biome-format
exclude: ^\.cruft\.json$ # inconsistent indentation with cruft - file never to be modified manually.
groups: [format]
- id: biome-lint
args: [--error-on-warnings]
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.30.3
hooks:
Expand All @@ -21,6 +23,7 @@ repos:
rev: v1.46.6
hooks:
- id: djlint-reformat
groups: [format]
- id: djlint
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.1
Expand Down
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"**",
"!src/scverse_doc/registry.json",
"!src/scverse_doc/_accents.json",
"!src/scverse_doc/theme/scverse/static/_tokens.css",
"!src/scverse_doc/theme/scverse/static/styles/_tokens.css",
],
},
"formatter": { "useEditorconfig": true },
Expand Down
3 changes: 2 additions & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,9 @@ sphinx-build -M html . _build -W

### Changing the theme

The theme lives in `src/scverse_doc/theme/scverse`: brand tokens in `static/_tokens.css`,
The theme lives in `src/scverse_doc/theme/scverse`: brand tokens in `static/styles/_tokens.css`,
component styling in `static/styles/scverse.css`, and the sidebar, header and footer parts in `components/`.
On package build or docs build, `scverse.css` is bundled into a minified `scverse.min.css` with its imports inlined.

After a change, build the docs and check {doc}`kitchen-sink` – it renders every construct the theme
styles – in both colour schemes, using the theme switcher in the page header.
Expand Down
22 changes: 22 additions & 0 deletions hatch_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: MPL-2.0
"""Build the theme’s ``styles/*.min.css``, see ``scverse_doc.theme._bundle``."""

from __future__ import annotations

import runpy
from pathlib import Path
from typing import Any

from hatchling.builders.hooks.plugin.interface import BuildHookInterface


class CSSBundleHook(BuildHookInterface): # type: ignore[type-arg]
"""Bundle the theme stylesheets."""

def initialize(self, version: str, build_data: dict[str, Any]) -> None: # noqa: D102
# By path, as the package isn’t installed yet.
mod = runpy.run_path(str(Path(self.root) / "src/scverse_doc/theme/_bundle.py"))
if (exe := mod["find_lightningcss"]()) is None:
msg = "lightningcss binary not found"
raise RuntimeError(msg)
mod["bundle"](exe)
18 changes: 12 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
build-backend = "hatchling.build"
requires = [ "hatch-fancy-pypi-readme", "hatch-vcs", "hatchling" ]
requires = [ "hatch-fancy-pypi-readme", "hatch-vcs", "hatchling", "lightningcss-cli" ]

[project]
name = "scverse-doc"
Expand Down Expand Up @@ -50,16 +50,18 @@ dev = [
"twine>=4.0.2",
]
test = [
"coloraide", # to check the accent lightness bounds
"coloraide", # to check the accent lightness bounds
"coverage>=7.10",
"defusedxml", # for sphinx.testing’s HTML assertions
"defusedxml", # for sphinx.testing’s HTML assertions
"lightningcss-cli", # to rebuild the theme’s stylesheet bundles
"pytest",
"pytest-cov", # For VS Code’s coverage functionality
"wenmode", # for scripts/check_changelog.py
"pytest-cov", # For VS Code’s coverage functionality
"wenmode", # for scripts/check_changelog.py
]
doc = [
"ipykernel",
"ipython",
"lightningcss-cli", # to rebuild the theme’s stylesheet bundles
"scverse-misc[sphinx]>=0.1.2",
"sphinx-autodoc-typehints",
"sphinxcontrib-bibtex>=1",
Expand All @@ -72,6 +74,10 @@ metadata.hooks.fancy-pypi-readme.content-type = "text/markdown"
metadata.hooks.fancy-pypi-readme.fragments = [
{ path = "README.md", start-after = "<!-- badges-end -->\n" },
]
build.targets.wheel.exclude = [ "/src/scverse_doc/theme/scverse/static/styles/" ]
build.targets.wheel.artifacts = [ "/src/scverse_doc/theme/scverse/static/styles/*.min.css*" ]
# Builds `styles/*.min.css`, which replace the stylesheet sources in wheels, see `hatch_build.py`.
build.targets.wheel.hooks.custom = {}
build.targets.sdist.exclude = [ "/docs", "/tests" ]
envs.default.installer = "uv"
envs.default.dependency-groups = [ "dev" ]
Expand All @@ -85,7 +91,7 @@ envs.hatch-check-code.scripts.lint-check = "echo 'try `hatch check code --fix`';
envs.hatch-check-code.scripts.lint-fix = "prek run -a --no-group=format --no-group=typecheck"
envs.hatch-check-fmt.dependencies = [ "prek" ]
envs.hatch-check-fmt.scripts.format-check = "echo 'try `hatch check fmt --fix`'; false"
envs.hatch-check-fmt.scripts.format-fix = "prek run -a --group=format --no-group=typecheck"
envs.hatch-check-fmt.scripts.format-fix = "prek run -a --group=format"
envs.hatch-check-types.features = [ "full" ]
envs.hatch-check-types.scripts.check = [ "mypy" ]
envs.hatch-check-types.dependency-groups = [ "typecheck", "doc", "test" ]
Expand Down
2 changes: 1 addition & 1 deletion scripts/sync_brand_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

HERE = Path(__file__).parent
STATIC = HERE.parent / "src" / "scverse_doc" / "theme" / "scverse" / "static"
TARGET = STATIC / "_tokens.css"
TARGET = STATIC / "styles" / "_tokens.css"

#: Website file -> package file, copied verbatim.
ASSETS = {
Expand Down
4 changes: 4 additions & 0 deletions src/scverse_doc/theme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from sphinx.util.typing import ExtensionMetadata

from ..registry import DEFAULT_ACCENT, _build_cache, packages
from ._bundle import bundle, find_lightningcss

if TYPE_CHECKING:
from sphinx.application import Sphinx
Expand Down Expand Up @@ -102,6 +103,9 @@ def _configure(app: Sphinx) -> None:
return

_add_chrome(config)
# In source trees, rebuild the stylesheet bundles so edits show without reinstalling.
if exe := find_lightningcss():
bundle(exe)

css = _ACCENT_CSS.format(accent=_accent(config))
if secondary := _secondary_accent(config):
Expand Down
33 changes: 33 additions & 0 deletions src/scverse_doc/theme/_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: MPL-2.0
"""Bundle the theme stylesheets into minified ``*.min.css``, saving browsers a request per `@import`.

Stdlib only: ``hatch_build.py`` runs this before the package is installed.
"""

from __future__ import annotations

import shutil
import subprocess
import sysconfig
from pathlib import Path

STYLES = Path(__file__).parent / "scverse" / "static" / "styles"


def find_lightningcss() -> str | None:
"""Find the binary `lightningcss-cli` installs into the env, which needn’t be on $PATH."""
return shutil.which("lightningcss", path=sysconfig.get_path("scripts"))


def bundle(exe: str) -> None:
"""Bundle each ``styles/*.css`` into ``*.min.css`` next to it; ``_*.css`` partials are only imported.

Each bundle gets a ``*.min.css.map`` source map, which embeds the sources.
Wheels ship no sources, so this is a no-op outside of source trees.
"""
for src in STYLES.glob("[!_]*.css"):
if not src.name.endswith(".min.css"):
# No --targets, so modern syntax like light-dark() passes through untouched.
# Relative paths in STYLES, as the CLI writes them into the source map and its URL as given.
args = ["--bundle", "--minify", "--sourcemap", src.name, "-o", src.with_suffix(".min.css").name]
subprocess.run([exe, *args], cwd=STYLES, check=True)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{#- Opens the secondary sidebar drawer on narrow screens; pydata only renders its own in the (empty) navbar.

Unconditional: pydata decides whether a page has a secondary sidebar only after it has dropped
the header templates that render empty. scverse.css hides the button on pages without one.
the header templates that render empty. styles/_pydata.css hides the button on pages without one.
-#}
<button
type="button"
Expand Down
140 changes: 140 additions & 0 deletions src/scverse_doc/theme/scverse/static/styles/_pydata.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Workarounds for pydata-sphinx-theme, kept apart from the scverse styling so they can be upstreamed.
* Nothing in here uses scverse tokens.
*/

/* --- Header ------------------------------------------------------------- */

.bd-header-article {
/* pydata pushes __end right with margin-left: auto, but never makes this a flex container */
.header-article-items,
.header-article-items__start,
.header-article-items__end,
.header-article-item {
display: flex;
align-items: center;
gap: 0.5rem;
}

/* Bootstrap’s list margin would push them off-centre */
ul.bd-breadcrumbs {
margin: 0;
}

/* Space the icon links like the buttons beside them */
ul.navbar-icon-links {
column-gap: 0.5rem;
}

/* On narrow screens, wrap the breadcrumbs instead of the buttons */
.header-article-items__start {
min-width: 0;
}
.header-article-items__end {
flex-shrink: 0;
}

/* pydata only styles these inside .bd-header */
button.sidebar-toggle {
font-size: var(--pst-font-size-icon);
color: var(--pst-color-text-muted);
background-color: inherit;
border: none;
padding: 0.5rem;
}
/* pydata’s $breakpoint-sidebar-primary (lg) and $breakpoint-sidebar-secondary (xl) */
@media (min-width: 960px) {
button.primary-toggle {
display: none;
}
}
@media (min-width: 1200px) {
button.secondary-toggle {
display: none;
}
}
}

/* The navbar is empty unless a `navbar_*` option fills it (see theme.toml).
* Then drop it, let the sidebars stick to the top of the viewport,
* and make the article header sticky like sphinx-book-theme’s, since it holds the controls instead. */
html:not(:has(.bd-header__inner)) {
--pst-header-height: 0px;
/* pydata derives these from the header height */
--pst-header-article-height: calc(4rem * 2 / 3);
scroll-padding-top: calc(var(--pst-header-article-height) + 1rem);

.bd-header {
display: none;
}

/* pydata sizes the logo by the header height */
.navbar-brand {
--pst-header-height: auto;
}

/* pydata’s overflow-x would make this the sticky header’s scroll container;
* min-width keeps wide content from pushing off the secondary sidebar instead. */
.bd-main .bd-content .bd-article-container {
overflow-x: unset;
min-width: 0;
}

.bd-header-article {
position: sticky;
top: 0;
z-index: 1020; /* Bootstrap’s $zindex-sticky */
background-color: var(--pst-color-background);
/* Span the article container’s padding, so content doesn’t peek out beside it */
margin: -1rem -1rem 0;
padding-inline: 1rem;
/* pydata’s navbar shadow, horizontally shrunk so it only extends downwards */
box-shadow: -0.125rem 0.25rem 0.25rem -0.125rem var(--pst-color-shadow);
}
/* Where supported, only once the page is scrolled */
@supports (animation-timeline: scroll()) {
.bd-header-article {
animation: scverse-header-shadow linear both;
animation-timeline: scroll();
animation-range: 0 1rem;
}
}
}
@keyframes scverse-header-shadow {
from {
box-shadow: none;
}
}
/* A filled navbar has pydata’s own sidebar toggles. Its JS only binds the first .primary-toggle/.secondary-toggle,
* so the ones from our toggle-*-sidebar.html components further down would do nothing. */
html:has(.bd-header__inner) .bd-header-article button.sidebar-toggle {
display: none;
}
.bd-main:not(:has(#pst-secondary-sidebar)) button.secondary-toggle {
display: none;
}

/* Bootstrap’s dropdown link colours, as specific as pydata’s a:visited(:hover), so they win by coming later */
a.dropdown-item {
color: var(--bs-dropdown-link-color);

&:hover {
color: var(--bs-dropdown-link-hover-color);
}
&:is(.active, :active) {
color: var(--bs-dropdown-link-active-color);
}
}

/* --- Sidebar ------------------------------------------------------------ */

.sidebar-primary-item {
/* Hide empty sidebar items; this:
* excludes defined custom elements (which only contain Shadow DOM)
* includes an empty ad container
*/
&:has(> :only-child:empty:not(readthedocs-flyout:defined)),
&:has(> #ethical-ad-placement):not(:has(> #ethical-ad-placement > *)) {
display: none;
}
}
Loading
Loading