diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5964115..b039e55 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,9 @@ on:
# PRs. To bump the pin, update FIXTURES_SHA below.
env:
FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures
- FIXTURES_SHA: d8ffc17c753ecf45fa25c6062827e1aa9de201b3
+ # Includes the announcement-banner demo in the fixtures config (merged in
+ # fixtures #1), so the preview/visual build exercises the banner.
+ FIXTURES_SHA: 32763e43b893c1520df8e58c0c6e10d81db7f1ac
# 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 3252bfe..aedc713 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: d8ffc17c753ecf45fa25c6062827e1aa9de201b3
+ FIXTURES_SHA: 32763e43b893c1520df8e58c0c6e10d81db7f1ac
jobs:
# /update-new-snapshots — only creates MISSING snapshots (safe for adding new tests)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de193c0..22bfd55 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### 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).
+
### 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/user/announcements.md b/docs/user/announcements.md
new file mode 100644
index 0000000..1133200
--- /dev/null
+++ b/docs/user/announcements.md
@@ -0,0 +1,99 @@
+# Announcement Banner
+
+Display a dismissible announcement at the top of every page — useful for
+site-wide notices such as a software upgrade, a link to changelog notes, or a
+scheduled maintenance window.
+
+```{contents}
+:local:
+:depth: 1
+```
+
+## Basic usage
+
+Set the `announcement` option in `html_theme_options`. The value is an HTML
+string, so you can include emphasis and a link:
+
+```yaml
+sphinx:
+ config:
+ html_theme_options:
+ announcement: 'We upgraded to Anaconda 2026.06 — see the CHANGELOG.'
+```
+
+For a `conf.py` project:
+
+```python
+html_theme_options = {
+ "announcement": 'We upgraded to Anaconda 2026.06 — see the CHANGELOG.',
+}
+```
+
+Leave the option empty (the default) and no banner is shown.
+
+## Style
+
+Choose how the banner looks with `announcement_style`:
+
+```yaml
+sphinx:
+ config:
+ html_theme_options:
+ announcement: 'We upgraded to Anaconda 2026.06.'
+ announcement_style: bar # "bar" (default) or "callout"
+```
+
+- **`bar`** (default) — a thin, full-width strip with centered text, sitting just
+ below the toolbar and scrolling away with the page. Discreet; good for standing
+ notices.
+- **`callout`** — a boxed notice in the content column with an accent border.
+ More prominent; good for a notice you want to stand out.
+
+Both styles adapt to dark mode and right-to-left layouts, and both are
+dismissible. An unrecognized value logs a warning and falls back to `bar`.
+
+## Dismissal
+
+Readers can dismiss the banner with the `×` button. The dismissal is remembered
+in the browser's `localStorage`, so it stays hidden on future visits.
+
+Dismissal is keyed to the **content of the message**. When you change the
+`announcement` text, the banner re-appears for everyone — including readers who
+dismissed the previous message. This means you can reuse the banner for a new
+notice without worrying that people who dismissed the last one will miss it.
+
+## Expiry date
+
+Add an optional `announcement_expires` date (ISO `YYYY-MM-DD`) to have the
+banner disappear automatically. The banner shows **through the end of** that
+day, in the reader's local timezone:
+
+```yaml
+sphinx:
+ config:
+ html_theme_options:
+ announcement: 'We upgraded to Anaconda 2026.06 — see the CHANGELOG.'
+ announcement_expires: "2026-07-01"
+```
+
+The expiry is enforced two ways, so it works whether or not the site is rebuilt:
+
+- **In the reader's browser** — the banner hides itself at the end of the expiry
+ day in the reader's local timezone, even if the published site has not been
+ rebuilt since.
+- **At build time** — once the expiry day is well past (a one-day grace ensures
+ it has ended in every timezone), the banner is omitted from the generated HTML
+ entirely. Around the expiry date the markup is still emitted and the per-reader
+ browser check above governs exactly when it disappears.
+
+If `announcement_expires` is not a valid `YYYY-MM-DD` date, the build logs a
+warning and ignores the expiry (the banner keeps showing) — a typo will never
+silently hide an active announcement.
+
+## Per-page announcements
+
+Per-page announcements (for example, flagging that a single lecture now uses a
+newer library version) are not yet supported. Progress is tracked in
+[issue #403](https://github.com/QuantEcon/quantecon-book-theme/issues/403); the
+banner is built to accept per-page notices additively, so this can be added
+without changing how the site-wide `announcement` option works.
diff --git a/docs/user/configuration.md b/docs/user/configuration.md
index 0c61c25..1cb7e41 100644
--- a/docs/user/configuration.md
+++ b/docs/user/configuration.md
@@ -102,6 +102,23 @@ html_theme_options = {
This disables the left navigation bar.
+## Announcement Banner
+
+Show a dismissible notice at the top of every page (for example, a software
+upgrade with a link to changelog notes):
+
+```python
+html_theme_options = {
+ ...
+ "announcement": 'We upgraded to Anaconda 2026.06 — see the CHANGELOG.',
+ "announcement_expires": "2026-07-01", # optional ISO date; banner auto-hides after this day
+ ...
+}
+```
+
+See [Announcement Banner](announcements.md) for dismissal behavior and expiry
+details.
+
## Add Authors
Display a list of authors just below the page title:
diff --git a/docs/user/index.md b/docs/user/index.md
index 436d2e1..1372e60 100644
--- a/docs/user/index.md
+++ b/docs/user/index.md
@@ -51,6 +51,7 @@ html_theme_options = {
:caption: User Guide
configuration
+announcements
layout
notebooks
launch
diff --git a/src/quantecon_book_theme/__init__.py b/src/quantecon_book_theme/__init__.py
index 2378854..8910523 100644
--- a/src/quantecon_book_theme/__init__.py
+++ b/src/quantecon_book_theme/__init__.py
@@ -5,7 +5,7 @@
import hashlib
from functools import lru_cache
import subprocess
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from docutils import nodes
from sphinx.util import logging
@@ -223,6 +223,58 @@ def get_relative_time(past_date):
return f"{years} year{'s' if years != 1 else ''} ago"
+def _parse_iso_date(value):
+ """Parse a ``YYYY-MM-DD`` string into a ``date``, or return ``None``."""
+ if not value:
+ return None
+ try:
+ return datetime.strptime(value.strip(), "%Y-%m-%d").date()
+ except (ValueError, TypeError):
+ return None
+
+
+def _build_announcements(config_theme):
+ """Build the list of announcements to render in the page banner.
+
+ Currently this is a single site-wide announcement (from the ``announcement``
+ option), but it returns a *list* so per-page announcements can be appended
+ additively in future without changing the template or JavaScript (tracked in
+ GitHub issue #403). Each entry is a dict with:
+
+ - ``html``: the (trusted) HTML message
+ - ``id``: a short content hash, used to key dismissal in localStorage so an
+ edited message re-appears for everyone who dismissed the old one
+ - ``expires_iso``: the ISO expiry date (or ``""``), checked client-side so the
+ banner disappears for visitors on the date even without a rebuild
+
+ An announcement whose expiry has already passed at build time is omitted
+ entirely; the client-side check handles expiry that falls between builds.
+ """
+ announcements = []
+ message = (config_theme.get("announcement") or "").strip()
+ if message:
+ expires = (config_theme.get("announcement_expires") or "").strip()
+ expires_date = _parse_iso_date(expires)
+ # Build-time skip is only an optimization: drop a clearly-stale notice so
+ # it isn't shipped in the HTML at all. Keep it conservative — the expiry
+ # day ends at different UTC instants across timezones, and the client
+ # side hides the banner precisely per-reader, so we only skip once the
+ # date is past for every real-world timezone (a one-day UTC grace
+ # covers the full UTC-12..UTC+14 range).
+ today_utc = datetime.now(timezone.utc).date()
+ if expires_date is not None and today_utc > expires_date + timedelta(days=1):
+ return announcements
+ announcement_id = hashlib.sha1(message.encode("utf-8")).hexdigest()[:12]
+ announcements.append(
+ {
+ "html": message,
+ "id": announcement_id,
+ "expires_iso": expires if expires_date is not None else "",
+ }
+ )
+ return announcements
+
+
def _process_languages(config_theme):
"""Validate and normalize language switcher configuration.
@@ -492,6 +544,10 @@ def get_github_src_folder(app):
config_theme
)
+ # Build the announcement banner list (currently site-wide only; the list
+ # shape leaves room for additive per-page announcements later).
+ context["announcements"] = _build_announcements(config_theme)
+
# Make sure the context values are bool
blns = [
"theme_use_edit_page_button",
@@ -627,6 +683,44 @@ def _string_or_bool(var):
return var is None
+# Announcement banner styles. "bar" is a thin full-width strip; "callout" is
+# the original boxed in-column notice.
+_VALID_ANNOUNCEMENT_STYLES = ["bar", "callout"]
+_DEFAULT_ANNOUNCEMENT_STYLE = "bar"
+
+
+def validate_announcement(app):
+ """Validate the announcement options once, at build start.
+
+ Fails open: an unparseable ``announcement_expires`` is dropped (cleared) so a
+ typo can never silently hide an active announcement, and an unknown
+ ``announcement_style`` falls back to the default. Warnings are logged so the
+ misconfiguration surfaces during the build.
+ """
+ theme_options = app.config.html_theme_options
+ expires = theme_options.get("announcement_expires", "")
+ if expires and _parse_iso_date(expires) is None:
+ SPHINX_LOGGER.warning(
+ "Invalid announcement_expires %r. Expected ISO date YYYY-MM-DD. "
+ "Ignoring expiry; the announcement will not auto-expire.",
+ expires,
+ )
+ theme_options["announcement_expires"] = ""
+
+ style = str(theme_options.get("announcement_style", "") or "").strip().lower()
+ if not style:
+ style = _DEFAULT_ANNOUNCEMENT_STYLE
+ elif style not in _VALID_ANNOUNCEMENT_STYLES:
+ SPHINX_LOGGER.warning(
+ "Unknown announcement_style %r. Valid styles: %s. Falling back to %r.",
+ style,
+ ", ".join(_VALID_ANNOUNCEMENT_STYLES),
+ _DEFAULT_ANNOUNCEMENT_STYLE,
+ )
+ style = _DEFAULT_ANNOUNCEMENT_STYLE
+ theme_options["announcement_style"] = style
+
+
# Built-in text color schemes
_VALID_COLOR_SCHEMES = ["seoul256", "gruvbox", "none"]
@@ -676,6 +770,7 @@ def setup(app):
app.connect("html-page-context", add_hub_urls)
app.connect("builder-inited", add_plugins_list)
+ app.connect("builder-inited", validate_announcement)
app.connect("builder-inited", validate_color_scheme)
app.connect("builder-inited", setup_pygments_css)
app.connect("html-page-context", hash_html_assets)
diff --git a/src/quantecon_book_theme/assets/scripts/announcement.js b/src/quantecon_book_theme/assets/scripts/announcement.js
new file mode 100644
index 0000000..ec6101d
--- /dev/null
+++ b/src/quantecon_book_theme/assets/scripts/announcement.js
@@ -0,0 +1,93 @@
+/**
+ * Announcement Banner Module
+ *
+ * Renders a dismissible announcement bar at the top of the page. The bar is
+ * server-rendered hidden and revealed here only for rows that survive two
+ * checks, so the reader never sees a flash of a notice they already dismissed
+ * or one that has expired:
+ *
+ * - Dismissed: each row carries a content hash (`data-announcement-id`). The
+ * set of dismissed ids is stored in localStorage, so an edited message —
+ * which produces a new hash — re-appears even for readers who dismissed the
+ * old one. Dismissal persists across visits until the message changes.
+ * - Expired: an optional `data-announcement-expires` (YYYY-MM-DD) hides the
+ * row once the visitor's clock is past the end of that day, so a notice
+ * disappears on the date even if the site has not been rebuilt.
+ *
+ * The bar may hold more than one row; the logic is intentionally n-aware so
+ * per-page announcements can be added additively later without changes here.
+ */
+
+const STORAGE_KEY = "qe-dismissed-announcements";
+const MAX_REMEMBERED = 20;
+
+function readDismissed() {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (e) {
+ // localStorage unavailable (e.g. private mode) or corrupt value.
+ return [];
+ }
+}
+
+function rememberDismissed(id) {
+ try {
+ const dismissed = readDismissed().filter((value) => value !== id);
+ dismissed.push(id);
+ // Cap the list so it can't grow unbounded over the site's lifetime.
+ const trimmed = dismissed.slice(-MAX_REMEMBERED);
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
+ } catch (e) {
+ // Persisting is best-effort; dismissal still works for this page view.
+ }
+}
+
+function isExpired(expires) {
+ if (!expires) return false;
+ // Parse YYYY-MM-DD into explicit local-time components (unambiguous across
+ // engines, and not subject to the "date-only string is UTC" parsing rule).
+ // Expire at the very end of that calendar day in the visitor's timezone.
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(expires.trim());
+ if (!m) return false; // fail open on bad/unsupported format
+ const endOfDay = new Date(+m[1], +m[2] - 1, +m[3], 23, 59, 59, 999);
+ if (Number.isNaN(endOfDay.getTime())) return false;
+ return new Date() > endOfDay;
+}
+
+export function initAnnouncement() {
+ const bar = document.querySelector(".qe-announcement-bar");
+ if (!bar) return;
+
+ const dismissed = readDismissed();
+
+ bar.querySelectorAll(".qe-announcement").forEach((row) => {
+ const id = row.getAttribute("data-announcement-id");
+ const expires = row.getAttribute("data-announcement-expires");
+
+ // Drop rows the reader has dismissed or that have expired.
+ if (dismissed.includes(id) || isExpired(expires)) {
+ row.remove();
+ return;
+ }
+
+ const closeButton = row.querySelector(".qe-announcement__close");
+ if (closeButton) {
+ closeButton.addEventListener("click", function () {
+ rememberDismissed(id);
+ row.remove();
+ if (!bar.querySelector(".qe-announcement")) {
+ bar.setAttribute("hidden", "");
+ }
+ });
+ }
+ });
+
+ // Reveal the bar only if at least one row survived.
+ if (bar.querySelector(".qe-announcement")) {
+ bar.removeAttribute("hidden");
+ } else {
+ bar.remove();
+ }
+}
diff --git a/src/quantecon_book_theme/assets/scripts/index.js b/src/quantecon_book_theme/assets/scripts/index.js
index 064f6c4..7126968 100644
--- a/src/quantecon_book_theme/assets/scripts/index.js
+++ b/src/quantecon_book_theme/assets/scripts/index.js
@@ -19,6 +19,7 @@ import { initPageHeader, initChangelog } from "./page-header.js";
import { initStderrWarnings } from "./stderr-warnings.js";
import { initScrollSpy } from "./scrollspy.js";
import { initLanguageSwitcher } from "./language-switcher.js";
+import { initAnnouncement } from "./announcement.js";
document.addEventListener("DOMContentLoaded", function () {
// Load feather icon set
@@ -54,4 +55,7 @@ document.addEventListener("DOMContentLoaded", function () {
// Initialize language switcher
initLanguageSwitcher();
+
+ // Initialize the dismissible announcement banner
+ initAnnouncement();
});
diff --git a/src/quantecon_book_theme/assets/styles/_announcement.scss b/src/quantecon_book_theme/assets/styles/_announcement.scss
new file mode 100644
index 0000000..b5e2e90
--- /dev/null
+++ b/src/quantecon_book_theme/assets/styles/_announcement.scss
@@ -0,0 +1,210 @@
+/*
+-----------------------------------
+ANNOUNCEMENT BANNER
+Dismissible notice rendered near the top of the page. Two selectable styles
+(set via the `announcement_style` theme option):
+
+ --bar thin full-width strip, discrete, scrolls away (default)
+ --callout boxed in-column notice (the original style)
+
+Both share the same markup, JS, and global dismissal; only the look differs.
+The bar holds one or more stacked rows (n-aware for future per-page notices).
+-----------------------------------
+*/
+
+@use "colors";
+
+/* Shared structure ----------------------------------------------------- */
+.qe-announcement-bar {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0 0 1.5rem 0;
+
+ // Honour the server-rendered `hidden` attribute even though the bar is a
+ // flex container (announcement.js removes it once rows are evaluated).
+ &[hidden] {
+ display: none;
+ }
+}
+
+.qe-announcement {
+ display: flex;
+ align-items: center;
+ font-family: "Source Sans Pro", sans-serif;
+ line-height: 1.4;
+
+ &__text {
+ // Drop the bottom margin from a trailing paragraph if the message is rich.
+ > :last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ &__close {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ line-height: 1;
+ transition: color 0.15s ease;
+ }
+}
+
+/* Style: callout — boxed in-column notice (original style) -------------- */
+.qe-announcement-bar--callout .qe-announcement {
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.6rem 0.9rem;
+ background-color: #eaf4fb;
+ border: 1px solid #cfe6f7;
+ border-left: 4px solid colors.$primary;
+ border-radius: 4px;
+ font-size: 0.95rem;
+ color: #1a3a4d;
+
+ .qe-announcement__text {
+ flex: 1;
+
+ a {
+ color: colors.$primary;
+ text-decoration: underline;
+ }
+ }
+
+ .qe-announcement__close {
+ color: #5a7184;
+
+ &:hover {
+ color: #1a3a4d;
+ }
+
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: 2px;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
+}
+
+body.dark-theme .qe-announcement-bar--callout .qe-announcement {
+ background-color: #1e2a38;
+ border-color: #2f4356;
+ border-left-color: #5fa8e0;
+ color: #d4e6f4;
+
+ .qe-announcement__text a {
+ color: #6cb6ff;
+ }
+
+ .qe-announcement__close {
+ color: #9bb4c8;
+
+ &:hover {
+ color: #ffffff;
+ }
+ }
+}
+
+// RTL: move the accent border to the right edge.
+[dir="rtl"] .qe-announcement-bar--callout .qe-announcement {
+ border-left: 1px solid #cfe6f7;
+ border-right: 4px solid colors.$primary;
+}
+
+body.dark-theme[dir="rtl"] .qe-announcement-bar--callout .qe-announcement,
+[dir="rtl"] body.dark-theme .qe-announcement-bar--callout .qe-announcement {
+ border-left-color: #2f4356;
+ border-right-color: #5fa8e0;
+}
+
+/* Style: bar — thin full-width strip (default) ------------------------- */
+.qe-announcement-bar--bar {
+ // Break out of the centered content column to span the viewport width.
+ // .qe-page is centered in the window when the sidebar drawer is closed (its
+ // default), so this aligns the strip to the window edges.
+ width: 100vw;
+ margin-left: calc(50% - 50vw);
+ margin-right: calc(50% - 50vw);
+ // Sit flush beneath the fixed toolbar (50px tall + its 1px bottom border) by
+ // absorbing the gap that .qe-page's 4rem top padding would otherwise leave,
+ // so the strip reads as a second tier of the header. It scrolls away with
+ // the page.
+ margin-top: calc(51px - 4rem);
+ margin-bottom: 1.5rem;
+ gap: 0;
+
+ .qe-announcement {
+ position: relative;
+ justify-content: center;
+ padding: 0.25rem 2.75rem; // thin strip; horizontal room clears the close
+ background-color: #f3f4f6;
+ border-bottom: 1px solid #e3e5ea;
+ font-size: 0.82rem;
+ color: #3a3a3a;
+ }
+
+ .qe-announcement__text {
+ flex: 0 1 auto;
+ text-align: center;
+
+ a {
+ color: colors.$primary;
+ text-decoration: underline;
+ }
+ }
+
+ .qe-announcement__close {
+ position: absolute;
+ right: 1rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #7a7a7a;
+
+ &:hover {
+ color: #1a1a1a;
+ }
+
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: 2px;
+ }
+
+ svg {
+ width: 14px;
+ height: 14px;
+ }
+ }
+}
+
+body.dark-theme .qe-announcement-bar--bar .qe-announcement {
+ background-color: #1b2130;
+ border-bottom-color: #313a52;
+ color: #c8cbd6;
+
+ .qe-announcement__text a {
+ color: #6cb6ff;
+ }
+}
+
+body.dark-theme .qe-announcement-bar--bar .qe-announcement__close {
+ color: #8a92a6;
+
+ &:hover {
+ color: #ffffff;
+ }
+}
+
+// RTL: move the close button to the left edge.
+[dir="rtl"] .qe-announcement-bar--bar .qe-announcement__close {
+ right: auto;
+ left: 1rem;
+}
diff --git a/src/quantecon_book_theme/assets/styles/index.scss b/src/quantecon_book_theme/assets/styles/index.scss
index 370a93b..8043992 100644
--- a/src/quantecon_book_theme/assets/styles/index.scss
+++ b/src/quantecon_book_theme/assets/styles/index.scss
@@ -34,6 +34,7 @@ All @forward rules must be at the top before any other rules.
@forward "sidebar";
@forward "page";
@forward "content";
+@forward "announcement";
@forward "admonitions";
@forward "footnotes";
@forward "stderr";
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 e033647..6147e7a 100644
--- a/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html
+++ b/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html
@@ -183,6 +183,23 @@
+ {# Announcement banner — hidden until announcement.js decides which
+ rows survive the dismissed/expired check (avoids a flash). #}
+ {%- if announcements %}
+
+ {%- for ann in announcements %}
+
+ {%- endfor %}
+
+ {%- endif %}
+
{# Determine sticky TOC and autoexpand settings once #}
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 377228e..cf1f6bf 100644
--- a/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf
+++ b/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf
@@ -3,6 +3,9 @@ inherit = pydata_sphinx_theme
stylesheet = styles/quantecon-book-theme.css
[options]
+announcement =
+announcement_expires =
+announcement_style = bar
authors =
binderhub_url =
qetheme_code_style = True
diff --git a/tests/test_announcement.py b/tests/test_announcement.py
new file mode 100644
index 0000000..7b22c07
--- /dev/null
+++ b/tests/test_announcement.py
@@ -0,0 +1,235 @@
+"""
+Tests for the dismissible announcement banner.
+
+Verifies that:
+- The `announcement` / `announcement_expires` options are registered in theme.conf
+- The SCSS partial, JS module, and layout markup are wired in
+- `_parse_iso_date` parses ISO dates and rejects bad input
+- `_build_announcements` builds the render list, hashes content for dismissal,
+ and skips announcements that have already expired at build time
+- `validate_announcement` fails open on an invalid expiry date
+"""
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from unittest.mock import MagicMock
+
+from quantecon_book_theme import (
+ _build_announcements,
+ _parse_iso_date,
+ validate_announcement,
+)
+
+# Paths
+THEME_DIR = Path("src/quantecon_book_theme/theme/quantecon_book_theme")
+ASSETS_DIR = Path("src/quantecon_book_theme/assets")
+
+
+def _future_date(days=365):
+ return (datetime.now(timezone.utc).date() + timedelta(days=days)).isoformat()
+
+
+def _past_date(days=1):
+ return (datetime.now(timezone.utc).date() - timedelta(days=days)).isoformat()
+
+
+class TestAnnouncementThemeOption:
+ """The options must be registered in theme.conf so Sphinx accepts them."""
+
+ def test_announcement_option_exists(self):
+ content = (THEME_DIR / "theme.conf").read_text()
+ assert "announcement =" in content
+
+ def test_announcement_expires_option_exists(self):
+ content = (THEME_DIR / "theme.conf").read_text()
+ assert "announcement_expires =" in content
+
+ def test_announcement_style_option_defaults_to_bar(self):
+ content = (THEME_DIR / "theme.conf").read_text()
+ lines = content.splitlines()
+ matching = [
+ line for line in lines if line.strip().startswith("announcement_style =")
+ ]
+ assert len(matching) == 1
+ assert matching[0].strip() == "announcement_style = bar"
+
+
+class TestAnnouncementAssets:
+ """The SCSS, JS, and template pieces must be present and wired together."""
+
+ def test_scss_partial_exists(self):
+ content = (ASSETS_DIR / "styles" / "_announcement.scss").read_text()
+ assert ".qe-announcement" in content
+
+ def test_scss_partial_is_forwarded(self):
+ content = (ASSETS_DIR / "styles" / "index.scss").read_text()
+ assert '@forward "announcement"' in content
+
+ def test_scss_has_dark_mode_styles(self):
+ content = (ASSETS_DIR / "styles" / "_announcement.scss").read_text()
+ assert "body.dark-theme .qe-announcement" in content
+
+ def test_scss_has_rtl_styles(self):
+ content = (ASSETS_DIR / "styles" / "_announcement.scss").read_text()
+ assert '[dir="rtl"] .qe-announcement' in content
+
+ def test_js_module_exists(self):
+ content = (ASSETS_DIR / "scripts" / "announcement.js").read_text()
+ assert "qe-dismissed-announcements" in content
+ assert "export function initAnnouncement" in content
+
+ def test_js_module_is_imported(self):
+ content = (ASSETS_DIR / "scripts" / "index.js").read_text()
+ assert 'from "./announcement.js"' in content
+ assert "initAnnouncement()" in content
+
+ def test_layout_renders_announcement_loop(self):
+ content = (THEME_DIR / "layout.html").read_text()
+ assert "qe-announcement-bar" in content
+ assert "data-announcement-id" in content
+ assert "data-announcement-expires" in content
+
+ def test_layout_renders_style_modifier(self):
+ content = (THEME_DIR / "layout.html").read_text()
+ assert "qe-announcement-bar--" in content
+ assert "theme_announcement_style" in content
+
+ def test_scss_defines_both_styles(self):
+ content = (ASSETS_DIR / "styles" / "_announcement.scss").read_text()
+ assert ".qe-announcement-bar--bar" in content
+ assert ".qe-announcement-bar--callout" in content
+
+
+class TestParseIsoDate:
+ """_parse_iso_date should parse YYYY-MM-DD and reject everything else."""
+
+ def test_valid_date(self):
+ assert _parse_iso_date("2026-07-01").isoformat() == "2026-07-01"
+
+ def test_whitespace_is_stripped(self):
+ assert _parse_iso_date(" 2026-07-01 ").isoformat() == "2026-07-01"
+
+ def test_empty_returns_none(self):
+ assert _parse_iso_date("") is None
+ assert _parse_iso_date(None) is None
+
+ def test_invalid_format_returns_none(self):
+ assert _parse_iso_date("July 1 2026") is None
+ assert _parse_iso_date("2026/07/01") is None
+ assert _parse_iso_date("not-a-date") is None
+
+
+class TestBuildAnnouncements:
+ """_build_announcements turns config into the list the template renders."""
+
+ def test_no_message_is_empty(self):
+ assert _build_announcements({}) == []
+ assert _build_announcements({"announcement": ""}) == []
+ assert _build_announcements({"announcement": " "}) == []
+
+ def test_message_produces_one_entry(self):
+ result = _build_announcements({"announcement": "Hello world"})
+ assert len(result) == 1
+ assert result[0]["html"] == "Hello world"
+ assert result[0]["expires_iso"] == ""
+ assert result[0]["id"] # a non-empty content hash
+
+ def test_id_is_stable_for_same_message(self):
+ a = _build_announcements({"announcement": "Same message"})[0]["id"]
+ b = _build_announcements({"announcement": "Same message"})[0]["id"]
+ assert a == b
+
+ def test_id_changes_when_message_changes(self):
+ a = _build_announcements({"announcement": "Message A"})[0]["id"]
+ b = _build_announcements({"announcement": "Message B"})[0]["id"]
+ assert a != b
+
+ def test_future_expiry_is_kept(self):
+ future = _future_date()
+ result = _build_announcements(
+ {"announcement": "Upgrade notice", "announcement_expires": future}
+ )
+ assert len(result) == 1
+ assert result[0]["expires_iso"] == future
+
+ def test_clearly_past_expiry_is_skipped_at_build_time(self):
+ # Past the one-day UTC grace (covers all timezones) -> omitted entirely.
+ result = _build_announcements(
+ {"announcement": "Stale notice", "announcement_expires": _past_date(3)}
+ )
+ assert result == []
+
+ def test_yesterday_expiry_is_kept_at_build_time(self):
+ # Within the one-day grace: it may still be the expiry day for some
+ # reader's timezone, so the notice is kept in the HTML and the client
+ # side hides it precisely per-reader.
+ yesterday = _past_date(1)
+ result = _build_announcements(
+ {"announcement": "Ended yesterday (UTC)", "announcement_expires": yesterday}
+ )
+ assert len(result) == 1
+ assert result[0]["expires_iso"] == yesterday
+
+ def test_today_expiry_is_kept(self):
+ today = datetime.now(timezone.utc).date().isoformat()
+ result = _build_announcements(
+ {"announcement": "Ends today", "announcement_expires": today}
+ )
+ assert len(result) == 1
+
+ def test_invalid_expiry_is_ignored_not_expired(self):
+ # _build_announcements treats an unparseable date as no expiry (fail open).
+ result = _build_announcements(
+ {"announcement": "Keep showing", "announcement_expires": "bad-date"}
+ )
+ assert len(result) == 1
+ assert result[0]["expires_iso"] == ""
+
+
+class TestValidateAnnouncement:
+ """validate_announcement fails open: a bad date is cleared, not the message."""
+
+ def _make_app(self, theme_options):
+ app = MagicMock()
+ app.config.html_theme_options = theme_options
+ return app
+
+ def test_invalid_date_is_cleared(self):
+ app = self._make_app(
+ {"announcement": "Notice", "announcement_expires": "July 1"}
+ )
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_expires"] == ""
+ # The message itself is untouched.
+ assert app.config.html_theme_options["announcement"] == "Notice"
+
+ def test_valid_date_is_preserved(self):
+ app = self._make_app({"announcement_expires": "2026-07-01"})
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_expires"] == "2026-07-01"
+
+ def test_empty_date_is_left_alone(self):
+ app = self._make_app({"announcement_expires": ""})
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_expires"] == ""
+
+ def test_valid_style_is_preserved(self):
+ for style in ("bar", "callout"):
+ app = self._make_app({"announcement_style": style})
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_style"] == style
+
+ def test_style_is_case_insensitive(self):
+ app = self._make_app({"announcement_style": "Callout"})
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_style"] == "callout"
+
+ def test_unknown_style_falls_back_to_bar(self):
+ app = self._make_app({"announcement_style": "fancy"})
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_style"] == "bar"
+
+ def test_missing_style_defaults_to_bar(self):
+ app = self._make_app({"announcement": "Notice"})
+ validate_announcement(app)
+ assert app.config.html_theme_options["announcement_style"] == "bar"
diff --git a/tests/visual/__snapshots__/desktop-chrome/admonitions-header.png b/tests/visual/__snapshots__/desktop-chrome/admonitions-header.png
index 886afb3..363c9c3 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 a7f5afa..e652a70 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/admonitions.png b/tests/visual/__snapshots__/desktop-chrome/admonitions.png
index 57a6011..6a65211 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome/admonitions.png and b/tests/visual/__snapshots__/desktop-chrome/admonitions.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome/bold-text.png b/tests/visual/__snapshots__/desktop-chrome/bold-text.png
index eb1e87c..ecc68c7 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 06dfa33..07fc7b3 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 886afb3..363c9c3 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 8ff2018..4dd0087 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 7eb1f5b..b7c4907 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 886afb3..363c9c3 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 ab63e9b..1006a77 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 94841e5..623476c 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 00afa27..3205f3f 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 dc44e04..a05617c 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 886afb3..363c9c3 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 01bb10e..6e1ca66 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 385c6b8..d71f7d9 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 886afb3..363c9c3 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 4500450..8074c1b 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 5c9c0fc..e448aee 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 886afb3..363c9c3 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 69214fd..c6f836f 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 2f26bf4..60e94d1 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 49b3e8a..7edcf79 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 88c70b2..ed46cd5 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/intro-sidebar.png b/tests/visual/__snapshots__/desktop-chrome/intro-sidebar.png
index d6eb6ad..0af0ec1 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome/intro-sidebar.png and b/tests/visual/__snapshots__/desktop-chrome/intro-sidebar.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome/intro.png b/tests/visual/__snapshots__/desktop-chrome/intro.png
index 8e3a7c3..182615b 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome/intro.png and b/tests/visual/__snapshots__/desktop-chrome/intro.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome/italic-text.png b/tests/visual/__snapshots__/desktop-chrome/italic-text.png
index eb1e87c..ecc68c7 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 886afb3..363c9c3 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 dbe7688..6b1f187 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 098e535..85d9a38 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 b1aba8a..55d178b 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 886afb3..363c9c3 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 5a8adf2..59402c0 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/math.png b/tests/visual/__snapshots__/desktop-chrome/math.png
index 31ac943..fb8b85b 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome/math.png and b/tests/visual/__snapshots__/desktop-chrome/math.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 886afb3..363c9c3 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 65230af..7fc180e 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 886afb3..363c9c3 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 619f22f..40d0e56 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 204d0e5..260455c 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 886afb3..363c9c3 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 2a2bf07..4670ea8 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 4aecc5d..884c22b 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 886afb3..363c9c3 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 0b570bd..276421d 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 2d359fb..08da7f7 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 886afb3..363c9c3 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 fb816c6..1d275af 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 7f77782..e5b621f 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 ca3f151..26335d9 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 90b03ca..778f57f 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 98d6c15..c7d9dab 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 ca3f151..26335d9 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 bcafc27..82cd98e 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 699d49f..9f872ac 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 ca3f151..26335d9 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 799f21d..8012a56 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 1fc8a93..3416299 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 edac089..246b509 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-list.png b/tests/visual/__snapshots__/mobile-chrome/definition-list.png
index b4d1560..bbd11f2 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome/definition-list.png and b/tests/visual/__snapshots__/mobile-chrome/definition-list.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 ca3f151..26335d9 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 669db51..cab9189 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 206be96..0dcd49c 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 ca3f151..26335d9 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 b21a39f..6140d59 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 d3285c3..7c9d60b 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 ca3f151..26335d9 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 c62e43f..3dc3571 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 d6f8b77..28da75e 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/glossary.png b/tests/visual/__snapshots__/mobile-chrome/glossary.png
index c0d59c1..5ba8ce3 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome/glossary.png and b/tests/visual/__snapshots__/mobile-chrome/glossary.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome/intro-sidebar.png b/tests/visual/__snapshots__/mobile-chrome/intro-sidebar.png
index d37057c..3bedf75 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome/intro-sidebar.png and b/tests/visual/__snapshots__/mobile-chrome/intro-sidebar.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome/intro.png b/tests/visual/__snapshots__/mobile-chrome/intro.png
index ea61dec..7dff2b4 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome/intro.png and b/tests/visual/__snapshots__/mobile-chrome/intro.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 ca3f151..26335d9 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 34ec566..22f67c4 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 5b3fc7e..5b3de5e 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-equation.png b/tests/visual/__snapshots__/mobile-chrome/math-equation.png
index aac2156..dd1fba4 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome/math-equation.png and b/tests/visual/__snapshots__/mobile-chrome/math-equation.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome/math-header.png b/tests/visual/__snapshots__/mobile-chrome/math-header.png
index ca3f151..26335d9 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 0686341..7aff895 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 9ca8192..b11cde3 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 ca3f151..26335d9 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 1c46899..590b11f 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 ca3f151..26335d9 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 e9b7a11..ce1cc3b 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 040f0ef..8060701 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 ca3f151..26335d9 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 96153f6..dbf8fbc 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 b5183bd..4f7793c 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 ca3f151..26335d9 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 584a36c..a980ed7 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 d9cf7ea..ad78a64 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 ca3f151..26335d9 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 2f62905..0a0022d 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 293cd1c..957fa35 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome/typography.png and b/tests/visual/__snapshots__/mobile-chrome/typography.png differ