diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b039e55..50c3223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,13 @@ on: # PRs. To bump the pin, update FIXTURES_SHA below. env: FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures - # Includes the announcement-banner demo in the fixtures config (merged in - # fixtures #1), so the preview/visual build exercises the banner. - FIXTURES_SHA: 32763e43b893c1520df8e58c0c6e10d81db7f1ac + # Includes the announcement-banner demo (merged in fixtures #1) and the + # author/translator byline demo (fixtures #2), so the preview/visual build + # exercises both. Points at the fixtures #2 branch head rather than a merge + # commit: that PR's own self-check builds against the latest *released* + # theme, which does not know the `translators` option yet, so it stays red + # until this ships. Re-pin to the merge commit once it does. + FIXTURES_SHA: ab9ba206d48870e86b822c74380546e42fcba299 # Explicit least-privilege permissions: # contents: write — checkout + upload-artifact + nwtgck/actions-netlify diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml index 5ccb3d0..b055453 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -11,7 +11,7 @@ on: # "Resolve fixtures pin" step. env: FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures - FIXTURES_SHA: 32763e43b893c1520df8e58c0c6e10d81db7f1ac + FIXTURES_SHA: ab9ba206d48870e86b822c74380546e42fcba299 jobs: # /update-new-snapshots — only creates MISSING snapshots (safe for adding new tests) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e2b1c..f57e78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Dismissible announcement banner** — a new `announcement` theme option renders a notice at the top of every page (HTML allowed, so you can include emphasis and a link to changelog notes). Readers dismiss it with a `×`; the dismissal persists in `localStorage` and is keyed to a hash of the message, so editing the text re-shows the banner for everyone who dismissed the previous one. An optional `announcement_expires` ISO date (`YYYY-MM-DD`) auto-hides the banner after that day — enforced client-side (so it disappears for visitors even without a rebuild) and at build time (an already-expired notice is omitted from the HTML). An invalid expiry date logs a warning and fails open. Two looks are available via `announcement_style`: `bar` (default) — a thin full-width strip that scrolls away below the toolbar — or `callout` — a boxed in-column notice; both adapt to dark mode and RTL. The banner defaults to empty/off, so existing sites are unaffected. The renderer iterates a list of notices internally so per-page announcements can be added additively later (tracked in #403). +- **`translators` theme option** (#426) — a new `translators` option, parallel to `authors`, takes a list of `{name, url}` entries and renders them in the page header behind a visible label, so translated editions can credit the people who produced them. Authors keep the line directly under the page title and translators sit one row lower, right-aligned on the "Last changed" line and sharing its accent rule, so crediting them costs the header no extra vertical space; on narrow screens the two stack. The label defaults to "Translated by" and is overridable via `translators_label` so each edition renders it in its own language; authors keep their current label-free rendering, and the matching `authors_label` defaults to empty, so a site that sets neither renders byte-identical HTML. Either list can also be overridden per page in MyST or notebook front matter, which is how a team-translated edition credits whoever produced each lecture: a page value replaces the project value rather than merging with it, an explicit empty list leaves the block out on that page, and an absent key inherits the project value. Only the documented list-of-mappings form counts as an override, because `authors` is shared ground — docutils treats it as a bibliographic field and nbformat defines it in the notebook schema — so a page already carrying one written for something else keeps the project credit instead of rendering it badly or losing the byline. Author links now carry `rel="author"`; translator links deliberately do not, and the two lines carry distinct classes so downstream CSS can tell them apart. Front matter is read from the source file rather than through Sphinx's document metadata, because docutils treats `authors` as a bibliographic field and returns it in a shape that varies with the `language` setting — unworkable for a feature whose whole purpose is non-English editions. Part of the cross-theme attribution work tracked in QuantEcon/meta#360. + +### Fixed +- **A malformed `authors` value no longer renders one empty link per character** — `authors` given as a bare string (rather than the documented list of `{name, url}` entries) was iterated character by character, emitting an empty anchor for each. Both `authors` and `translators` now normalise a bare string, a list of strings, or a single mapping into the documented form, and drop entries with no usable name. ### Documentation - **Developer setup troubleshooting for stale `.nodeenv`** — documented the `nodeenv-version-mismatch` error (an in-repo `.nodeenv/` left over from an older pinned Node.js version) and its fix (`rm -rf .nodeenv` then rebuild), which otherwise blocks `tox` and editable installs locally. Also clarified that `tox` keeps the toolchain fully repo-local (`.tox/`, `.nodeenv/`, `node_modules/` are all git-ignored and regenerated), so nothing is installed into the base/global environment. diff --git a/docs/developer/testing.md b/docs/developer/testing.md index 5071794..df082ee 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -30,6 +30,7 @@ tests/ ├── test_module_structure.py # Module organization tests ├── test_custom_colors.py # Color scheme tests ├── test_rtl_functionality.py # RTL language support tests +├── test_translators.py # Author and translator attribution tests └── sites/ # Test site configurations ├── base/ # Basic test site └── rtl_test/ # RTL-specific test site diff --git a/docs/user/authors.md b/docs/user/authors.md new file mode 100644 index 0000000..28006df --- /dev/null +++ b/docs/user/authors.md @@ -0,0 +1,138 @@ +# Authors and Translators + +The theme renders author credits below the page title, and translated editions can +credit their translators in the page header alongside them. + +```{contents} +:local: +:depth: 1 +``` + +## Basic usage + +Both options take a list of `{name, url}` entries. A translator without a `url` +renders as plain text; an author without one still renders as a link, as it always +has. + +```python +html_theme_options = { + "authors": [ + {"name": "Thomas J. Sargent", "url": "http://www.tomsargent.com/"}, + {"name": "John Stachurski", "url": "https://johnstachurski.net/"}, + ], + "translators": [ + {"name": "Zhang San", "url": "https://example.org/zhang"}, + ], +} +``` + +For Jupyter Book projects: + +```yaml +sphinx: + config: + html_theme_options: + authors: + - name: Thomas J. Sargent + url: http://www.tomsargent.com/ + translators: + - name: Zhang San + url: https://example.org/zhang +``` + +Authors keep the line directly beneath the page title. Translators sit one row +lower, right-aligned on the same line as the "Last changed" control and sharing its +accent rule, so crediting them does not add a fourth stacked line to the header. On +narrow screens the two stack instead. The two groups are never confused for one +another: author links carry `rel="author"`, which tells crawlers who wrote the page, +and translator links deliberately do not. + +## Labels + +Translators are introduced by a label, because an unlabelled second list of names +below the authors would be ambiguous. Authors have no label by default — the names +directly under the title already read as the authors. + +| Option | Default | Purpose | +|--------|---------|---------| +| `translators_label` | `Translated by` | Introduces the translators line | +| `authors_label` | *(empty)* | Adds a label to the authors line when set | + +Set `translators_label` to render the credit in the edition's own language: + +```python +html_theme_options = { + "translators_label": "译者", +} +``` + +Setting `authors_label` to an empty string (the default) omits the authors label +entirely, which is how every existing site renders today. + +## Per-page overrides + +A page can set its own `authors` and `translators` in front matter. This matters for +a translated edition produced by a team, where each lecture is the work of a +different translator: the project-level option carries the default credit, and each +page names whoever actually did that lecture. + +```yaml +--- +translators: + - name: Li Si + url: https://example.org/li +--- +``` + +A page value **replaces** the project value rather than merging with it, so a page +that wants to credit both a coordinator and its own translator must list both. + +| Front matter | Result on that page | +|--------------|---------------------| +| key absent | inherits the project-level value | +| `translators: []` | the block is left out on that page | +| `translators:` (no value) or `translators: ""` | likewise left out | +| `translators: [{name: …}]` | replaces the project value entirely | +| any other shape | ignored — inherits the project-level value | + +Any explicitly empty value is the way to mark a page that has no translator yet — +an untranslated or machine-translated page in an otherwise translated edition +should not carry a human's name. An empty list is the clearest form, but a bare key +and an empty string mean the same thing, since all three say "nobody" rather than +"I have nothing to say about this". + +`authors_label` and `translators_label` can be overridden per page in the same way. + +Overrides must use the list-of-mappings form shown above. `authors` in particular is +shared ground — docutils treats it as a bibliographic field and nbformat defines it +in the notebook schema — so a page may already carry an `authors` value written for +something else. A value this theme cannot read is left alone rather than rendered +badly or treated as suppression, and the project-level credit still shows. + +Overrides are read from the source file's front matter, so they work in MyST +markdown and Jupyter notebooks. In a notebook the keys go in the notebook-level +`metadata`, which means a notebook that already sets nbformat's standard +`metadata.authors` is taken at its word. reStructuredText field lists cannot express +a list of mappings, so `.rst` pages always inherit the project-level value. + +:::{note} +Quote any date-like value inside front matter (`since: "2020-01-01"`). An unquoted +YAML date anywhere in a page's front matter fails the build inside MyST, before the +theme sees it. +::: + +## When nothing is configured + +With no `translators` set, no translators block is rendered and the generated HTML +is unchanged from previous versions of the theme. This is fully backwards +compatible. + +With no `authors` set, the theme falls back to printing Sphinx's own `author` +configuration value in the authors slot. A page that sets `authors: []` in its front +matter suppresses that fallback too, so the page shows no attribution at all. + +## Limitations + +Translators are shown on content pages but not on the landing page, where only the +authors line is moved up beneath the title. The list connector (`, ` and ` and `) is +English regardless of the configured language; only the labels are localisable. diff --git a/docs/user/configuration.md b/docs/user/configuration.md index 1cb7e41..6a7fbb8 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -134,6 +134,23 @@ html_theme_options = { } ``` +## Add Translators + +Translated editions can credit their translators alongside the authors: + +```python +html_theme_options = { + ... + "translators": [ + {"name": "translator1", "url": "bio-link1"}, + ], + "translators_label": "Translated by", + ... +} +``` + +See [Authors and Translators](authors.md) for per-page overrides and label localisation. + ## Plugins Add custom JavaScript plugins to extend or modify theme features: diff --git a/docs/user/index.md b/docs/user/index.md index 1372e60..ad0c6a6 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -52,6 +52,7 @@ html_theme_options = { configuration announcements +authors layout notebooks launch diff --git a/pyproject.toml b/pyproject.toml index 9fdff1b..3896966 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ additional-compiled-static-assets = [ testpaths = [ "tests" ] +markers = [ + "build: runs a full sphinx-build in a subprocess, so it is slow", +] [project] name = "quantecon-book-theme" diff --git a/src/quantecon_book_theme/__init__.py b/src/quantecon_book_theme/__init__.py index 8910523..8772791 100644 --- a/src/quantecon_book_theme/__init__.py +++ b/src/quantecon_book_theme/__init__.py @@ -1,12 +1,14 @@ """A lightweight book theme based on the pydata sphinx theme.""" from pathlib import Path +import json import os import hashlib from functools import lru_cache import subprocess from datetime import datetime, timedelta, timezone +import yaml from docutils import nodes from sphinx.util import logging from bs4 import BeautifulSoup as bs @@ -297,6 +299,169 @@ def _process_languages(config_theme): return [], "" +def _normalise_people(value): + """Coerce an authors/translators value into a list of ``{name, url}`` dicts. + + Accepts the documented list-of-dicts form, and tolerates a list of plain + strings, a single dict, or a single string. Anything else -- including + ``None`` and the empty string that ``theme.conf`` uses for its default -- + becomes an empty list. Entries without a usable name are dropped. Name and + url strings are passed through untouched so that projects already setting + ``authors`` keep identical output. + """ + if isinstance(value, (str, dict)): + value = [value] + if not isinstance(value, (list, tuple)): + return [] + people = [] + for item in value: + if isinstance(item, dict): + name = item.get("name", "") + url = item.get("url", "") or "" + elif isinstance(item, str): + name, url = item, "" + else: + continue + name = name if isinstance(name, str) else str(name) + url = url if isinstance(url, str) else str(url) + if name.strip(): + people.append({"name": name, "url": url}) + return people + + +def _is_fence(line, width=3): + """True for a front matter fence: a run of at least ``width`` dashes.""" + return len(line) >= width and line.count("-") == len(line) + + +def _md_front_matter(path): + """Return the leading YAML front matter of a markdown source, or ``{}``. + + Sources are opened as ``utf-8-sig`` to match Sphinx's own + ``source_encoding`` default, so that a byte-order mark does not hide front + matter the parser goes on to read. The fence is a run of three or more + dashes closed by a run at least as long, which is what MyST accepts. + """ + try: + with open(path, encoding="utf-8-sig") as handle: + opening = handle.readline().strip() + if not _is_fence(opening): + return {} + lines = [] + for line in handle: + if _is_fence(line.strip(), width=len(opening)): + break + lines.append(line) + else: + # Unterminated block: not front matter as far as MyST is concerned. + return {} + except (OSError, UnicodeDecodeError): + return {} + try: + data = yaml.safe_load("".join(lines)) + except yaml.YAMLError: + return {} + return data if isinstance(data, dict) else {} + + +def _nb_front_matter(path): + """Return the notebook-level metadata of an ``.ipynb`` source, or ``{}``.""" + try: + with open(path, encoding="utf-8-sig") as handle: + notebook = json.load(handle) + except (OSError, UnicodeDecodeError, ValueError): + return {} + if not isinstance(notebook, dict): + return {} + metadata = notebook.get("metadata") + return metadata if isinstance(metadata, dict) else {} + + +@lru_cache(maxsize=None) +def _read_front_matter(path, mtime_ns, size): + """Front matter for one source file, cached on its path and mtime/size. + + The returned mapping is shared between callers, so treat it as read-only. + """ + if path.endswith(".ipynb"): + return _nb_front_matter(path) + if path.endswith((".md", ".myst", ".markdown")): + return _md_front_matter(path) + return {} + + +def _page_front_matter(app, pagename): + """Front matter for ``pagename``, or ``{}`` when there is none to read. + + The source file is read directly rather than going through + ``app.env.metadata``, because docutils treats ``authors`` as a + bibliographic field: MyST's serialised value comes back through that path + smart-quoted and split on commas, and in a *different* shape again once + ``language`` is set to a locale that does not list ``authors`` among its + bibliographic fields. Reading the source keeps one shape in every edition. + + Returns ``{}`` for generated pages such as ``genindex`` and ``search``, and + for source formats (notably reStructuredText) whose field lists cannot + express a list of mappings. + """ + try: + path = str(app.env.doc2path(pagename)) + stat = os.stat(path) + except (OSError, KeyError, AttributeError): + return {} + return _read_front_matter(path, stat.st_mtime_ns, stat.st_size) + + +def _front_matter_people(value): + """Interpret one front matter authors/translators value. + + Returns the people to render, or ``None`` when the value is not something + this theme should act on. + + Front matter is stricter than ``html_theme_options`` on purpose. ``authors`` + is shared ground -- docutils treats it as a bibliographic field and nbformat + defines it in the notebook schema -- so a page may already carry one written + for something else entirely, in a shape this theme cannot read. Only the + documented list-of-mappings form counts as an override, and only an + explicitly empty value suppresses the block; anything else is left alone so + the project-level credit still renders. + """ + if value is None or (isinstance(value, (list, tuple, str)) and not value): + return [] + if not isinstance(value, (list, tuple)): + return None + if not all(isinstance(item, dict) for item in value): + return None + people = _normalise_people(value) + return people if len(people) == len(value) else None + + +def _resolve_people(front_matter, config_theme, key): + """Resolve the authors or translators of one page. + + Returns ``(people, suppressed)``. Page front matter replaces the + project-level value outright rather than merging with it, and an absent key + inherits the project value. ``suppressed`` is True when a page set the key + explicitly to nobody, which leaves the block off that page rather than + falling back to the project-wide credit. + """ + if key in front_matter: + people = _front_matter_people(front_matter[key]) + if people is not None: + return people, not people + return _normalise_people(config_theme.get(key)), False + + +def _resolve_label(front_matter, context, key): + """Resolve an attribution label, letting page front matter override it. + + The fallback is whatever the theme configuration already placed in the + context, so ``theme.conf`` stays the only place a default is written down. + """ + value = front_matter[key] if key in front_matter else context.get("theme_" + key) + return "" if value is None else str(value) + + def add_to_context(app, pagename, templatename, context, doctree): """Functions and variable additions to context.""" @@ -548,6 +713,22 @@ def get_github_src_folder(app): # shape leaves room for additive per-page announcements later). context["announcements"] = _build_announcements(config_theme) + # Authors and translators, either of which a page may override in its own + # front matter + front_matter = _page_front_matter(app, pagename) + context["theme_authors"], context["authors_suppressed"] = _resolve_people( + front_matter, config_theme, "authors" + ) + context["theme_translators"], _ = _resolve_people( + front_matter, config_theme, "translators" + ) + context["theme_authors_label"] = _resolve_label( + front_matter, context, "authors_label" + ) + context["theme_translators_label"] = _resolve_label( + front_matter, context, "translators_label" + ) + # Make sure the context values are bool blns = [ "theme_use_edit_page_button", diff --git a/src/quantecon_book_theme/assets/scripts/page-header.js b/src/quantecon_book_theme/assets/scripts/page-header.js index 2f7c2de..c63f756 100644 --- a/src/quantecon_book_theme/assets/scripts/page-header.js +++ b/src/quantecon_book_theme/assets/scripts/page-header.js @@ -6,14 +6,16 @@ export function initPageHeader() { // Add authors to the heading of toc page const authors = document.getElementsByClassName("qe-page__header-authors")[0]; - if (!authors) return; - - const fontSize = authors.getAttribute("font-size"); const h1 = document.querySelector(".main-index h1"); // Check if its the main toc page if (!h1) return; + // A page may have no authors paragraph at all, either because none is + // configured or because it set an empty `authors` list in its front matter. + // The paragraph is still inserted below, since it carries the styling. + const fontSize = authors ? authors.getAttribute("font-size") : null; + // Creating a p tag for styling and author links const newParagraph = document.createElement("p"); newParagraph.setAttribute("id", "qe-page-author-links"); diff --git a/src/quantecon_book_theme/assets/styles/_dark-theme.scss b/src/quantecon_book_theme/assets/styles/_dark-theme.scss index 9c999ba..3674d8d 100644 --- a/src/quantecon_book_theme/assets/styles/_dark-theme.scss +++ b/src/quantecon_book_theme/assets/styles/_dark-theme.scss @@ -73,6 +73,14 @@ body.dark-theme { color: var(--qe-dark-text); } + &-meta { + border-bottom-color: var(--qe-dark-accent); + } + + &-translators { + color: var(--qe-dark-text-muted); + } + &-changed { color: var(--qe-dark-text-muted); border-bottom-color: var(--qe-dark-accent); diff --git a/src/quantecon_book_theme/assets/styles/_page.scss b/src/quantecon_book_theme/assets/styles/_page.scss index 5e2cd35..5f220e4 100644 --- a/src/quantecon_book_theme/assets/styles/_page.scss +++ b/src/quantecon_book_theme/assets/styles/_page.scss @@ -362,6 +362,40 @@ Main page structure and components } } + // Shares the "Last changed" row so the header does not grow a fourth + // stacked line. The accent rule moves here from the button, since it now + // has to span both. + &-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 1.5rem; + border-bottom: 5px solid colors.$primary; + + .qe-page__header-changed { + width: auto; + border-bottom: none; + } + + @media (max-width: breakpoints.$md) { + display: block; + } + } + + &-translators { + margin: 0 0 0.75rem 0; + margin-inline-start: auto; + font-size: 0.85rem; + text-align: end; + color: color.adjust(colors.$body, $lightness: 15%); + + @media (max-width: breakpoints.$md) { + margin: 0 0 0.5rem 0; + font-size: 0.75rem; + text-align: start; + } + } + &-changed { margin: 0.5rem 0 0 0; padding: 0.25rem 0.5rem 0.75rem 0; diff --git a/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html b/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html index 6147e7a..60080b7 100644 --- a/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html +++ b/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html @@ -277,28 +277,42 @@ {%- if theme_authors | length > 0 %} -
- {%- else %} + {%- elif not authors_suppressed %} {%- endif %} + {%- if theme_translators | length > 0 %} + + {%- endif %} {%- if has_git_info and changelog_entries %} diff --git a/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf b/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf index cf1f6bf..5dad8e4 100644 --- a/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf +++ b/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf @@ -7,6 +7,7 @@ announcement = announcement_expires = announcement_style = bar authors = +authors_label = binderhub_url = qetheme_code_style = True dark_logo = @@ -40,6 +41,8 @@ sticky_contents = True repository_branch = repository_url = single_page = False +translators = +translators_label = Translated by twitter = twitter_logo_url = use_issues_button = False diff --git a/tests/test_translators.py b/tests/test_translators.py new file mode 100644 index 0000000..219f782 --- /dev/null +++ b/tests/test_translators.py @@ -0,0 +1,642 @@ +"""Tests for the authors and translators attribution options.""" + +import json +import subprocess +import sys +import textwrap + +import pytest +from bs4 import BeautifulSoup + +from quantecon_book_theme import ( + _md_front_matter, + _nb_front_matter, + _normalise_people, + _resolve_label, + _resolve_people, +) + + +AUTHORS = [ + {"name": "Tom Sargent", "url": "https://tom.example.org"}, + {"name": "John Stachurski", "url": "https://john.example.org"}, +] +TRANSLATORS = [ + {"name": "Zhang San", "url": "https://zhang.example.org"}, + {"name": "Li Si", "url": "https://li.example.org"}, +] + + +class TestThemeOptionDefaults: + """The four options must be declared in theme.conf, with safe defaults.""" + + @pytest.fixture + def option_lines(self, theme_dir): + conf = (theme_dir / "theme.conf").read_text() + return [line.strip() for line in conf.splitlines()] + + @pytest.mark.parametrize( + "declaration", + ["authors =", "authors_label =", "translators =", "translators_label ="], + ) + def test_option_declared_once(self, option_lines, declaration): + matches = [line for line in option_lines if line.startswith(declaration)] + assert len(matches) == 1, f"expected one {declaration!r} line, got {matches}" + + def test_translators_label_default(self, option_lines): + assert "translators_label = Translated by" in option_lines + + @pytest.mark.parametrize("option", ["authors", "authors_label", "translators"]) + def test_list_defaults_are_empty(self, option_lines, option): + """An empty string is falsy in Jinja; the string "[]" is not. + + Declaring ``translators = []`` would put the two-character string "[]" + in the template context, whose length is 2, and every page of every + site would render an empty translators block. + """ + assert f"{option} =" in option_lines + + +class TestTemplateMarkup: + """Assertions about layout.html that the rendering tests cannot make.""" + + @pytest.fixture + def layout(self, theme_dir): + return (theme_dir / "layout.html").read_text() + + def test_authors_links_are_marked_up_as_authors(self, layout): + assert layout.count('rel="author"') == 3, "one per branch of the author loop" + + def test_translators_links_are_not_marked_up_as_authors(self, layout): + translators_block = layout.split("")[1] + translators_block = translators_block.split("{%- endif %}")[0] + assert 'rel="author"' not in translators_block + + def test_suppression_guard_present(self, layout): + """Without this the project ``author`` string leaks back onto a page + that set ``authors: []`` in its front matter.""" + assert "{%- elif not authors_suppressed %}" in layout + + def test_translators_paragraph_has_no_font_size_attribute(self, layout): + """``font-size`` is a non-standard attribute that only exists so + page-header.js can read it back off the authors paragraph. It should + not be copied onto new markup.""" + assert layout.count("font-size=") == 2, "the two authors paragraphs only" + + def test_translators_block_is_conditional(self, layout): + assert "{%- if theme_translators | length > 0 %}" in layout + # The section comment sits inside the conditional, so a site without + # translators gets no stray comment on every page. + marker = "{%- if theme_translators | length > 0 %}" + assert layout.index(marker) < layout.index("") + + +class TestNormalisePeople: + """_normalise_people is the single gate every value passes through.""" + + def test_documented_form_passes_through(self): + assert _normalise_people(AUTHORS) == AUTHORS + + def test_missing_url_becomes_empty_string(self): + assert _normalise_people([{"name": "Solo"}]) == [{"name": "Solo", "url": ""}] + + def test_null_url_becomes_empty_string(self): + assert _normalise_people([{"name": "Solo", "url": None}]) == [ + {"name": "Solo", "url": ""} + ] + + def test_entries_without_a_name_are_dropped(self): + value = [{"url": "https://x.org"}, {"name": " "}, {"name": "Real"}] + assert _normalise_people(value) == [{"name": "Real", "url": ""}] + + def test_bare_string_becomes_one_person(self): + """Previously a string was iterated character by character, emitting + one empty anchor per character.""" + assert _normalise_people("Tom Sargent") == [{"name": "Tom Sargent", "url": ""}] + + def test_list_of_strings(self): + assert _normalise_people(["Tom", "John"]) == [ + {"name": "Tom", "url": ""}, + {"name": "John", "url": ""}, + ] + + def test_single_dict(self): + assert _normalise_people({"name": "Tom", "url": "https://tom.org"}) == [ + {"name": "Tom", "url": "https://tom.org"} + ] + + @pytest.mark.parametrize("value", [None, "", [], (), 42, 3.5, True, object()]) + def test_values_without_people_become_empty(self, value): + assert _normalise_people(value) == [] + + def test_non_string_name_and_url_are_coerced(self): + assert _normalise_people([{"name": 42, "url": 7}]) == [ + {"name": "42", "url": "7"} + ] + + def test_non_mapping_entries_are_skipped(self): + assert _normalise_people([["nested"], {"name": "Real"}]) == [ + {"name": "Real", "url": ""} + ] + + def test_names_are_not_stripped(self): + """Whitespace decides only whether an entry counts, never what renders, + so projects already setting ``authors`` keep identical output.""" + assert _normalise_people([{"name": " Tom ", "url": ""}]) == [ + {"name": " Tom ", "url": ""} + ] + + def test_input_is_not_mutated(self): + original = [{"name": "Tom"}] + _normalise_people(original) + assert original == [{"name": "Tom"}] + + +class TestResolvePeople: + """Page front matter replaces the project value; it never merges.""" + + def test_absent_key_inherits_project_value(self): + people, suppressed = _resolve_people({}, {"authors": AUTHORS}, "authors") + assert people == AUTHORS + assert suppressed is False + + def test_page_value_replaces_rather_than_merges(self): + front_matter = {"authors": [{"name": "Page Author", "url": "https://p.org"}]} + people, suppressed = _resolve_people( + front_matter, {"authors": AUTHORS}, "authors" + ) + assert people == [{"name": "Page Author", "url": "https://p.org"}] + assert suppressed is False + + @pytest.mark.parametrize("empty", [None, "", [], ()]) + def test_any_explicitly_empty_value_suppresses(self, empty): + """All of these say "nobody" rather than "I have nothing to say". + ``None`` is what a bare ``authors:`` key parses to, and an empty string + is what an interpolated template variable leaves behind.""" + people, suppressed = _resolve_people( + {"authors": empty}, {"authors": AUTHORS}, "authors" + ) + assert people == [] + assert suppressed is True + + def test_absent_key_with_no_project_value_is_not_suppressed(self): + """Nothing configured anywhere must keep the existing fallback to + Sphinx's own ``author`` setting.""" + people, suppressed = _resolve_people({}, {}, "authors") + assert people == [] + assert suppressed is False + + def test_translators_resolve_independently_of_authors(self): + config = {"authors": AUTHORS, "translators": TRANSLATORS} + front_matter = {"authors": []} + authors, authors_suppressed = _resolve_people(front_matter, config, "authors") + translators, _ = _resolve_people(front_matter, config, "translators") + assert authors == [] and authors_suppressed is True + assert translators == TRANSLATORS + + @pytest.mark.parametrize( + "value", + [ + "Jane Doe, John Roe", # docutils' own bibliographic field shape + ["Jane Doe", "John Roe"], # a list, but not of mappings + [{"given": "Jane", "family": "Doe"}], # mystmd's shape + [{"name": "Real"}, {"url": "https://x.org"}], # one unusable entry + {"name": "Jane Doe"}, # a bare mapping rather than a list + 42, + ], + ) + def test_unreadable_front_matter_is_left_alone(self, value): + """``authors`` is shared ground: docutils treats it as a bibliographic + field and nbformat defines it in the notebook schema. A page carrying + one written for something else must keep the project credit rather than + rendering it badly or losing the byline.""" + people, suppressed = _resolve_people( + {"authors": value}, {"authors": AUTHORS}, "authors" + ) + assert people == AUTHORS + assert suppressed is False + + def test_unreadable_front_matter_never_suppresses(self): + """Suppression must mean "explicitly nobody", never "I could not read + this" -- otherwise an unrecognised value silently deletes the byline.""" + _, suppressed = _resolve_people( + {"authors": "Jane Doe, John Roe"}, {}, "authors" + ) + assert suppressed is False + + +class TestResolveLabel: + def test_falls_back_to_the_configured_context_value(self): + context = {"theme_translators_label": "Translated by"} + assert _resolve_label({}, context, "translators_label") == "Translated by" + + def test_page_front_matter_wins(self): + context = {"theme_translators_label": "Translated by"} + front_matter = {"translators_label": "译者"} + assert _resolve_label(front_matter, context, "translators_label") == "译者" + + def test_explicit_empty_removes_the_label(self): + context = {"theme_translators_label": "Translated by"} + assert ( + _resolve_label({"translators_label": ""}, context, "translators_label") + == "" + ) + + def test_null_becomes_empty_rather_than_the_string_none(self): + assert _resolve_label({"authors_label": None}, {}, "authors_label") == "" + + def test_missing_context_key_is_empty(self): + assert _resolve_label({}, {}, "authors_label") == "" + + +class TestFrontMatterReaders: + """Front matter is read from the source file rather than env.metadata. + + docutils treats ``authors`` as a bibliographic field, so a value routed + through env.metadata comes back smart-quoted and split on commas -- and in + a different shape again depending on the ``language`` setting, because not + every locale lists ``authors`` among its bibliographic fields. + """ + + def test_reads_a_leading_yaml_block(self, tmp_path): + page = tmp_path / "page.md" + page.write_text( + textwrap.dedent( + """\ + --- + translators: + - name: Zhang San + url: https://zhang.example.org + --- + + # Title + """ + ) + ) + assert _md_front_matter(str(page)) == { + "translators": [{"name": "Zhang San", "url": "https://zhang.example.org"}] + } + + def test_jupytext_markdown(self, tmp_path): + """The shape QuantEcon lecture repos actually use.""" + page = tmp_path / "lecture.md" + page.write_text( + textwrap.dedent( + """\ + --- + jupytext: + text_representation: + extension: .md + format_name: myst + kernelspec: + display_name: Python 3 + name: python3 + translators: + - name: Li Si + --- + + # Lecture + """ + ) + ) + front_matter = _md_front_matter(str(page)) + assert front_matter["translators"] == [{"name": "Li Si"}] + assert "kernelspec" in front_matter + + def test_unicode_names(self, tmp_path): + page = tmp_path / "page.md" + page.write_text( + "---\ntranslators:\n - name: 李四\n---\n\n# T\n", encoding="utf-8" + ) + assert _md_front_matter(str(page))["translators"] == [{"name": "李四"}] + + def test_explicit_empty_list_survives(self, tmp_path): + """An empty list must stay distinguishable from an absent key.""" + page = tmp_path / "page.md" + page.write_text("---\ntranslators: []\n---\n\n# T\n") + front_matter = _md_front_matter(str(page)) + assert front_matter == {"translators": []} + assert "translators" in front_matter + + @pytest.mark.parametrize( + "source", + [ + "# No front matter\n", + "\n---\ntranslators: []\n---\n", # must start on the first line + "---\ntranslators: []\n", # unterminated + "---\n\ttranslators: [\n---\n", # malformed yaml + "---\n- just\n- a list\n---\n", # not a mapping + "", + ], + ) + def test_sources_without_usable_front_matter(self, tmp_path, source): + page = tmp_path / "page.md" + page.write_text(source) + assert _md_front_matter(str(page)) == {} + + def test_missing_file(self, tmp_path): + assert _md_front_matter(str(tmp_path / "nope.md")) == {} + assert _nb_front_matter(str(tmp_path / "nope.ipynb")) == {} + + def test_byte_order_mark_does_not_hide_front_matter(self, tmp_path): + """Sphinx reads sources as utf-8-sig, so MyST parses a BOM'd page's + front matter. Reading it as plain utf-8 here would leave the marker on + the opening fence and silently drop every override on that page.""" + page = tmp_path / "page.md" + page.write_text( + "---\ntranslators:\n - name: BOM Translator\n---\n\n# T\n", + encoding="utf-8-sig", + ) + assert page.read_bytes().startswith(b"\xef\xbb\xbf") + assert _md_front_matter(str(page))["translators"] == [ + {"name": "BOM Translator"} + ] + + def test_byte_order_mark_on_a_notebook(self, tmp_path): + notebook = tmp_path / "page.ipynb" + notebook.write_text( + json.dumps({"cells": [], "metadata": {"translators": [{"name": "BOM"}]}}), + encoding="utf-8-sig", + ) + assert _nb_front_matter(str(notebook))["translators"] == [{"name": "BOM"}] + + @pytest.mark.parametrize("width", [3, 4, 8]) + def test_longer_dash_fences(self, tmp_path, width): + """MyST accepts a fence of three or more dashes, closed by a run at + least as long.""" + fence = "-" * width + page = tmp_path / "page.md" + page.write_text(f"{fence}\ntranslators:\n - name: Fenced\n{fence}\n\n# T\n") + assert _md_front_matter(str(page))["translators"] == [{"name": "Fenced"}] + + def test_closing_fence_may_be_longer_than_the_opening_one(self, tmp_path): + page = tmp_path / "page.md" + page.write_text("---\ntranslators:\n - name: Fenced\n-----\n\n# T\n") + assert _md_front_matter(str(page))["translators"] == [{"name": "Fenced"}] + + def test_closing_fence_may_not_be_shorter(self, tmp_path): + page = tmp_path / "page.md" + page.write_text("-----\ntranslators: []\n---\n\n# T\n") + assert _md_front_matter(str(page)) == {} + + @pytest.mark.parametrize("opening", ["--", "---x", "- --", "***"]) + def test_lines_that_are_not_fences(self, tmp_path, opening): + page = tmp_path / "page.md" + page.write_text(f"{opening}\ntranslators: []\n{opening}\n\n# T\n") + assert _md_front_matter(str(page)) == {} + + def test_notebook_metadata(self, tmp_path): + notebook = tmp_path / "page.ipynb" + notebook.write_text( + json.dumps( + { + "cells": [], + "metadata": {"translators": [{"name": "Wang Wu"}]}, + "nbformat": 4, + "nbformat_minor": 5, + } + ) + ) + assert _nb_front_matter(str(notebook)) == {"translators": [{"name": "Wang Wu"}]} + + @pytest.mark.parametrize( + "source", ["not json", "[]", '{"cells": []}', '{"metadata": []}'] + ) + def test_notebooks_without_usable_metadata(self, tmp_path, source): + notebook = tmp_path / "page.ipynb" + notebook.write_text(source) + assert _nb_front_matter(str(notebook)) == {} + + +def build_site(tmp_path, theme_options, pages, bom_pages=()): + """Build a throwaway site and return ``{pagename: BeautifulSoup}``. + + Options go through a generated ``conf.py`` rather than ``-D``, because + Sphinx keeps a ``-D html_theme_options.authors=...`` value as a string. + Names listed in ``bom_pages`` are written with a byte-order mark, which is + what a Windows editor produces and what Sphinx's ``source_encoding`` + default expects to strip. + """ + src = tmp_path / "src" + src.mkdir() + (src / "conf.py").write_text( + "project = 'Attribution'\n" + "author = 'Project Author'\n" + "master_doc = 'index'\n" + "extensions = ['myst_nb']\n" + "html_theme = 'quantecon_book_theme'\n" + f"html_theme_options = {json.dumps(theme_options)}\n" + ) + toctree = "\n".join(sorted(pages)) + (src / "index.md").write_text(f"# Index\n\n```{{toctree}}\n{toctree}\n```\n") + for name, source in pages.items(): + encoding = "utf-8-sig" if name in bom_pages else "utf-8" + (src / f"{name}.md").write_text(source, encoding=encoding) + + out = tmp_path / "out" + result = subprocess.run( + [sys.executable, "-m", "sphinx", "-b", "html", "-a", "-q", str(src), str(out)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise AssertionError(f"sphinx-build failed:\n{result.stdout}\n{result.stderr}") + return { + name: BeautifulSoup( + (out / f"{name}.html").read_text(encoding="utf-8"), "html.parser" + ) + for name in pages + } + + +def attribution(soup, kind): + """Return the rendered text of the authors or translators paragraph.""" + paragraph = soup.select_one(f"p.qe-page__header-{kind}") + return None if paragraph is None else " ".join(paragraph.get_text().split()) + + +@pytest.mark.build +class TestRendering: + """End-to-end rendering of both blocks through a real Sphinx build.""" + + @pytest.fixture(scope="class") + def pages(self, tmp_path_factory): + return build_site( + tmp_path_factory.mktemp("attribution"), + {"authors": AUTHORS, "translators": TRANSLATORS}, + { + "inherit": "# Inherit\n", + "override": ( + "---\n" + "authors:\n" + " - name: Page Author\n" + " url: https://page.example.org\n" + "translators:\n" + " - name: Solo Translator\n" + " url: https://solo.example.org\n" + "---\n\n# Override\n" + ), + "suppress": "---\nauthors: []\ntranslators: []\n---\n\n# Suppress\n", + "labels": ( + "---\nauthors_label: 作者\ntranslators_label: 译者\n---\n\n# Labels\n" + ), + "nourl": "---\ntranslators:\n - name: No Url\n---\n\n# No url\n", + "docinfo": "---\nauthors: Jane Doe, John Roe\n---\n\n# Docinfo\n", + "bom": "---\ntranslators:\n - name: BOM Translator\n---\n\n# Bom\n", + }, + bom_pages=("bom",), + ) + + def test_project_values_render_on_a_plain_page(self, pages): + soup = pages["inherit"] + assert attribution(soup, "authors") == "Tom Sargent and John Stachurski" + assert attribution(soup, "translators") == "Translated by Zhang San and Li Si" + + def test_only_authors_are_marked_up_for_crawlers(self, pages): + soup = pages["inherit"] + assert len(soup.select("p.qe-page__header-authors a[rel=author]")) == 2 + assert soup.select("p.qe-page__header-translators a[rel=author]") == [] + + def test_translators_share_the_last_changed_row(self, pages): + """Translators sit in the meta wrapper next to the "Last changed" + control rather than on a line of their own, so the header does not grow + a fourth row.""" + meta = pages["inherit"].select_one("div.qe-page__header-meta") + assert meta is not None + assert meta.select_one("p.qe-page__header-translators") is not None + + def test_page_front_matter_replaces_both_lists(self, pages): + soup = pages["override"] + assert attribution(soup, "authors") == "Page Author" + assert attribution(soup, "translators") == "Translated by Solo Translator" + + def test_explicit_empty_lists_suppress_both_blocks(self, pages): + soup = pages["suppress"] + assert attribution(soup, "authors") is None + assert attribution(soup, "translators") is None + + def test_suppressed_authors_do_not_fall_back_to_the_project_author(self, pages): + """The template's fallback prints Sphinx's own ``author`` setting; a + page that suppressed its authors must not get it instead.""" + assert "Project Author" not in pages["suppress"].get_text() + + def test_labels_are_overridable_per_page(self, pages): + soup = pages["labels"] + assert attribution(soup, "authors").startswith("作者") + assert attribution(soup, "translators").startswith("译者") + assert soup.select_one("span.qe-page__header-translators-label").text == "译者" + + def test_default_authors_label_is_empty(self, pages): + """Authors keep their label-free rendering unless a label is set.""" + assert pages["inherit"].select("span.qe-page__header-authors-label") == [] + + def test_translator_without_a_url_renders_as_plain_text(self, pages): + soup = pages["nourl"] + assert attribution(soup, "translators") == "Translated by No Url" + assert soup.select("p.qe-page__header-translators a") == [] + + def test_a_docutils_style_authors_field_leaves_the_byline_alone(self, pages): + """A page carrying `authors:` as docutils' bibliographic field must keep + the project byline rather than collapsing both names into one bad link + or losing the byline entirely.""" + assert attribution(pages["docinfo"], "authors") == ( + "Tom Sargent and John Stachurski" + ) + + def test_front_matter_survives_a_byte_order_mark(self, pages): + """Sphinx reads sources as utf-8-sig, so MyST honours this page's front + matter; the theme must not disagree with the parser about the same + file and silently credit the wrong people.""" + assert attribution(pages["bom"], "translators") == ( + "Translated by BOM Translator" + ) + + +@pytest.mark.build +class TestRenderingWithoutTranslators: + """A site that configures nothing new must render as it always has.""" + + @pytest.fixture(scope="class") + def pages(self, tmp_path_factory): + return build_site( + tmp_path_factory.mktemp("plain"), + {"authors": AUTHORS}, + {"plain": "# Plain\n"}, + ) + + def test_no_translators_block(self, pages): + assert pages["plain"].select("p.qe-page__header-translators") == [] + + def test_no_translators_comment_left_behind(self, pages): + assert "Translators section" not in str(pages["plain"]) + + def test_no_meta_wrapper_left_behind(self, pages): + """The wrapper that puts translators on the "Last changed" row only + exists when there are translators, so the accent rule stays on the + button and the markup is unchanged for everyone else.""" + assert pages["plain"].select("div.qe-page__header-meta") == [] + + def test_authors_still_render(self, pages): + assert attribution(pages["plain"], "authors") == ( + "Tom Sargent and John Stachurski" + ) + + +@pytest.mark.build +class TestJoinFormatting: + """The translators list joins exactly like the authors list does.""" + + @pytest.fixture(scope="class") + def pages(self, tmp_path_factory): + people = [ + {"name": "One", "url": "https://one.example.org"}, + {"name": "Two", "url": "https://two.example.org"}, + {"name": "Three", "url": "https://three.example.org"}, + {"name": "Four", "url": "https://four.example.org"}, + ] + return build_site( + tmp_path_factory.mktemp("join"), + {"authors": people, "translators": people}, + { + f"n{count}": ( + "---\ntranslators:\n" + + "".join( + f" - name: {p['name']}\n url: {p['url']}\n" + for p in people[:count] + ) + + "authors:\n" + + "".join( + f" - name: {p['name']}\n url: {p['url']}\n" + for p in people[:count] + ) + + f"---\n\n# {count}\n" + ) + for count in (1, 2, 3, 4) + }, + ) + + @pytest.mark.parametrize( + "count,expected", + [ + (1, "One"), + (2, "One and Two"), + (3, "One, Two, and Three"), + (4, "One, Two, Three, and Four"), + ], + ) + def test_translators_join(self, pages, count, expected): + assert attribution(pages[f"n{count}"], "translators") == ( + f"Translated by {expected}" + ) + + @pytest.mark.parametrize( + "count,expected", + [ + (1, "One"), + (2, "One and Two"), + (3, "One, Two, and Three"), + (4, "One, Two, Three, and Four"), + ], + ) + def test_authors_join_is_unchanged(self, pages, count, expected): + assert attribution(pages[f"n{count}"], "authors") == expected diff --git a/tests/visual/__snapshots__/desktop-chrome/admonitions-header.png b/tests/visual/__snapshots__/desktop-chrome/admonitions-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/admonitions-header.png and b/tests/visual/__snapshots__/desktop-chrome/admonitions-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/admonitions-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/admonitions-sidebar.png index e652a70..3c2c2ed 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/admonitions-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/admonitions-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/bold-text.png b/tests/visual/__snapshots__/desktop-chrome/bold-text.png index ecc68c7..b96b02e 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/bold-text.png and b/tests/visual/__snapshots__/desktop-chrome/bold-text.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/code-block.png b/tests/visual/__snapshots__/desktop-chrome/code-block.png index 07fc7b3..cd587b2 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/code-block.png and b/tests/visual/__snapshots__/desktop-chrome/code-block.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/code-blocks-header.png b/tests/visual/__snapshots__/desktop-chrome/code-blocks-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/code-blocks-header.png and b/tests/visual/__snapshots__/desktop-chrome/code-blocks-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/code-blocks-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/code-blocks-sidebar.png index 4dd0087..e673cab 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/code-blocks-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/code-blocks-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/code-blocks.png b/tests/visual/__snapshots__/desktop-chrome/code-blocks.png index b7c4907..1d66f0c 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/code-blocks.png and b/tests/visual/__snapshots__/desktop-chrome/code-blocks.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/cross-references-header.png b/tests/visual/__snapshots__/desktop-chrome/cross-references-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/cross-references-header.png and b/tests/visual/__snapshots__/desktop-chrome/cross-references-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/cross-references-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/cross-references-sidebar.png index 1006a77..033e8f0 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/cross-references-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/cross-references-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/cross-references.png b/tests/visual/__snapshots__/desktop-chrome/cross-references.png index 623476c..c26bc0d 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/cross-references.png and b/tests/visual/__snapshots__/desktop-chrome/cross-references.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/dark-mode.png b/tests/visual/__snapshots__/desktop-chrome/dark-mode.png index 3205f3f..229c1f1 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/dark-mode.png and b/tests/visual/__snapshots__/desktop-chrome/dark-mode.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/definition-list.png b/tests/visual/__snapshots__/desktop-chrome/definition-list.png index a05617c..bdb6bb2 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/definition-list.png and b/tests/visual/__snapshots__/desktop-chrome/definition-list.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/definition-lists-header.png b/tests/visual/__snapshots__/desktop-chrome/definition-lists-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/definition-lists-header.png and b/tests/visual/__snapshots__/desktop-chrome/definition-lists-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/definition-lists-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/definition-lists-sidebar.png index 6e1ca66..e0d024f 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/definition-lists-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/definition-lists-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/definition-lists.png b/tests/visual/__snapshots__/desktop-chrome/definition-lists.png index d71f7d9..ba48fae 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/definition-lists.png and b/tests/visual/__snapshots__/desktop-chrome/definition-lists.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/exercises-header.png b/tests/visual/__snapshots__/desktop-chrome/exercises-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/exercises-header.png and b/tests/visual/__snapshots__/desktop-chrome/exercises-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/exercises-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/exercises-sidebar.png index 8074c1b..b38f075 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/exercises-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/exercises-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/exercises.png b/tests/visual/__snapshots__/desktop-chrome/exercises.png index e448aee..30b6dc5 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/exercises.png and b/tests/visual/__snapshots__/desktop-chrome/exercises.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/figures-header.png b/tests/visual/__snapshots__/desktop-chrome/figures-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/figures-header.png and b/tests/visual/__snapshots__/desktop-chrome/figures-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/figures-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/figures-sidebar.png index c6f836f..cc70e2e 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/figures-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/figures-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/figures.png b/tests/visual/__snapshots__/desktop-chrome/figures.png index 60e94d1..beb066f 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/figures.png and b/tests/visual/__snapshots__/desktop-chrome/figures.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/fstring-interpolation.png b/tests/visual/__snapshots__/desktop-chrome/fstring-interpolation.png index 7edcf79..d88cced 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/fstring-interpolation.png and b/tests/visual/__snapshots__/desktop-chrome/fstring-interpolation.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/glossary.png b/tests/visual/__snapshots__/desktop-chrome/glossary.png index ed46cd5..d53ea7f 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/glossary.png and b/tests/visual/__snapshots__/desktop-chrome/glossary.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/italic-text.png b/tests/visual/__snapshots__/desktop-chrome/italic-text.png index ecc68c7..b96b02e 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/italic-text.png and b/tests/visual/__snapshots__/desktop-chrome/italic-text.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/long-page-header.png b/tests/visual/__snapshots__/desktop-chrome/long-page-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/long-page-header.png and b/tests/visual/__snapshots__/desktop-chrome/long-page-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/long-page-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/long-page-sidebar.png index 6b1f187..3961b44 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/long-page-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/long-page-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/long-page.png b/tests/visual/__snapshots__/desktop-chrome/long-page.png index 85d9a38..09e1706 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/long-page.png and b/tests/visual/__snapshots__/desktop-chrome/long-page.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/math-equation.png b/tests/visual/__snapshots__/desktop-chrome/math-equation.png index 55d178b..4ba34d9 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/math-equation.png and b/tests/visual/__snapshots__/desktop-chrome/math-equation.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/math-header.png b/tests/visual/__snapshots__/desktop-chrome/math-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/math-header.png and b/tests/visual/__snapshots__/desktop-chrome/math-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/math-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/math-sidebar.png index 59402c0..d84a1ca 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/math-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/math-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/prob-matrix-header.png b/tests/visual/__snapshots__/desktop-chrome/prob-matrix-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/prob-matrix-header.png and b/tests/visual/__snapshots__/desktop-chrome/prob-matrix-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/prob-matrix-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/prob-matrix-sidebar.png index 7fc180e..a6b2cea 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/prob-matrix-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/prob-matrix-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/proofs-header.png b/tests/visual/__snapshots__/desktop-chrome/proofs-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/proofs-header.png and b/tests/visual/__snapshots__/desktop-chrome/proofs-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/proofs-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/proofs-sidebar.png index 40d0e56..ea957f9 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/proofs-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/proofs-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/proofs.png b/tests/visual/__snapshots__/desktop-chrome/proofs.png index 260455c..33d092a 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/proofs.png and b/tests/visual/__snapshots__/desktop-chrome/proofs.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/tables-header.png b/tests/visual/__snapshots__/desktop-chrome/tables-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/tables-header.png and b/tests/visual/__snapshots__/desktop-chrome/tables-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/tables-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/tables-sidebar.png index 4670ea8..b014fee 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/tables-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/tables-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/tables.png b/tests/visual/__snapshots__/desktop-chrome/tables.png index 884c22b..2c47c63 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/tables.png and b/tests/visual/__snapshots__/desktop-chrome/tables.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-header.png b/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-header.png and b/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-sidebar.png index 276421d..54a723a 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting.png b/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting.png index 08da7f7..64daa67 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting.png and b/tests/visual/__snapshots__/desktop-chrome/toc-deep-nesting.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/typography-header.png b/tests/visual/__snapshots__/desktop-chrome/typography-header.png index 363c9c3..ea23618 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/typography-header.png and b/tests/visual/__snapshots__/desktop-chrome/typography-header.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/typography-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/typography-sidebar.png index 1d275af..0276d65 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/typography-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/typography-sidebar.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/typography.png b/tests/visual/__snapshots__/desktop-chrome/typography.png index e5b621f..fa88438 100644 Binary files a/tests/visual/__snapshots__/desktop-chrome/typography.png and b/tests/visual/__snapshots__/desktop-chrome/typography.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/admonitions-header.png b/tests/visual/__snapshots__/mobile-chrome/admonitions-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/admonitions-header.png and b/tests/visual/__snapshots__/mobile-chrome/admonitions-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/admonitions-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/admonitions-sidebar.png index 778f57f..56abc70 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/admonitions-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/admonitions-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/admonitions.png b/tests/visual/__snapshots__/mobile-chrome/admonitions.png index c7d9dab..dc1c241 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/admonitions.png and b/tests/visual/__snapshots__/mobile-chrome/admonitions.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/code-blocks-header.png b/tests/visual/__snapshots__/mobile-chrome/code-blocks-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/code-blocks-header.png and b/tests/visual/__snapshots__/mobile-chrome/code-blocks-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/code-blocks-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/code-blocks-sidebar.png index 82cd98e..0a9c8ab 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/code-blocks-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/code-blocks-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/code-blocks.png b/tests/visual/__snapshots__/mobile-chrome/code-blocks.png index 9f872ac..2655e68 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/code-blocks.png and b/tests/visual/__snapshots__/mobile-chrome/code-blocks.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/cross-references-header.png b/tests/visual/__snapshots__/mobile-chrome/cross-references-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/cross-references-header.png and b/tests/visual/__snapshots__/mobile-chrome/cross-references-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/cross-references-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/cross-references-sidebar.png index 8012a56..0951b2a 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/cross-references-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/cross-references-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/cross-references.png b/tests/visual/__snapshots__/mobile-chrome/cross-references.png index 3416299..578ffed 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/cross-references.png and b/tests/visual/__snapshots__/mobile-chrome/cross-references.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/dark-mode.png b/tests/visual/__snapshots__/mobile-chrome/dark-mode.png index 246b509..1c6db7b 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/dark-mode.png and b/tests/visual/__snapshots__/mobile-chrome/dark-mode.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/definition-lists-header.png b/tests/visual/__snapshots__/mobile-chrome/definition-lists-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/definition-lists-header.png and b/tests/visual/__snapshots__/mobile-chrome/definition-lists-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/definition-lists-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/definition-lists-sidebar.png index cab9189..cb73504 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/definition-lists-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/definition-lists-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/definition-lists.png b/tests/visual/__snapshots__/mobile-chrome/definition-lists.png index 0dcd49c..9edbe46 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/definition-lists.png and b/tests/visual/__snapshots__/mobile-chrome/definition-lists.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/exercises-header.png b/tests/visual/__snapshots__/mobile-chrome/exercises-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/exercises-header.png and b/tests/visual/__snapshots__/mobile-chrome/exercises-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/exercises-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/exercises-sidebar.png index 6140d59..0c26656 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/exercises-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/exercises-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/exercises.png b/tests/visual/__snapshots__/mobile-chrome/exercises.png index 7c9d60b..6733450 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/exercises.png and b/tests/visual/__snapshots__/mobile-chrome/exercises.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/figures-header.png b/tests/visual/__snapshots__/mobile-chrome/figures-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/figures-header.png and b/tests/visual/__snapshots__/mobile-chrome/figures-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/figures-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/figures-sidebar.png index 3dc3571..874f962 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/figures-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/figures-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/figures.png b/tests/visual/__snapshots__/mobile-chrome/figures.png index 28da75e..e87c98b 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/figures.png and b/tests/visual/__snapshots__/mobile-chrome/figures.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/long-page-header.png b/tests/visual/__snapshots__/mobile-chrome/long-page-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/long-page-header.png and b/tests/visual/__snapshots__/mobile-chrome/long-page-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/long-page-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/long-page-sidebar.png index 22f67c4..b71c80b 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/long-page-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/long-page-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/long-page.png b/tests/visual/__snapshots__/mobile-chrome/long-page.png index 5b3de5e..50705fc 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/long-page.png and b/tests/visual/__snapshots__/mobile-chrome/long-page.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/math-header.png b/tests/visual/__snapshots__/mobile-chrome/math-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/math-header.png and b/tests/visual/__snapshots__/mobile-chrome/math-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/math-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/math-sidebar.png index 7aff895..9a5a358 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/math-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/math-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/math.png b/tests/visual/__snapshots__/mobile-chrome/math.png index b11cde3..1a944f4 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/math.png and b/tests/visual/__snapshots__/mobile-chrome/math.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/prob-matrix-header.png b/tests/visual/__snapshots__/mobile-chrome/prob-matrix-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/prob-matrix-header.png and b/tests/visual/__snapshots__/mobile-chrome/prob-matrix-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/prob-matrix-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/prob-matrix-sidebar.png index 590b11f..f23406b 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/prob-matrix-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/prob-matrix-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/proofs-header.png b/tests/visual/__snapshots__/mobile-chrome/proofs-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/proofs-header.png and b/tests/visual/__snapshots__/mobile-chrome/proofs-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/proofs-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/proofs-sidebar.png index ce1cc3b..53a7174 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/proofs-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/proofs-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/proofs.png b/tests/visual/__snapshots__/mobile-chrome/proofs.png index 8060701..d0b8f6a 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/proofs.png and b/tests/visual/__snapshots__/mobile-chrome/proofs.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/tables-header.png b/tests/visual/__snapshots__/mobile-chrome/tables-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/tables-header.png and b/tests/visual/__snapshots__/mobile-chrome/tables-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/tables-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/tables-sidebar.png index dbf8fbc..6791fee 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/tables-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/tables-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/tables.png b/tests/visual/__snapshots__/mobile-chrome/tables.png index 4f7793c..fb46ca3 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/tables.png and b/tests/visual/__snapshots__/mobile-chrome/tables.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-header.png b/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-header.png and b/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-sidebar.png index a980ed7..778be8f 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting.png b/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting.png index ad78a64..f70de78 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting.png and b/tests/visual/__snapshots__/mobile-chrome/toc-deep-nesting.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/typography-header.png b/tests/visual/__snapshots__/mobile-chrome/typography-header.png index 26335d9..7906a41 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/typography-header.png and b/tests/visual/__snapshots__/mobile-chrome/typography-header.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/typography-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/typography-sidebar.png index 0a0022d..fd0609c 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/typography-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/typography-sidebar.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/typography.png b/tests/visual/__snapshots__/mobile-chrome/typography.png index 957fa35..28bbe0e 100644 Binary files a/tests/visual/__snapshots__/mobile-chrome/typography.png and b/tests/visual/__snapshots__/mobile-chrome/typography.png differ