diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1f99ea7..498bcdf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,13 @@ src/quantecon_book_theme/ - **Alternative**: Try `pip install --timeout=120` for longer timeout ### GitHub CLI (gh) Issues +- **Shell Escaping**: zsh has many issues with shell escaping, heredocs, and multiline strings. **ALWAYS** use the `create_file` tool to write content to a temporary file first, then pass it to `gh` with the `--body-file` flag. **NEVER** pass multiline body content directly via `--body` in the terminal. + - Example workflow for updating a PR description: + 1. Use `create_file` to write the body to `/tmp/pr_body.md` + 2. Run: `gh pr edit 123 --body-file /tmp/pr_body.md` + - Example workflow for creating a release: + 1. Use `create_file` to write release notes to `/tmp/release_notes.md` + 2. Run: `gh release create vX.Y.Z --title "Title" --notes-file /tmp/release_notes.md` - **Output Capture**: Always write gh output to `/tmp` file for reliable capture: `gh pr view 123 2>&1 | tee /tmp/gh_output.txt` ### Missing Python 3.13 @@ -240,12 +247,14 @@ git push && git push origin vX.Y.Z ### 5. Create GitHub Release ```bash +# Write release notes to a temp file first (use create_file tool) +# Then create the release using --notes-file gh release create vX.Y.Z \ --title "vX.Y.Z - Release Title" \ - --notes "Release notes from CHANGELOG.md" + --notes-file /tmp/release_notes.md ``` -**IMPORTANT**: Creating the GitHub release triggers the PyPI publish workflow automatically. +**IMPORTANT**: Always use `--notes-file` with a temp file instead of `--notes` to avoid zsh shell escaping issues. Creating the GitHub release triggers the PyPI publish workflow automatically. ### 6. Verify PyPI Publication - Check GitHub Actions for successful PyPI publish workflow diff --git a/docs/configure.md b/docs/configure.md index ba587d2..129cdd1 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -323,3 +323,60 @@ This is a collapsible note that will show "Show" when collapsed and "Hide" when ```{toggle} This is a toggle directive that will use the configured button text. ``` + +## Customizing Emphasis and Definition Colors + +The theme applies custom colors to emphasis (italic), bold/strong, and definition list terms. By default: + +- **Emphasis** (`em`): Green (`#2d9f42`) in light mode, lighter green (`#66bb6a`) in dark mode +- **Bold/Strong** (`strong`, `b`): Brown (`#8b4513`) in light mode, lighter brown (`#cd853f`) in dark mode +- **Definitions** (`dl dt`): Inherits from bold/strong color by default + +You can override these colors using `html_theme_options`: + +```python +html_theme_options = { + ... + "emphasis_color": "#1a73e8", + "emphasis_color_dark": "#8ab4f8", + "strong_color": "#d93025", + "strong_color_dark": "#f28b82", + "definition_color": "#6a1b9a", + "definition_color_dark": "#ce93d8", + ... +} +``` + +For Jupyter Book projects, add to your `_config.yml`: + +```yaml +sphinx: + config: + html_theme_options: + emphasis_color: "#1a73e8" + emphasis_color_dark: "#8ab4f8" + strong_color: "#d93025" + strong_color_dark: "#f28b82" + definition_color: "#6a1b9a" + definition_color_dark: "#ce93d8" +``` + +| Option | Description | Default | +|---|---|---| +| `emphasis_color` | Color for `em` tags in light mode | `#2d9f42` (green) | +| `emphasis_color_dark` | Color for `em` tags in dark mode | `#66bb6a` (light green) | +| `strong_color` | Color for `strong`/`b` tags in light mode | `#8b4513` (brown) | +| `strong_color_dark` | Color for `strong`/`b` tags in dark mode | `#cd853f` (peru) | +| `definition_color` | Color for definition list terms (`dl dt`) in light mode | Inherits from `strong_color` | +| `definition_color_dark` | Color for definition list terms (`dl dt`) in dark mode | Inherits from `strong_color_dark` | + +Any option left empty will use the theme's built-in default color. The `definition_color` +options target Sphinx definition lists, glossary terms, and field lists specifically, +while `strong_color` applies to all inline bold/strong text. + +```{note} +Color values are validated at build time against safe CSS color patterns +(hex codes, named colors, `rgb()`/`hsl()` functions). Invalid values are +ignored and a warning is logged. Only use trusted color values from your +own configuration files. +``` diff --git a/src/quantecon_book_theme/__init__.py b/src/quantecon_book_theme/__init__.py index f229fa5..eb2febf 100644 --- a/src/quantecon_book_theme/__init__.py +++ b/src/quantecon_book_theme/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path import os import hashlib +import re from functools import lru_cache import subprocess from datetime import datetime, timezone @@ -594,6 +595,48 @@ def _string_or_bool(var): return var is None +# Valid CSS color pattern: hex (#RGB, #RRGGBB, #RRGGBBAA), named colors, +# rgb/rgba/hsl/hsla functions, or CSS keywords +_CSS_COLOR_RE = re.compile( + r"^(" + r"#[0-9a-fA-F]{3,8}" + r"|[a-zA-Z]+" + r"|rgba?\([^)]+\)" + r"|hsla?\([^)]+\)" + r"|var\(--[a-zA-Z0-9-]+\)" + r")$" +) + +_COLOR_OPTIONS = [ + "emphasis_color", + "emphasis_color_dark", + "strong_color", + "strong_color_dark", + "definition_color", + "definition_color_dark", +] + + +def validate_color_options(app): + """Validate that color theme options contain safe CSS color values. + + Prevents CSS injection by ensuring color values match a known-safe pattern + (hex colors, named colors, rgb/hsl functions). Invalid values are replaced + with empty strings and a warning is logged. + """ + theme_options = app.config.html_theme_options + for option in _COLOR_OPTIONS: + value = theme_options.get(option, "") + if value and not _CSS_COLOR_RE.match(value.strip()): + SPHINX_LOGGER.warning( + "Ignoring invalid CSS color value for %s: %r. " + "Use hex (#RRGGBB), named colors, or rgb()/hsl() functions.", + option, + value, + ) + theme_options[option] = "" + + def setup(app): # Configuration for Juypter Book app.setup_extension("sphinx_book_theme") @@ -603,6 +646,7 @@ def setup(app): app.connect("html-page-context", add_hub_urls) app.connect("builder-inited", add_plugins_list) + app.connect("builder-inited", validate_color_options) app.connect("builder-inited", setup_pygments_css) app.connect("html-page-context", hash_html_assets) app.connect("html-page-context", add_pygments_style_class) diff --git a/src/quantecon_book_theme/assets/styles/_base.scss b/src/quantecon_book_theme/assets/styles/_base.scss index 1c28a45..24ba341 100644 --- a/src/quantecon_book_theme/assets/styles/_base.scss +++ b/src/quantecon_book_theme/assets/styles/_base.scss @@ -151,14 +151,21 @@ h4 { em { font-style: normal; font-weight: 550; - color: colors.$emphasis; + color: var(--qe-emphasis-color, colors.$emphasis); } -// Strong/bold styling - semi-bold weight with color for definitions +// Strong/bold styling - semi-bold weight with color strong, b { font-weight: 550; - color: colors.$definition; + color: var(--qe-strong-color, colors.$definition); +} + +// Definition list term styling - inherits from strong color by default +dl.simple dt, +dl.glossary dt, +dl.field-list dt { + color: var(--qe-definition-color, var(--qe-strong-color, colors.$definition)); } li { diff --git a/src/quantecon_book_theme/assets/styles/_dark-theme.scss b/src/quantecon_book_theme/assets/styles/_dark-theme.scss index 614ab48..5ff3941 100644 --- a/src/quantecon_book_theme/assets/styles/_dark-theme.scss +++ b/src/quantecon_book_theme/assets/styles/_dark-theme.scss @@ -23,12 +23,18 @@ body.dark-theme { // Lighter colors for emphasis and strong in dark mode em { - color: #66bb6a; // Lighter green for dark mode + color: var(--qe-emphasis-color, #66bb6a); // Lighter green for dark mode } strong, b { - color: #cd853f; // Lighter brown for dark mode + color: var(--qe-strong-color, #cd853f); // Lighter brown for dark mode + } + + dl.simple dt, + dl.glossary dt, + dl.field-list dt { + color: var(--qe-definition-color, var(--qe-strong-color, #cd853f)); } .qe-toolbar__inner > ul > li > a, 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 9ec83ae..b04e1ba 100644 --- a/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html +++ b/src/quantecon_book_theme/theme/quantecon_book_theme/layout.html @@ -119,6 +119,36 @@ } } +{%- endif %} + + +{%- if theme_emphasis_color or theme_strong_color or theme_definition_color %} + {%- endif %} 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 7f8bf73..10a595b 100644 --- a/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf +++ b/src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf @@ -36,3 +36,10 @@ twitter = twitter_logo_url = use_issues_button = False use_repository_button = False + +emphasis_color = +emphasis_color_dark = +strong_color = +strong_color_dark = +definition_color = +definition_color_dark = diff --git a/tests/test_custom_colors.py b/tests/test_custom_colors.py new file mode 100644 index 0000000..8d36449 --- /dev/null +++ b/tests/test_custom_colors.py @@ -0,0 +1,379 @@ +""" +Tests for customizable emphasis, strong/bold, and definition text colors. + +Verifies that: +- Theme options are registered in theme.conf +- CSS custom properties are used in SCSS source files +- CSS custom properties appear in compiled CSS output +- Layout template injects custom color styles when options are set +- Definition color targets dl dt elements and falls back to strong color +- Color values are validated against safe CSS patterns +""" + +from pathlib import Path +from unittest.mock import MagicMock + +from quantecon_book_theme import _CSS_COLOR_RE, validate_color_options + + +# Paths +THEME_DIR = Path("src/quantecon_book_theme/theme/quantecon_book_theme") +ASSETS_DIR = Path("src/quantecon_book_theme/assets") + + +class TestCustomColorThemeOptions: + """Test that color customization options are registered in theme.conf.""" + + def test_emphasis_color_option_exists(self): + """theme.conf should define emphasis_color option.""" + content = (THEME_DIR / "theme.conf").read_text() + assert "emphasis_color =" in content + + def test_emphasis_color_dark_option_exists(self): + """theme.conf should define emphasis_color_dark option.""" + content = (THEME_DIR / "theme.conf").read_text() + assert "emphasis_color_dark =" in content + + def test_strong_color_option_exists(self): + """theme.conf should define strong_color option.""" + content = (THEME_DIR / "theme.conf").read_text() + assert "strong_color =" in content + + def test_strong_color_dark_option_exists(self): + """theme.conf should define strong_color_dark option.""" + content = (THEME_DIR / "theme.conf").read_text() + assert "strong_color_dark =" in content + + def test_definition_color_option_exists(self): + """theme.conf should define definition_color option.""" + content = (THEME_DIR / "theme.conf").read_text() + assert "definition_color =" in content + + def test_definition_color_dark_option_exists(self): + """theme.conf should define definition_color_dark option.""" + content = (THEME_DIR / "theme.conf").read_text() + assert "definition_color_dark =" in content + + def test_options_default_to_empty(self): + """All color options should default to empty (use CSS fallback).""" + content = (THEME_DIR / "theme.conf").read_text() + for option in [ + "emphasis_color", + "emphasis_color_dark", + "strong_color", + "strong_color_dark", + "definition_color", + "definition_color_dark", + ]: + # Each option should appear as "option_name =" with no value + # Use a targeted check to avoid matching substrings + lines = content.splitlines() + matching = [ + line for line in lines if line.strip().startswith(f"{option} =") + ] + assert len(matching) == 1, f"Expected exactly one '{option}' option" + assert ( + matching[0].strip() == f"{option} =" + ), f"'{option}' should default to empty string" + + +class TestCustomColorCSSVariables: + """Test that SCSS uses CSS custom properties for emphasis/strong.""" + + def test_base_scss_uses_emphasis_variable(self): + """_base.scss should use --qe-emphasis-color CSS variable for em.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert "var(--qe-emphasis-color" in content + + def test_base_scss_uses_strong_variable(self): + """_base.scss should use --qe-strong-color CSS variable for strong.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert "var(--qe-strong-color" in content + + def test_base_scss_has_emphasis_fallback(self): + """_base.scss should have SCSS fallback value for emphasis color.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert "var(--qe-emphasis-color, colors.$emphasis)" in content + + def test_base_scss_has_strong_fallback(self): + """_base.scss should have SCSS fallback value for strong color.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert "var(--qe-strong-color, colors.$definition)" in content + + def test_dark_theme_uses_emphasis_variable(self): + """_dark-theme.scss should use --qe-emphasis-color CSS variable.""" + content = (ASSETS_DIR / "styles" / "_dark-theme.scss").read_text() + assert "var(--qe-emphasis-color" in content + + def test_dark_theme_uses_strong_variable(self): + """_dark-theme.scss should use --qe-strong-color CSS variable.""" + content = (ASSETS_DIR / "styles" / "_dark-theme.scss").read_text() + assert "var(--qe-strong-color" in content + + def test_dark_theme_has_emphasis_fallback(self): + """_dark-theme.scss should have fallback for emphasis in dark mode.""" + content = (ASSETS_DIR / "styles" / "_dark-theme.scss").read_text() + assert "var(--qe-emphasis-color, #66bb6a)" in content + + def test_dark_theme_has_strong_fallback(self): + """_dark-theme.scss should have fallback for strong in dark mode.""" + content = (ASSETS_DIR / "styles" / "_dark-theme.scss").read_text() + assert "var(--qe-strong-color, #cd853f)" in content + + def test_base_scss_uses_definition_variable(self): + """_base.scss should use --qe-definition-color for dl dt elements.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert "var(--qe-definition-color" in content + + def test_base_scss_definition_falls_back_to_strong(self): + """_base.scss definition color should fall back to strong color.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert ( + "var(--qe-definition-color, var(--qe-strong-color, colors.$definition))" + in content + ) + + def test_base_scss_targets_definition_list_terms(self): + """_base.scss should target dl.simple dt, dl.glossary dt.""" + content = (ASSETS_DIR / "styles" / "_base.scss").read_text() + assert "dl.simple dt" in content + assert "dl.glossary dt" in content + + def test_dark_theme_uses_definition_variable(self): + """_dark-theme.scss should use --qe-definition-color.""" + content = (ASSETS_DIR / "styles" / "_dark-theme.scss").read_text() + assert "var(--qe-definition-color" in content + + def test_dark_theme_definition_falls_back_to_strong(self): + """_dark-theme.scss definition color should fall back to strong.""" + content = (ASSETS_DIR / "styles" / "_dark-theme.scss").read_text() + assert "var(--qe-definition-color, var(--qe-strong-color, #cd853f))" in content + + +class TestCustomColorCompiledCSS: + """Test that compiled CSS contains custom properties.""" + + def test_compiled_css_has_emphasis_variable(self): + """Compiled CSS should contain --qe-emphasis-color variable.""" + css_path = THEME_DIR / "static" / "styles" / "quantecon-book-theme.css" + content = css_path.read_text() + assert "--qe-emphasis-color" in content + + def test_compiled_css_has_strong_variable(self): + """Compiled CSS should contain --qe-strong-color variable.""" + css_path = THEME_DIR / "static" / "styles" / "quantecon-book-theme.css" + content = css_path.read_text() + assert "--qe-strong-color" in content + + def test_compiled_css_em_uses_variable(self): + """Compiled CSS em rule should use var(--qe-emphasis-color).""" + css_path = THEME_DIR / "static" / "styles" / "quantecon-book-theme.css" + content = css_path.read_text() + assert "var(--qe-emphasis-color" in content + + def test_compiled_css_strong_uses_variable(self): + """Compiled CSS strong rule should use var(--qe-strong-color).""" + css_path = THEME_DIR / "static" / "styles" / "quantecon-book-theme.css" + content = css_path.read_text() + assert "var(--qe-strong-color" in content + + def test_compiled_css_has_definition_variable(self): + """Compiled CSS should contain --qe-definition-color variable.""" + css_path = THEME_DIR / "static" / "styles" / "quantecon-book-theme.css" + content = css_path.read_text() + assert "--qe-definition-color" in content + + def test_compiled_css_definition_uses_variable(self): + """Compiled CSS dl dt rule should use var(--qe-definition-color).""" + css_path = THEME_DIR / "static" / "styles" / "quantecon-book-theme.css" + content = css_path.read_text() + assert "var(--qe-definition-color" in content + + +class TestCustomColorLayoutTemplate: + """Test that layout.html injects custom color styles.""" + + def _read_layout(self): + return (THEME_DIR / "layout.html").read_text() + + def test_template_checks_emphasis_color(self): + """Layout template should conditionally check theme_emphasis_color.""" + content = self._read_layout() + assert "theme_emphasis_color" in content + + def test_template_checks_strong_color(self): + """Layout template should conditionally check theme_strong_color.""" + content = self._read_layout() + assert "theme_strong_color" in content + + def test_template_checks_emphasis_color_dark(self): + """Layout template should conditionally check theme_emphasis_color_dark.""" + content = self._read_layout() + assert "theme_emphasis_color_dark" in content + + def test_template_checks_strong_color_dark(self): + """Layout template should conditionally check theme_strong_color_dark.""" + content = self._read_layout() + assert "theme_strong_color_dark" in content + + def test_template_sets_emphasis_css_variable(self): + """Layout template should set --qe-emphasis-color CSS variable.""" + content = self._read_layout() + assert "--qe-emphasis-color: {{ theme_emphasis_color }}" in content + + def test_template_sets_strong_css_variable(self): + """Layout template should set --qe-strong-color CSS variable.""" + content = self._read_layout() + assert "--qe-strong-color: {{ theme_strong_color }}" in content + + def test_template_sets_dark_emphasis_css_variable(self): + """Layout template should set --qe-emphasis-color for dark mode.""" + content = self._read_layout() + assert "--qe-emphasis-color: {{ theme_emphasis_color_dark }}" in content + + def test_template_sets_dark_strong_css_variable(self): + """Layout template should set --qe-strong-color for dark mode.""" + content = self._read_layout() + assert "--qe-strong-color: {{ theme_strong_color_dark }}" in content + + def test_template_dark_mode_uses_body_dark_theme(self): + """Layout template should target body.dark-theme for dark colors.""" + content = self._read_layout() + assert "body.dark-theme" in content + + def test_template_light_mode_uses_root(self): + """Layout template should target :root for light mode colors.""" + content = self._read_layout() + assert ":root" in content + + def test_template_checks_definition_color(self): + """Layout template should conditionally check theme_definition_color.""" + content = self._read_layout() + assert "theme_definition_color" in content + + def test_template_checks_definition_color_dark(self): + """Layout template should check theme_definition_color_dark.""" + content = self._read_layout() + assert "theme_definition_color_dark" in content + + def test_template_sets_definition_css_variable(self): + """Layout template should set --qe-definition-color CSS variable.""" + content = self._read_layout() + assert "--qe-definition-color: {{ theme_definition_color }}" in content + + def test_template_sets_dark_definition_css_variable(self): + """Layout template should set --qe-definition-color for dark mode.""" + content = self._read_layout() + assert "--qe-definition-color: {{ theme_definition_color_dark }}" in content + + +class TestColorValueValidation: + """Test that CSS color values are validated against safe patterns.""" + + def test_hex_3_digit(self): + """#RGB hex colors should be accepted.""" + assert _CSS_COLOR_RE.match("#f00") + + def test_hex_6_digit(self): + """#RRGGBB hex colors should be accepted.""" + assert _CSS_COLOR_RE.match("#ff0000") + + def test_hex_8_digit(self): + """#RRGGBBAA hex colors should be accepted.""" + assert _CSS_COLOR_RE.match("#ff000080") + + def test_named_color(self): + """Named CSS colors should be accepted.""" + assert _CSS_COLOR_RE.match("red") + + def test_named_color_camelcase(self): + """CamelCase named CSS colors should be accepted.""" + assert _CSS_COLOR_RE.match("DarkSlateGray") + + def test_rgb_function(self): + """rgb() function should be accepted.""" + assert _CSS_COLOR_RE.match("rgb(255, 0, 0)") + + def test_rgba_function(self): + """rgba() function should be accepted.""" + assert _CSS_COLOR_RE.match("rgba(255, 0, 0, 0.5)") + + def test_hsl_function(self): + """hsl() function should be accepted.""" + assert _CSS_COLOR_RE.match("hsl(0, 100%, 50%)") + + def test_hsla_function(self): + """hsla() function should be accepted.""" + assert _CSS_COLOR_RE.match("hsla(0, 100%, 50%, 0.5)") + + def test_rejects_css_injection(self): + """CSS injection attempts should be rejected.""" + assert not _CSS_COLOR_RE.match("red; } body { display: none; } /*") + + def test_rejects_semicolon(self): + """Values with semicolons should be rejected.""" + assert not _CSS_COLOR_RE.match("#ff0000; color: red") + + def test_rejects_braces(self): + """Values with braces should be rejected.""" + assert not _CSS_COLOR_RE.match("red } .evil { color: blue") + + def test_rejects_url(self): + """url() values should be rejected.""" + assert not _CSS_COLOR_RE.match("url(evil.com)") + + def test_rejects_expression(self): + """expression() values should be rejected.""" + assert not _CSS_COLOR_RE.match("expression(alert(1))") + + +class TestValidateColorOptionsFunction: + """Test the validate_color_options function with a mock Sphinx app.""" + + def _make_app(self, **theme_options): + """Create a mock Sphinx app with given theme options.""" + app = MagicMock() + app.config.html_theme_options = dict(theme_options) + return app + + def test_valid_hex_color_preserved(self): + """Valid hex color values should be preserved.""" + app = self._make_app(emphasis_color="#ff0000") + validate_color_options(app) + assert app.config.html_theme_options["emphasis_color"] == "#ff0000" + + def test_valid_named_color_preserved(self): + """Valid named color values should be preserved.""" + app = self._make_app(strong_color="red") + validate_color_options(app) + assert app.config.html_theme_options["strong_color"] == "red" + + def test_invalid_value_cleared(self): + """Invalid color values should be replaced with empty string.""" + app = self._make_app(emphasis_color="red; } body { display: none; } /*") + validate_color_options(app) + assert app.config.html_theme_options["emphasis_color"] == "" + + def test_empty_value_unchanged(self): + """Empty values should remain empty (no error).""" + app = self._make_app(emphasis_color="") + validate_color_options(app) + assert app.config.html_theme_options["emphasis_color"] == "" + + def test_missing_option_no_error(self): + """Missing options should not cause errors.""" + app = self._make_app() + validate_color_options(app) + # Should complete without error + + def test_multiple_options_validated(self): + """All color options should be validated independently.""" + app = self._make_app( + emphasis_color="#00ff00", + strong_color="invalid; injection", + definition_color="blue", + ) + validate_color_options(app) + assert app.config.html_theme_options["emphasis_color"] == "#00ff00" + assert app.config.html_theme_options["strong_color"] == "" + assert app.config.html_theme_options["definition_color"] == "blue" diff --git a/tests/visual/__snapshots__/desktop-chrome/bold-text-dark.png b/tests/visual/__snapshots__/desktop-chrome/bold-text-dark.png new file mode 100644 index 0000000..1232675 Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome/bold-text-dark.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/bold-text.png b/tests/visual/__snapshots__/desktop-chrome/bold-text.png new file mode 100644 index 0000000..0e83011 Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome/bold-text.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/italic-text-dark.png b/tests/visual/__snapshots__/desktop-chrome/italic-text-dark.png new file mode 100644 index 0000000..b8c3712 Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome/italic-text-dark.png differ diff --git a/tests/visual/__snapshots__/desktop-chrome/italic-text.png b/tests/visual/__snapshots__/desktop-chrome/italic-text.png new file mode 100644 index 0000000..321ebf6 Binary files /dev/null and b/tests/visual/__snapshots__/desktop-chrome/italic-text.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/bold-text-dark.png b/tests/visual/__snapshots__/mobile-chrome/bold-text-dark.png new file mode 100644 index 0000000..fb07589 Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome/bold-text-dark.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/bold-text.png b/tests/visual/__snapshots__/mobile-chrome/bold-text.png new file mode 100644 index 0000000..2fe6462 Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome/bold-text.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/italic-text-dark.png b/tests/visual/__snapshots__/mobile-chrome/italic-text-dark.png new file mode 100644 index 0000000..5f624b8 Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome/italic-text-dark.png differ diff --git a/tests/visual/__snapshots__/mobile-chrome/italic-text.png b/tests/visual/__snapshots__/mobile-chrome/italic-text.png new file mode 100644 index 0000000..34265ca Binary files /dev/null and b/tests/visual/__snapshots__/mobile-chrome/italic-text.png differ diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts index 243b0a7..8bcc71c 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -137,3 +137,55 @@ test.describe("Theme Features", () => { await expect(toolbar).toHaveScreenshot("toolbar.png"); }); }); + +test.describe("Typography Styling", () => { + test("bold text styling", async ({ page }) => { + // names.html has rich bold content (7 elements) + await page.goto("/names.html"); + await page.waitForLoadState("networkidle"); + + // Capture a paragraph containing bold text for context + const paragraph = page.locator(".qe-page__content p:has(strong)").first(); + await expect(paragraph).toHaveScreenshot("bold-text.png"); + }); + + test("italic text styling", async ({ page }) => { + // numpy.html has rich italic content (15 elements) + await page.goto("/numpy.html"); + await page.waitForLoadState("networkidle"); + + const paragraph = page.locator(".qe-page__content p:has(em)").first(); + await expect(paragraph).toHaveScreenshot("italic-text.png"); + }); + + test("bold text in dark mode", async ({ page }) => { + await page.goto("/names.html"); + await page.waitForLoadState("networkidle"); + + // Toggle dark mode + const contrastBtn = page.locator(".btn__contrast"); + await contrastBtn.click(); + await page.waitForTimeout(300); + + const paragraph = page.locator(".qe-page__content p:has(strong)").first(); + await expect(paragraph).toHaveScreenshot("bold-text-dark.png"); + }); + + test("italic text in dark mode", async ({ page }) => { + await page.goto("/numpy.html"); + await page.waitForLoadState("networkidle"); + + // Toggle dark mode + const contrastBtn = page.locator(".btn__contrast"); + await contrastBtn.click(); + await page.waitForTimeout(300); + + const paragraph = page.locator(".qe-page__content p:has(em)").first(); + await expect(paragraph).toHaveScreenshot("italic-text-dark.png"); + }); + + // Note: Definition list (
) visual tests are not included because the + // lecture-python-programming.myst site does not currently contain definition + // lists, glossaries, or field-lists. When a page with
content is added, + // a corresponding visual test should be created here. +});