diff --git a/scripts/sync_brand_tokens.py b/scripts/sync_brand_tokens.py index a2c4a82..d24b820 100755 --- a/scripts/sync_brand_tokens.py +++ b/scripts/sync_brand_tokens.py @@ -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. @@ -25,6 +28,7 @@ from __future__ import annotations import argparse +import json import re import sys from pathlib import Path @@ -32,30 +36,42 @@ 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 */" @@ -63,41 +79,45 @@ #: 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: @@ -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: diff --git a/src/scverse_doc/_accents.json b/src/scverse_doc/_accents.json new file mode 100644 index 0000000..9e07d3a --- /dev/null +++ b/src/scverse_doc/_accents.json @@ -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" +} diff --git a/src/scverse_doc/registry.py b/src/scverse_doc/registry.py index db57251..2c5fb9d 100644 --- a/src/scverse_doc/registry.py +++ b/src/scverse_doc/registry.py @@ -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 @@ -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: @@ -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 diff --git a/src/scverse_doc/theme/scverse/static/_tokens.css b/src/scverse_doc/theme/scverse/static/_tokens.css index d229eab..cc63992 100644 --- a/src/scverse_doc/theme/scverse/static/_tokens.css +++ b/src/scverse_doc/theme/scverse/static/_tokens.css @@ -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, diff --git a/src/scverse_doc/theme/scverse/static/styles/scverse.css b/src/scverse_doc/theme/scverse/static/styles/scverse.css index c06dc48..b1a631e 100644 --- a/src/scverse_doc/theme/scverse/static/styles/scverse.css +++ b/src/scverse_doc/theme/scverse/static/styles/scverse.css @@ -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); } @@ -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);