diff --git a/static/index.html b/static/index.html
index 29c39fe..9f02734 100644
--- a/static/index.html
+++ b/static/index.html
@@ -5,9 +5,24 @@
Claude Code Chat Browser
-
-
-
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js
index 262e80e..c3edcd8 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,5 +1,33 @@
// Claude Code Chat Browser — Main JS
+// Highlight.js theme stylesheets, keyed by theme name. Both `href` and
+// `integrity` MUST be assigned together when swapping at runtime —
+// changing `href` while leaving a stale `integrity` would make the
+// browser refuse the new stylesheet and break the UI (issue #19).
+// Hashes verified against cdnjs's SRI API. The corresponding static
+// tag in static/index.html carries crossorigin="anonymous" which
+// persists across runtime href swaps.
+const HLJS_THEME_SHEETS = {
+ dark: {
+ href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css',
+ integrity: 'sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew==',
+ },
+ light: {
+ href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css',
+ integrity: 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==',
+ },
+};
+
+function applyHljsTheme(themeName) {
+ const link = document.getElementById('hljs-theme');
+ if (!link) return;
+ const sheet = HLJS_THEME_SHEETS[themeName] || HLJS_THEME_SHEETS.dark;
+ // Set integrity FIRST, then href — the browser reads the current
+ // integrity at fetch time, and href change is what triggers the fetch.
+ link.integrity = sheet.integrity;
+ link.href = sheet.href;
+}
+
function showToast(message, type = 'info') {
const icons = { success: '\u2713', error: '\u2717', info: '\u2139' };
const toast = document.createElement('div');
@@ -122,14 +150,8 @@ function setHamburgerVisible(visible) {
function setWorkspaceMode(active) {
// No container class change needed — workspace lives inside the standard container
document.body.classList.toggle('workspace-mode', active);
- // Switch highlight.js theme
- const hljsLink = document.getElementById('hljs-theme');
- if (hljsLink) {
- const theme = localStorage.getItem('theme') || 'dark';
- hljsLink.href = theme === 'dark'
- ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css'
- : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
- }
+ // Switch highlight.js theme — helper updates href + integrity together (issue #19).
+ applyHljsTheme(localStorage.getItem('theme') || 'dark');
}
let _navInProgress = false;
@@ -836,12 +858,7 @@ function applyTheme(theme) {
moon.style.display = theme === 'dark' ? 'block' : 'none';
sun.style.display = theme === 'light' ? 'block' : 'none';
}
- const hljsLink = document.getElementById('hljs-theme');
- if (hljsLink) {
- hljsLink.href = theme === 'dark'
- ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css'
- : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
- }
+ applyHljsTheme(theme); // href + integrity swapped together (issue #19)
}
function toggleTheme() {
diff --git a/tests/test_hljs_theme_consistency.py b/tests/test_hljs_theme_consistency.py
new file mode 100644
index 0000000..6f55b70
--- /dev/null
+++ b/tests/test_hljs_theme_consistency.py
@@ -0,0 +1,65 @@
+"""Regression test for highlight.js theme URL+hash drift between
+static/index.html (initial load) and static/js/app.js (runtime swap).
+
+Both files carry the dark-theme stylesheet URL and its SRI hash. On a
+highlight.js version bump both must update together — if they drift, either
+the initial load breaks (HTML stale, mismatched hash) or the runtime theme
+swap breaks (JS stale). This test fails fast when they diverge so the
+"MUST also swap the integrity attribute" comments in both files become a
+checked invariant rather than a hope.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+INDEX_HTML = REPO_ROOT / "static" / "index.html"
+APP_JS = REPO_ROOT / "static" / "js" / "app.js"
+
+
+def _link_attr(html: str, link_id: str, attr: str) -> str:
+ """Return the value of `attr` on the tag with id=`link_id`."""
+ tag_re = re.compile(
+ r']*\bid\s*=\s*"' + re.escape(link_id) + r'"[^>]*>',
+ re.DOTALL,
+ )
+ m = tag_re.search(html)
+ assert m, f'No found in index.html'
+ attr_m = re.search(re.escape(attr) + r'\s*=\s*"([^"]*)"', m.group(0))
+ assert attr_m, f' has no {attr!r} attribute'
+ return attr_m.group(1)
+
+
+def _js_theme_entry(js: str, theme: str) -> dict:
+ """Return {'href': ..., 'integrity': ...} from HLJS_THEME_SHEETS.."""
+ block = re.search(re.escape(theme) + r"\s*:\s*\{([^}]*)\}", js, re.DOTALL)
+ assert block, f"HLJS_THEME_SHEETS.{theme} entry not found in app.js"
+ body = block.group(1)
+ out = {}
+ for key in ("href", "integrity"):
+ m = re.search(key + r"\s*:\s*['\"]([^'\"]+)['\"]", body)
+ assert m, f"HLJS_THEME_SHEETS.{theme} has no {key!r} key"
+ out[key] = m.group(1)
+ return out
+
+
+def test_dark_theme_url_and_hash_match_between_html_and_js():
+ html = INDEX_HTML.read_text(encoding="utf-8")
+ js = APP_JS.read_text(encoding="utf-8")
+
+ html_href = _link_attr(html, "hljs-theme", "href")
+ html_integrity = _link_attr(html, "hljs-theme", "integrity")
+ js_dark = _js_theme_entry(js, "dark")
+
+ assert html_href == js_dark["href"], (
+ "highlight.js theme URL drifted between index.html and app.js — "
+ f"html={html_href!r}, app.js HLJS_THEME_SHEETS.dark={js_dark['href']!r}. "
+ "On a version bump both must update together (issue #19)."
+ )
+ assert html_integrity == js_dark["integrity"], (
+ "highlight.js theme SRI hash drifted between index.html and app.js — "
+ f"html={html_integrity!r}, "
+ f"app.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}."
+ )