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("' + ":root{" + f"--background-base:{_esc(bg_hex)};" + f"--midground-base:{_esc(mg_hex)};" + f"--theme-font-sans:{_esc(font_sans)};" + f"--theme-base-size:{_esc(base_size)};" + "}" + "html,body{background-color:var(--background-base);" + "color:var(--midground-base);" + "font-family:var(--theme-font-sans);" + "font-size:var(--theme-base-size);}" + "" + ) + 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 '