From aad2a3f93d3e1d940565e5c6dc6b48d1eade5f38 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Fri, 19 Jun 2026 13:20:32 +1000 Subject: [PATCH 1/8] feat: add dismissible announcement banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a site-wide announcement banner configured via the new `announcement` theme option (HTML allowed, so it can include emphasis and a link to changelog notes). Readers dismiss it with a ×; the dismissal persists in localStorage and is keyed to a content hash, so editing the message re-shows the banner for everyone who dismissed the previous one. An optional `announcement_expires` ISO date auto-hides the banner after that day — enforced client-side (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. The banner defaults to empty/off, so existing sites are unchanged. The renderer iterates a list of notices internally so per-page announcements can be added additively later (tracked in #403). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 + docs/user/announcements.md | 75 +++++++ docs/user/configuration.md | 17 ++ docs/user/index.md | 1 + src/quantecon_book_theme/__init__.py | 72 +++++++ .../assets/scripts/announcement.js | 89 +++++++++ .../assets/scripts/index.js | 4 + .../assets/styles/_announcement.scss | 111 +++++++++++ .../assets/styles/index.scss | 1 + .../theme/quantecon_book_theme/layout.html | 17 ++ .../theme/quantecon_book_theme/theme.conf | 2 + tests/test_announcement.py | 183 ++++++++++++++++++ 12 files changed, 575 insertions(+) create mode 100644 docs/user/announcements.md create mode 100644 src/quantecon_book_theme/assets/scripts/announcement.js create mode 100644 src/quantecon_book_theme/assets/styles/_announcement.scss create mode 100644 tests/test_announcement.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf38cf..3d4916c 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. 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). + ### CI - **CI Node bumped 20 → 24** — Node 20 reached end-of-life in April 2026, and the grouped npm updates in #400 raised engine floors (`sass-loader` 17 requires Node ≥22.11). Node 24 is the current active LTS (supported to April 2028). Applies to `ci.yml`, `docs.yml`, and `update-snapshots.yml`; `.nvmrc` and the contributor docs (`CONTRIBUTING.md`, `docs/developer/setup.md`) move to Node 24 in step so local dev matches CI. - **Stabilized flaky mobile-chrome visual tests on math-heavy pages** — after the Playwright 1.57→1.60 (Chromium) bump in #400, `math`/`proofs`/`cross-references` full-page screenshots intermittently rendered ~60px shorter than baseline, failing Playwright's dimension check before pixel tolerances apply. `waitForReady` now waits for `document.fonts.ready` and polls until the document height holds steady for 750ms, instead of a fixed 500ms sleep after MathJax typesetting. diff --git a/docs/user/announcements.md b/docs/user/announcements.md new file mode 100644 index 0000000..6b8c6c5 --- /dev/null +++ b/docs/user/announcements.md @@ -0,0 +1,75 @@ +# 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. + +## 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 once the date passes, + even if the published site has not been rebuilt since. +- **At build time** — if the date has already passed when the site is built, the + banner is omitted from the generated HTML entirely. + +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..8d29452 100644 --- a/src/quantecon_book_theme/__init__.py +++ b/src/quantecon_book_theme/__init__.py @@ -223,6 +223,55 @@ 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: drop if we are already past the expiry day. + if ( + expires_date is not None + and datetime.now(timezone.utc).date() > expires_date + ): + 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 +541,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 +680,24 @@ def _string_or_bool(var): return var is None +def validate_announcement(app): + """Validate the ``announcement_expires`` date once, at build start. + + Fails open: an unparseable date is dropped (cleared) so a typo can never + silently hide an active announcement, and a warning is 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"] = "" + + # Built-in text color schemes _VALID_COLOR_SCHEMES = ["seoul256", "gruvbox", "none"] @@ -676,6 +747,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..17dba10 --- /dev/null +++ b/src/quantecon_book_theme/assets/scripts/announcement.js @@ -0,0 +1,89 @@ +/** + * 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; + // Expire at the end of the given calendar day, in the visitor's timezone. + const expiryDate = new Date(`${expires}T23:59:59`); + if (Number.isNaN(expiryDate.getTime())) return false; // fail open on bad date + return new Date() > expiryDate; +} + +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..906ec20 --- /dev/null +++ b/src/quantecon_book_theme/assets/styles/_announcement.scss @@ -0,0 +1,111 @@ +/* +----------------------------------- +ANNOUNCEMENT BANNER +Dismissible notice bar at the top of the page content column. +Holds one or more stacked rows (n-aware for future per-page notices). +----------------------------------- +*/ + +@use "colors"; + +.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: 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-family: "Source Sans Pro", sans-serif; + font-size: 0.95rem; + line-height: 1.4; + color: #1a3a4d; + + &__text { + flex: 1; + + a { + color: colors.$primary; + text-decoration: underline; + } + + // 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; + color: #5a7184; + line-height: 1; + transition: color 0.15s ease; + + &:hover { + color: #1a3a4d; + } + + &:focus-visible { + outline: 2px solid colors.$primary; + outline-offset: 2px; + } + + svg { + width: 16px; + height: 16px; + } + } +} + +/* Dark mode */ +body.dark-theme .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 and align the close button to the left */ +[dir="rtl"] .qe-announcement { + border-left: 1px solid #cfe6f7; + border-right: 4px solid colors.$primary; +} + +body.dark-theme[dir="rtl"] .qe-announcement, +[dir="rtl"] body.dark-theme .qe-announcement { + border-left-color: #2f4356; + border-right-color: #5fa8e0; +} 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..2f3f884 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 %} + + {%- 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..38543f5 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,8 @@ inherit = pydata_sphinx_theme stylesheet = styles/quantecon-book-theme.css [options] +announcement = +announcement_expires = 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..ef40f44 --- /dev/null +++ b/tests/test_announcement.py @@ -0,0 +1,183 @@ +""" +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 + + +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 + + +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_past_expiry_is_skipped_at_build_time(self): + result = _build_announcements( + {"announcement": "Stale notice", "announcement_expires": _past_date()} + ) + assert result == [] + + 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"] == "" From 2d6d32c308f6cfd9d1c2a6979b1fd1516d4a5d5c Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Fri, 19 Jun 2026 13:41:12 +1000 Subject: [PATCH 2/8] fix: address Copilot review on announcement expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes from the #404 review: - announcement.js: parse the expiry `YYYY-MM-DD` with an explicit regex into local-time date components and expire at 23:59:59.999, instead of `new Date("...T23:59:59")`. Removes the ~1s-early cutoff and any cross-engine ambiguity in how the date string is parsed. - __init__.py: the build-time expiry skip compared a UTC "today" against the expiry date, while the client-side check is the reader's local end-of-day — so a rebuild just after UTC midnight could omit a banner that's still the expiry day in Americas timezones. The skip now keeps a one-day UTC grace so a notice is never dropped from the HTML while it's still "today" anywhere; the client side hides it precisely per-reader. Tests and the expiry docs updated to match. Co-Authored-By: Claude Opus 4.8 --- docs/user/announcements.md | 11 +++++++---- src/quantecon_book_theme/__init__.py | 15 +++++++++------ .../assets/scripts/announcement.js | 12 ++++++++---- tests/test_announcement.py | 16 ++++++++++++++-- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/docs/user/announcements.md b/docs/user/announcements.md index 6b8c6c5..25044be 100644 --- a/docs/user/announcements.md +++ b/docs/user/announcements.md @@ -57,10 +57,13 @@ sphinx: 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 once the date passes, - even if the published site has not been rebuilt since. -- **At build time** — if the date has already passed when the site is built, the - banner is omitted from the generated HTML entirely. +- **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 diff --git a/src/quantecon_book_theme/__init__.py b/src/quantecon_book_theme/__init__.py index 8d29452..1fe7630 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 @@ -255,11 +255,14 @@ def _build_announcements(config_theme): if message: expires = (config_theme.get("announcement_expires") or "").strip() expires_date = _parse_iso_date(expires) - # Build-time skip: drop if we are already past the expiry day. - if ( - expires_date is not None - and datetime.now(timezone.utc).date() > expires_date - ): + # 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( diff --git a/src/quantecon_book_theme/assets/scripts/announcement.js b/src/quantecon_book_theme/assets/scripts/announcement.js index 17dba10..ec6101d 100644 --- a/src/quantecon_book_theme/assets/scripts/announcement.js +++ b/src/quantecon_book_theme/assets/scripts/announcement.js @@ -46,10 +46,14 @@ function rememberDismissed(id) { function isExpired(expires) { if (!expires) return false; - // Expire at the end of the given calendar day, in the visitor's timezone. - const expiryDate = new Date(`${expires}T23:59:59`); - if (Number.isNaN(expiryDate.getTime())) return false; // fail open on bad date - return new Date() > expiryDate; + // 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() { diff --git a/tests/test_announcement.py b/tests/test_announcement.py index ef40f44..2ab53b7 100644 --- a/tests/test_announcement.py +++ b/tests/test_announcement.py @@ -133,12 +133,24 @@ def test_future_expiry_is_kept(self): assert len(result) == 1 assert result[0]["expires_iso"] == future - def test_past_expiry_is_skipped_at_build_time(self): + 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()} + {"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( From c259490ed2a68b78fad2b5d97079d698aeccdedb Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Fri, 19 Jun 2026 13:56:52 +1000 Subject: [PATCH 3/8] ci: point fixtures pin at announcement-banner demo Bump FIXTURES_SHA in both ci.yml and update-snapshots.yml to the fixtures commit that adds an announcement to the fixtures _config.yml, so this PR's Netlify preview and visual build show the new banner. Both workflows are kept in sync so regenerated snapshots are produced against the same input. The banner shifts every page's layout, so the visual job is expected to fail until the baselines are regenerated via /update-snapshots. See QuantEcon/quantecon-book-theme-fixtures#1 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 5 ++++- .github/workflows/update-snapshots.yml | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd538a4..1b17c70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,10 @@ on: # PRs. To bump the pin, update FIXTURES_SHA below. env: FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures - FIXTURES_SHA: d8ffc17c753ecf45fa25c6062827e1aa9de201b3 + # Pinned to the announcement-banner demo branch (fixtures PR #1) so this PR's + # preview/visual build shows the new banner. Re-pin to the merge commit once + # that fixtures PR lands. + FIXTURES_SHA: 1aa4a7288cfca2d3a8020affdea536f74ba6c453 # 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 08f4f77..1f2b1b4 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -7,7 +7,9 @@ on: # pinned SHA. Keep FIXTURES_SHA in sync with .github/workflows/ci.yml. env: FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures - FIXTURES_SHA: d8ffc17c753ecf45fa25c6062827e1aa9de201b3 + # Kept in sync with ci.yml — pinned to the announcement-banner demo branch + # (fixtures PR #1) so regenerated baselines include the banner. + FIXTURES_SHA: 1aa4a7288cfca2d3a8020affdea536f74ba6c453 jobs: # /update-new-snapshots — only creates MISSING snapshots (safe for adding new tests) From 21b005af1ba8584bfaa0333d8e049db31872f765 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Fri, 19 Jun 2026 14:22:45 +1000 Subject: [PATCH 4/8] feat: add announcement_style with bar (default) and callout looks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the announcement banner's look selectable via a new announcement_style theme option, so the team can choose between two styles (and switch the default in one place): - bar (default): a thin, full-width strip with centred text that sits just below the toolbar and scrolls away — discreet, for standing notices. - callout: the original boxed in-column notice with an accent border — preserved as a style so nothing is lost. Both share the same markup, JS, and global dismissal; only the styling differs, and both adapt to dark mode and RTL. The value is validated at build start (unknown values warn and fall back to bar), mirroring color_scheme. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- docs/user/announcements.md | 21 +++ src/quantecon_book_theme/__init__.py | 26 +++- .../assets/styles/_announcement.scss | 145 +++++++++++++++--- .../theme/quantecon_book_theme/layout.html | 2 +- .../theme/quantecon_book_theme/theme.conf | 1 + tests/test_announcement.py | 40 +++++ 7 files changed, 207 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4916c..97fb119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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. 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). +- **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). ### CI - **CI Node bumped 20 → 24** — Node 20 reached end-of-life in April 2026, and the grouped npm updates in #400 raised engine floors (`sass-loader` 17 requires Node ≥22.11). Node 24 is the current active LTS (supported to April 2028). Applies to `ci.yml`, `docs.yml`, and `update-snapshots.yml`; `.nvmrc` and the contributor docs (`CONTRIBUTING.md`, `docs/developer/setup.md`) move to Node 24 in step so local dev matches CI. diff --git a/docs/user/announcements.md b/docs/user/announcements.md index 25044be..1133200 100644 --- a/docs/user/announcements.md +++ b/docs/user/announcements.md @@ -31,6 +31,27 @@ html_theme_options = { 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 diff --git a/src/quantecon_book_theme/__init__.py b/src/quantecon_book_theme/__init__.py index 1fe7630..8910523 100644 --- a/src/quantecon_book_theme/__init__.py +++ b/src/quantecon_book_theme/__init__.py @@ -683,11 +683,18 @@ 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_expires`` date once, at build start. + """Validate the announcement options once, at build start. - Fails open: an unparseable date is dropped (cleared) so a typo can never - silently hide an active announcement, and a warning is logged so the + 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 @@ -700,6 +707,19 @@ def validate_announcement(app): ) 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"] diff --git a/src/quantecon_book_theme/assets/styles/_announcement.scss b/src/quantecon_book_theme/assets/styles/_announcement.scss index 906ec20..a25556c 100644 --- a/src/quantecon_book_theme/assets/styles/_announcement.scss +++ b/src/quantecon_book_theme/assets/styles/_announcement.scss @@ -1,13 +1,20 @@ /* ----------------------------------- ANNOUNCEMENT BANNER -Dismissible notice bar at the top of the page content column. -Holds one or more stacked rows (n-aware for future per-page notices). +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; @@ -23,26 +30,11 @@ Holds one or more stacked rows (n-aware for future per-page notices). .qe-announcement { display: flex; - 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; + align-items: center; font-family: "Source Sans Pro", sans-serif; - font-size: 0.95rem; line-height: 1.4; - color: #1a3a4d; &__text { - flex: 1; - - a { - color: colors.$primary; - text-decoration: underline; - } - // Drop the bottom margin from a trailing paragraph if the message is rich. > :last-child { margin-bottom: 0; @@ -58,9 +50,34 @@ Holds one or more stacked rows (n-aware for future per-page notices). background: transparent; border: none; cursor: pointer; - color: #5a7184; 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; @@ -78,8 +95,7 @@ Holds one or more stacked rows (n-aware for future per-page notices). } } -/* Dark mode */ -body.dark-theme .qe-announcement { +body.dark-theme .qe-announcement-bar--callout .qe-announcement { background-color: #1e2a38; border-color: #2f4356; border-left-color: #5fa8e0; @@ -98,14 +114,93 @@ body.dark-theme .qe-announcement { } } -/* RTL: move the accent border and align the close button to the left */ -[dir="rtl"] .qe-announcement { +// 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, -[dir="rtl"] body.dark-theme .qe-announcement { +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; it sits just below + // the fixed toolbar and scrolls away with the page. + width: 100vw; + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); + margin-bottom: 1.5rem; + gap: 0; + + .qe-announcement { + position: relative; + justify-content: center; + padding: 0.4rem 2.75rem; // horizontal room so centred text clears the close + background-color: #f3f4f6; + border-bottom: 1px solid #e3e5ea; + font-size: 0.85rem; + 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/theme/quantecon_book_theme/layout.html b/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html index 2f3f884..6147e7a 100644 --- a/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html +++ b/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html @@ -186,7 +186,7 @@ {# Announcement banner — hidden until announcement.js decides which rows survive the dismissed/expired check (avoids a flash). #} {%- if announcements %} -