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.
+});