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
117 changes: 68 additions & 49 deletions scripts/sync_brand_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
"""Refresh the generated brand assets from the scverse website.

The website is the brand’s source of truth,
so transcribing its hex values into this repository by hand
so transcribing its colours into this repository by hand
would create exactly the kind of drift this package exists to remove.
This script extracts the handful of SCSS variables that make up the brand
and writes them into ``_tokens.css`` as ``--scverse-color-x-light`` values.

Only the region between the marker comments is touched; it holds upstream hex values and nothing else.
The rest of the file is hand-authored – including the ``light-dark()`` tokens that pair each generated
light value with a dark one, because the website has no dark mode to extract those from.
The website takes its neutral colours from Bootstrap’s tokens (``--bs-fg-*``, ``--bs-bg-*``, …),
which already carry dark values via ``light-dark()``, and defines only the brand hues itself.
This script looks up the tokens the theme needs in the website’s vendored Bootstrap and ``assets/main.scss``,
resolves every ``var()`` down to literals, and writes them into ``_tokens.css`` as ``--scverse-color-x``.
The per-package accents go to ``_accents.json``, because the registry needs their values in Python
to derive readable shades from them.

Only the region between the marker comments is touched; the rest of the file is hand-authored.

The scverse logo used for the navbar link back to the website is copied verbatim for the same reason.

Expand All @@ -25,79 +28,96 @@
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

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

#: Website file -> theme static file, copied verbatim.
ASSETS = {Path("static/img/logo/scverse-fa.svg"): STATIC / "scverse-fa.svg"}

#: SCSS variable in ``assets/main.scss`` -> CSS custom property emitted here.
#: CSS custom property emitted here -> website custom property it is resolved from.
#: The pairing mirrors the one scverse/scverse.github.io#329 used when replacing its SCSS variables.
TOKEN_MAP = {
"greyheader": "--scverse-color-heading",
"navtext": "--scverse-color-text-secondary",
"greydesc": "--scverse-color-text-muted",
"tilebg": "--scverse-color-surface",
"tilebg4": "--scverse-color-surface-alt",
"overline": "--scverse-color-border",
"backtickbg": "--scverse-color-code-bg",
"tiletext": "--scverse-color-code-text",
"footerbg": "--scverse-color-footer-bg",
"--scverse-color-gradient-start": "--scverse-deep-blue",
"--scverse-color-gradient-end": "--scverse-sky-blue",
"--scverse-color-background": "--bs-bg-body",
"--scverse-color-text": "--bs-fg-body",
"--scverse-color-heading": "--bs-fg-1",
"--scverse-color-text-secondary": "--bs-fg-2",
"--scverse-color-text-muted": "--bs-fg-3",
"--scverse-color-surface": "--bs-bg-2",
"--scverse-color-surface-alt": "--bs-bg-1",
"--scverse-color-border": "--bs-border-color",
"--scverse-color-border-muted": "--bs-border-muted",
"--scverse-color-code-bg": "--bs-bg-1",
"--scverse-color-code-text": "--bs-fg-1",
"--scverse-color-footer-bg": "--bs-bg-2",
}

#: Values that are written as literals in the SCSS rather than as variables,
#: so they cannot be looked up by name.
#: Verified against ``assets/main.scss`` by :func:`check_literals`.
LITERALS = {
"--scverse-color-primary": "#4557c4",
"--scverse-color-gradient-start": "#262fb5",
"--scverse-color-gradient-end": "#74c8fa",
#: Package -> website custom property holding its accent; ``default`` is for packages without one.
ACCENT_MAP = {
"default": "--scverse-deep-blue",
"anndata": "--anndata-orange",
"mudata": "--mudata-green",
"muon": "--muon-aquamarine",
"pertpy": "--scirpy-purple", # the website has no pertpy colour; it has always shared scirpy’s
"scanpy": "--scanpy-cerise",
"scirpy": "--scirpy-purple",
"scvi-tools": "--scvi-yellow",
"spatialdata": "--spatialdata-blue",
"squidpy": "--squidpy-violet",
}

BEGIN = "/* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_brand_tokens.py */"
END = "/* END GENERATED BRAND TOKENS */"

#: The fenced region, captured together with the indentation of its opening marker.
REGION_RE = re.compile(rf"^([ \t]*){re.escape(BEGIN)}\n.*?^[ \t]*{re.escape(END)}$", re.DOTALL | re.MULTILINE)
DECL_RE = re.compile(r"(--[\w-]+)\s*:\s*([^;{}]+?)\s*;")


def parse_scss(scss: str) -> dict[str, str]:
"""Extract top-level ``$name: #value;`` declarations from SCSS source."""
return dict(re.findall(r"^\$([\w-]+):\s*(#[0-9a-fA-F]{3,8})\s*;", scss, re.MULTILINE))
def parse_bootstrap(css: str) -> dict[str, str]:
"""Extract the custom properties Bootstrap declares on ``:root``."""
blocks = re.findall(r":root,:host\{([^}]*)\}", css)
return dict(DECL_RE.findall(";".join(blocks) + ";"))


def check_literals(scss: str) -> list[str]:
"""Return the literal token values that no longer appear in the SCSS.
def parse_scss(scss: str) -> dict[str, str]:
"""Extract custom property declarations from the website’s SCSS."""
return dict(DECL_RE.findall(scss))


The primary and the gradient stops are written inline in the website’s CSS rules,
so they cannot be resolved by variable name.
Checking that they still occur at all is a cheap guard against the brand changing underneath us.
"""
return [f"{name} ({value})" for name, value in LITERALS.items() if value.lower() not in scss.lower()]
def resolve(name: str, props: dict[str, str]) -> str:
"""Return the value of `name` with every ``var()`` in it recursively substituted."""
return re.sub(r"var\((--[\w-]+)\)", lambda m: resolve(m[1], props), props[name])


def render_region(scss: str, indent: str) -> str:
def render_region(props: dict[str, str], indent: str) -> str:
"""Render the generated region – marker comments included – indented by `indent`."""
variables = parse_scss(scss)
if missing := sorted(set(TOKEN_MAP) - set(variables)):
msg = f"SCSS variables vanished from the website: {', '.join('$' + m for m in missing)}"
if missing := sorted(set(TOKEN_MAP.values()) - set(props)):
msg = f"custom properties vanished from the website: {', '.join(missing)}"
raise KeyError(msg)

values = LITERALS | {prop: variables[scss_name] for scss_name, prop in TOKEN_MAP.items()}
lines = [BEGIN, *(f"{prop}-light: {value};" for prop, value in values.items()), END]
lines = [BEGIN, *(f"{prop}: {resolve(src, props)};" for prop, src in TOKEN_MAP.items()), END]
return "\n".join(indent + line for line in lines)


def render(scss: str, current: str) -> str:
"""Return `current` with its generated region replaced by one rendered from `scss`."""
def render_accents(props: dict[str, str]) -> str:
"""Render the accent JSON read by ``scverse_doc.registry``."""
return json.dumps({pkg: resolve(src, props) for pkg, src in ACCENT_MAP.items()}, indent=4) + "\n"


def render(props: dict[str, str], current: str) -> str:
"""Return `current` with its generated region replaced by one rendered from `props`."""
if (region := REGION_RE.search(current)) is None:
msg = f"{TARGET} has no “{BEGIN}” … “{END}” region"
raise LookupError(msg)
return current[: region.start()] + render_region(scss, region[1]) + current[region.end() :]
return current[: region.start()] + render_region(props, region[1]) + current[region.end() :]


def main() -> int:
Expand All @@ -107,13 +127,12 @@ def main() -> int:
parser.add_argument("--check", action="store_true", help="fail instead of writing if the output would change")
args = parser.parse_args()

scss = (args.website / "assets" / "main.scss").read_text()
if stale := check_literals(scss):
print(f"literal brand colours no longer found in main.scss: {', '.join(stale)}", file=sys.stderr)
return 1

props = parse_bootstrap(
(args.website / "static" / "bootstrap" / "css" / "bootstrap.min.css").read_text()
) | parse_scss((args.website / "assets" / "main.scss").read_text())
updates = {
TARGET: render(scss, TARGET.read_text()).encode(),
TARGET: render(props, TARGET.read_text()).encode(),
ACCENTS_TARGET: render_accents(props).encode(),
**{dst: (args.website / src).read_bytes() for src, dst in ASSETS.items()},
}
if args.check:
Expand Down
12 changes: 12 additions & 0 deletions src/scverse_doc/_accents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"default": "#262fb5",
"anndata": "#e5864b",
"mudata": "#4ab274",
"muon": "#6cf1a1",
"pertpy": "#da347f",
"scanpy": "#de367b",
"scirpy": "#da347f",
"scvi-tools": "#fbb822",
"spatialdata": "#40a9ff",
"squidpy": "#969dea"
}
23 changes: 7 additions & 16 deletions src/scverse_doc/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from functools import cache, cached_property
from importlib.resources import files
from operator import itemgetter
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
Expand Down Expand Up @@ -53,21 +54,11 @@ def _build_cache(app: Sphinx) -> Path:
return _cache_dir


#: The brand primary, used when a package has no accent of its own.
DEFAULT_ACCENT = "#4557c4"

#: Brand accents, transcribed from ``assets/main.scss`` in the website repository.
_ACCENTS = {
"anndata": "#e5864b",
"mudata": "#4ab274",
"muon": "#6cf1a1",
"pertpy": "#da347f",
"scanpy": "#de367b",
"scirpy": "#da347f",
"scvi-tools": "#fbb822",
"spatialdata": "#40a9ff",
"squidpy": "#969dea",
}
#: Brand accents from the website, generated by ``scripts/sync_brand_tokens.py``.
_ACCENTS: dict[str, str] = json.loads(files(__package__).joinpath("_accents.json").read_text())

#: The website’s ``--scverse-deep-blue``, used when a package has no accent of its own.
DEFAULT_ACCENT = _ACCENTS.pop("default")

#: Upstream ``category`` -> the ``kind`` recorded here.
#: ``core-infrastructure`` is skipped:
Expand Down Expand Up @@ -111,7 +102,7 @@ class Package:
"""One-line summary of the package."""

accent: str = DEFAULT_ACCENT
"""The package’s brand accent, falling back to the scverse primary."""
"""The package’s brand accent, falling back to the scverse deep blue."""


@cache
Expand Down
54 changes: 17 additions & 37 deletions src/scverse_doc/theme/scverse/static/_tokens.css
Original file line number Diff line number Diff line change
@@ -1,50 +1,30 @@
/*
* scverse brand tokens.
*
* The light halves come from the website (the brand’s source of truth) and live in the fenced
* region below; everything outside it is hand-authored – including the dark halves, because the
* website has no dark mode to extract one from.
*
* light-dark() picks between them following the used color-scheme,
* The colours come from the website (the brand’s source of truth) and live in the fenced region below;
* everything outside it is hand-authored. They are Bootstrap 6 tokens resolved to literals,
* so light-dark() picks the half matching the used color-scheme,
* which pydata-sphinx-theme sets from html[data-theme].
*/

:root {
/* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_brand_tokens.py */
--scverse-color-primary-light: #4557c4;
--scverse-color-gradient-start-light: #262fb5;
--scverse-color-gradient-end-light: #74c8fa;
--scverse-color-heading-light: #333333;
--scverse-color-text-secondary-light: #555555;
--scverse-color-text-muted-light: #777777;
--scverse-color-surface-light: #f0f0f0;
--scverse-color-surface-alt-light: #f5f5f5;
--scverse-color-border-light: #e0e0e0;
--scverse-color-code-bg-light: #f9f9f9;
--scverse-color-code-text-light: #333333;
--scverse-color-footer-bg-light: #f0f0f0;
--scverse-color-gradient-start: #262fb5;
--scverse-color-gradient-end: #74c8fa;
--scverse-color-background: light-dark(#fff,color-mix(in oklch, #000 76%, oklch(60% .02 245)));
--scverse-color-text: light-dark(color-mix(in oklch, #000 64%, oklch(60% .02 245)),color-mix(in oklch, #fff 90%, oklch(60% .02 245)));
--scverse-color-heading: light-dark(color-mix(in oklch, #000 48%, oklch(60% .02 245)),color-mix(in oklch, #fff 60%, oklch(60% .02 245)));
--scverse-color-text-secondary: light-dark(color-mix(in oklch, #000 32%, oklch(60% .02 245)),color-mix(in oklch, #fff 40%, oklch(60% .02 245)));
--scverse-color-text-muted: light-dark(color-mix(in oklch, #000 16%, oklch(60% .02 245)),oklch(60% .02 245));
--scverse-color-surface: light-dark(color-mix(in oklch, #fff 90%, oklch(60% .02 245)),color-mix(in oklch, #000 64%, oklch(60% .02 245)));
--scverse-color-surface-alt: light-dark(color-mix(in oklch, #fff 94%, oklch(60% .02 245)),color-mix(in oklch, #000 70%, oklch(60% .02 245)));
--scverse-color-border: light-dark(color-mix(in oklch, #fff 60%, oklch(60% .02 245)),color-mix(in oklch, #000 32%, oklch(60% .02 245)));
--scverse-color-border-muted: light-dark(color-mix(in oklch, #fff 60%, oklch(60% .02 245)),color-mix(in oklch, #000 48%, oklch(60% .02 245)));
--scverse-color-code-bg: light-dark(color-mix(in oklch, #fff 94%, oklch(60% .02 245)),color-mix(in oklch, #000 70%, oklch(60% .02 245)));
--scverse-color-code-text: light-dark(color-mix(in oklch, #000 48%, oklch(60% .02 245)),color-mix(in oklch, #fff 60%, oklch(60% .02 245)));
--scverse-color-footer-bg: light-dark(color-mix(in oklch, #fff 90%, oklch(60% .02 245)),color-mix(in oklch, #000 64%, oklch(60% .02 245)));
/* END GENERATED BRAND TOKENS */

/*
* The tokens the theme actually uses: the generated light half above,
* and a dark half chosen to sit on pydata-sphinx-theme’s dark background (#14181e)
* and its on-background surface (#222832).
*/

/* Lightened brand primary: #4557c4 only reaches 1.6:1 on #14181e, this reaches 4.5:1 while keeping the hue. */
--scverse-color-primary: light-dark(var(--scverse-color-primary-light), #6b7ad0);
--scverse-color-gradient-start: light-dark(var(--scverse-color-gradient-start-light), #4557c4);
--scverse-color-gradient-end: light-dark(var(--scverse-color-gradient-end-light), #74c8fa);
--scverse-color-heading: light-dark(var(--scverse-color-heading-light), #f0f2f5);
--scverse-color-text-secondary: light-dark(var(--scverse-color-text-secondary-light), #ced6dd);
--scverse-color-text-muted: light-dark(var(--scverse-color-text-muted-light), #9ca4af);
--scverse-color-surface: light-dark(var(--scverse-color-surface-light), #222832);
--scverse-color-surface-alt: light-dark(var(--scverse-color-surface-alt-light), #1a1f27);
--scverse-color-border: light-dark(var(--scverse-color-border-light), #48566b);
--scverse-color-code-bg: light-dark(var(--scverse-color-code-bg-light), #1a1f27);
--scverse-color-code-text: light-dark(var(--scverse-color-code-text-light), #e7eaf0);
--scverse-color-footer-bg: light-dark(var(--scverse-color-footer-bg-light), #10141a);

/* Derived: the brand gradient used for hero surfaces and the active-nav underline. */
--scverse-gradient: linear-gradient(
135deg,
Expand Down
40 changes: 36 additions & 4 deletions src/scverse_doc/theme/scverse/static/styles/scverse.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,41 @@

/* Specific enough to beat pydata’s own html[data-theme="…"] token blocks. */
html[data-theme] {
--pst-color-primary: var(--scverse-color-primary);
--pst-color-secondary: var(--scverse-color-accent-text);
--pst-color-link: var(--scverse-color-accent-text);
--pst-color-link-hover: var(--scverse-color-primary);
/* Primary and secondary both follow the package accent; the website has no brand primary of its own. */
--pst-color-primary: var(--scverse-color-accent-text);
/* Mixing towards the text colour only raises the contrast accent-text already guarantees. */
--pst-color-primary-highlight: color-mix(
in oklch,
var(--scverse-color-accent-text) 80%,
var(--scverse-color-text)
);
--pst-color-primary-bg: color-mix(
in oklch,
var(--scverse-color-accent-decorative) 20%,
var(--scverse-color-background)
);
--sd-color-primary-bg: var(--pst-color-primary-bg);
--pst-color-secondary: var(--pst-color-primary);
--pst-color-secondary-highlight: var(--pst-color-primary-highlight);
--pst-color-secondary-bg: var(--pst-color-primary-bg);
--sd-color-secondary-bg: var(--pst-color-primary-bg);
--pst-color-link: var(--pst-color-primary);
--pst-color-link-hover: var(--pst-color-primary-highlight);
--pst-color-link-higher-contrast: color-mix(
in oklch,
var(--scverse-color-accent-text) 70%,
var(--scverse-color-text)
);
--pst-color-inline-code: var(--scverse-color-code-text);
--pst-color-background: var(--scverse-color-background);
--pst-color-text-base: var(--scverse-color-text);
--pst-color-on-surface: var(--scverse-color-text);
--pst-color-surface: var(--scverse-color-surface);
--pst-color-border: var(--scverse-color-border);
--pst-color-border-muted: var(--scverse-color-border-muted);
--pst-color-table-row-hover-bg: var(--pst-color-secondary-bg);
--pst-color-text-muted: var(--scverse-color-text-muted);
--pst-color-heading: var(--scverse-color-heading);
--pst-heading-color: var(--scverse-color-heading);
}

Expand All @@ -35,6 +62,11 @@ html {
/* --- Navbar ------------------------------------------------------------- */

.bd-header ul.navbar-nav > li.nav-item {
/* Like the website: the current link is plain text, marked only by its accent underline. */
&.current > .nav-link {
color: var(--scverse-color-text);
}

&.current > .nav-link::before,
& > .nav-link:hover::before {
border-bottom-color: var(--scverse-color-accent-decorative);
Expand Down
Loading