diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 38487c14ee2c..95a579d6eb42 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -16229,6 +16229,77 @@ def _normalise_prefix(raw: Optional[str]) -> str:
return normalise_prefix(raw)
+def _render_active_theme_bootstrap_css() -> str:
+ """Critical-CSS shim for the active user theme.
+
+ Returns a ```` escape — current values are well-known
+ # hex/font strings, but this keeps the helper safe if it is
+ # later extended to ship user-authored CSS literals.
+ def _esc(s: str) -> str:
+ return str(s).replace("", "<\\/")
+ # Variable names MUST match what the bundle actually consumes:
+ # - ``--background-base`` / ``--midground-base`` come from
+ # ``layerVars()`` in ``web/src/themes/context.tsx``.
+ # - ``--theme-font-sans`` / ``--theme-base-size`` come from
+ # ``typographyVars()`` there, and ``index.css`` applies them
+ # via ``html{font-family:var(--theme-font-sans);
+ # font-size:var(--theme-base-size)}``.
+ # The ``html,body`` canvas rule references the SAME variables
+ # instead of literal values so runtime theme switches stay
+ # live: ``applyTheme()`` writes these vars as inline styles on
+ # ``documentElement``, which outrank this stylesheet block in
+ # the cascade — the rule below re-resolves automatically and
+ # never goes stale when the user picks a different theme.
+ return (
+ '"
+ )
+ return ""
+ except Exception:
+ _log.debug("theme bootstrap render failed", exc_info=True)
+ return ""
+
+
def mount_spa(application: FastAPI):
"""Mount the built SPA. Falls back to index.html for client-side routing.
@@ -16303,6 +16374,16 @@ def _serve_index(prefix: str = ""):
html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
+ # Theme flash mitigation: when the active theme is a user theme
+ # (``HERMES_HOME/dashboard-themes/.yaml``), inject a minimal
+ # critical-CSS block so the first paint uses the target palette.
+ # Without this the SPA paints the default Hermes Teal canvas, then
+ # ``ThemeProvider`` flips the CSS variables once
+ # ``/api/dashboard/themes`` resolves. Built-in themes are already
+ # in the bundle's ``presets.ts`` so no shim is needed for them.
+ theme_bootstrap = _render_active_theme_bootstrap_css()
+ if theme_bootstrap:
+ html = html.replace("", f"{theme_bootstrap}", 1)
html = html.replace("", f"{bootstrap_script}", 1)
return HTMLResponse(
html,
diff --git a/scripts/release.py b/scripts/release.py
index a558f3a2a8b0..4d1fcb2dba2a 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -198,6 +198,7 @@
"39369769+jasonQin6@users.noreply.github.com": "jasonQin6", # PR #15093 salvage (session staleness guard on stream consumer run() loop; #11016 follow-up)
"znding04@gmail.com": "znding04", # PR #15487 salvage (distinguish OpenRouter upstream 429 from account 429; upstream_rate_limit failover reason)
"zkowkmdx@sharklasers.com": "nnnet", # PR #25142 salvage (stop STT-failure chatter poisoning the LLM prompt; drop hardcoded English notice)
+ "21066097+nnnet@users.noreply.github.com": "nnnet", # PR #36024 salvage (dashboard: inline critical-CSS bootstrap for user themes)
"vladimsmirnoff33@gmail.com": "londo161", # PR #15795 salvage (redact status --all API keys; tolerate dict/str compression message shape)
"neo.assistant2026@gmail.com": "neo-2026", # PR #14026 salvage (clear input-blocking overlays on interrupt so the CLI doesn't freeze; #13618)
"cypher@augmentl.com": "Nickperillo", # PR #8008 salvage (Discord channel-name matching + flush pending sends on shutdown)
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 5cbdeffe4612..691d3796a25c 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -6130,6 +6130,189 @@ def test_malformed_yaml_skipped(self, tmp_path, monkeypatch):
assert len(results) == 1 # only the valid one
+class TestThemeBootstrapCSS:
+ """Tests for _render_active_theme_bootstrap_css() and its injection
+ into index.html via _serve_index() — the critical-CSS shim that kills
+ the default-teal first-paint flash for user YAML themes."""
+
+ @staticmethod
+ def _write_theme(hermes_home, name="ocean"):
+ themes_dir = hermes_home / "dashboard-themes"
+ themes_dir.mkdir(exist_ok=True)
+ (themes_dir / f"{name}.yaml").write_text(
+ f"name: {name}\n"
+ "label: Ocean\n"
+ "palette:\n"
+ " background:\n"
+ " hex: \"#0a1628\"\n"
+ " midground:\n"
+ " hex: \"#dbe4f0\"\n"
+ "typography:\n"
+ " fontSans: \"Inter, sans-serif\"\n"
+ " baseSize: \"17px\"\n",
+ encoding="utf-8",
+ )
+
+ def test_user_theme_renders_bundle_vars(self, tmp_path, monkeypatch):
+ """Active user theme → style block with ONLY variable names the
+ bundle actually consumes (layerVars/typographyVars tokens)."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ self._write_theme(tmp_path)
+ from hermes_cli import web_server
+ monkeypatch.setattr(
+ web_server, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
+ )
+ css = web_server._render_active_theme_bootstrap_css()
+ assert css.startswith('")
+ # Real bundle tokens (web/src/themes/context.tsx + index.css).
+ assert "--background-base:#0a1628;" in css
+ assert "--midground-base:#dbe4f0;" in css
+ assert "--theme-font-sans:Inter, sans-serif;" in css
+ assert "--theme-base-size:17px;" in css
+ # Names that do NOT exist in the bundle must not be emitted.
+ for bogus in ("--color-background", "--color-midground",
+ "--font-sans:", "--font-base-size"):
+ assert bogus not in css
+ # Canvas rule flows through the variables (never goes stale when
+ # applyTheme() rewrites them as inline styles at runtime).
+ assert "html,body{background-color:var(--background-base);" in css
+ assert "font-family:var(--theme-font-sans);" in css
+ assert "font-size:var(--theme-base-size);" in css
+ # No baked literal values in the html,body rule.
+ assert "#0a1628" not in css.split("html,body")[1]
+
+ def test_builtin_theme_renders_nothing(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ from hermes_cli import web_server
+ for builtin in ("default", "midnight", "cyberpunk"):
+ monkeypatch.setattr(
+ web_server, "load_config",
+ lambda b=builtin: {"dashboard": {"theme": b}},
+ )
+ assert web_server._render_active_theme_bootstrap_css() == ""
+
+ def test_unknown_theme_renders_nothing(self, tmp_path, monkeypatch):
+ """Configured theme has no YAML on disk → empty string, no crash."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ from hermes_cli import web_server
+ monkeypatch.setattr(
+ web_server, "load_config", lambda: {"dashboard": {"theme": "ghost"}}
+ )
+ assert web_server._render_active_theme_bootstrap_css() == ""
+
+ def test_non_string_theme_renders_nothing(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ from hermes_cli import web_server
+ monkeypatch.setattr(
+ web_server, "load_config", lambda: {"dashboard": {"theme": 42}}
+ )
+ assert web_server._render_active_theme_bootstrap_css() == ""
+
+ def test_malformed_theme_yaml_no_crash(self, tmp_path, monkeypatch):
+ """A garbage YAML for the active theme name must not crash — the
+ discover helper skips it, so no style block is emitted."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ themes_dir = tmp_path / "dashboard-themes"
+ themes_dir.mkdir()
+ (themes_dir / "broken.yaml").write_text(
+ "::: not valid yaml :::\n\tindent wrong", encoding="utf-8"
+ )
+ from hermes_cli import web_server
+ monkeypatch.setattr(
+ web_server, "load_config", lambda: {"dashboard": {"theme": "broken"}}
+ )
+ assert web_server._render_active_theme_bootstrap_css() == ""
+
+ def test_load_config_exception_no_crash(self, monkeypatch):
+ from hermes_cli import web_server
+
+ def boom():
+ raise RuntimeError("config unreadable")
+
+ monkeypatch.setattr(web_server, "load_config", boom)
+ assert web_server._render_active_theme_bootstrap_css() == ""
+
+ def test_style_escape_defends_style_breakout(self, tmp_path, monkeypatch):
+ """`` in a theme value cannot break out of the block."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ themes_dir = tmp_path / "dashboard-themes"
+ themes_dir.mkdir()
+ (themes_dir / "sneaky.yaml").write_text(
+ "name: sneaky\n"
+ "typography:\n"
+ " fontSans: ''\n",
+ encoding="utf-8",
+ )
+ from hermes_cli import web_server
+ monkeypatch.setattr(
+ web_server, "load_config", lambda: {"dashboard": {"theme": "sneaky"}}
+ )
+ css = web_server._render_active_theme_bootstrap_css()
+ assert css.count("") == 1 # only the legitimate closer
+ assert "<\\/style>" in css # payload was escaped, not emitted raw
+
+ @staticmethod
+ def _mount_spa_client(tmp_path, monkeypatch):
+ from fastapi import FastAPI
+ from starlette.testclient import TestClient
+ import hermes_cli.web_server as ws
+
+ dist = tmp_path / "web_dist"
+ (dist / "assets").mkdir(parents=True)
+ (dist / "index.html").write_text(
+ "tSPA",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(ws, "WEB_DIST", dist)
+ spa_app = FastAPI()
+ ws.mount_spa(spa_app)
+ return TestClient(spa_app)
+
+ def test_serve_index_injects_bootstrap_for_user_theme(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ self._write_theme(tmp_path)
+ import hermes_cli.web_server as ws
+ monkeypatch.setattr(
+ ws, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
+ )
+ client = self._mount_spa_client(tmp_path, monkeypatch)
+ resp = client.get("/chat")
+ assert resp.status_code == 200
+ assert '