Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<style>`` block with the ``:root`` CSS variables that
``ThemeProvider.applyTheme()`` installs once the
``/api/dashboard/themes`` round-trip completes. The goal is to
eliminate the green flash where the first paint shows the bundle's
default Hermes Teal canvas before the SPA flips the configured user
theme into place.

Built-in themes return an empty string — their full definitions live
in ``web/src/themes/presets.ts`` and are applied by the bundle
before paint, so no shim is needed for them.
"""
try:
config = load_config()
active = cfg_get(config, "dashboard", "theme", default="default")
if not active or not isinstance(active, str):
return ""
# Built-in: the bundle already owns the definition, no flash.
if any(b["name"] == active for b in _BUILTIN_DASHBOARD_THEMES):
return ""
for theme in _discover_user_themes():
if theme.get("name") != active:
continue
palette = theme.get("palette") or {}
bg = palette.get("background") or {}
mg = palette.get("midground") or {}
bg_hex = bg.get("hex", "#0a0a0a") if isinstance(bg, dict) else "#0a0a0a"
mg_hex = mg.get("hex", "#e5e5e5") if isinstance(mg, dict) else "#e5e5e5"
typo = theme.get("typography") or {}
font_sans = typo.get("fontSans") or _THEME_DEFAULT_TYPOGRAPHY["fontSans"]
base_size = typo.get("baseSize") or _THEME_DEFAULT_TYPOGRAPHY["baseSize"]
# Defensive ``</style>`` 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 (
'<style id="hermes-theme-bootstrap">'
":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);}"
"</style>"
)
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.

Expand Down Expand Up @@ -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/<name>.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("</head>", f"{theme_bootstrap}</head>", 1)
html = html.replace("</head>", f"{bootstrap_script}</head>", 1)
return HTMLResponse(
html,
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
183 changes: 183 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<style id="hermes-theme-bootstrap">')
assert css.endswith("</style>")
# 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):
"""`</style>` 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: '</style><script>alert(1)</script>'\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("</style>") == 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(
"<html><head><title>t</title></head><body>SPA</body></html>",
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 '<style id="hermes-theme-bootstrap">' in resp.text
assert "--background-base:#0a1628;" in resp.text
# Injected inside <head>, before the closing tag.
head = resp.text.split("</head>")[0]
assert "hermes-theme-bootstrap" in head

def test_serve_index_no_bootstrap_for_builtin_theme(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import hermes_cli.web_server as ws
monkeypatch.setattr(
ws, "load_config", lambda: {"dashboard": {"theme": "default"}}
)
client = self._mount_spa_client(tmp_path, monkeypatch)
resp = client.get("/chat")
assert resp.status_code == 200
assert "hermes-theme-bootstrap" not in resp.text

def test_serve_index_survives_render_failure(self, tmp_path, monkeypatch):
"""Even if theme rendering blows up internally, index serving
must not crash (the helper swallows and returns '')."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import hermes_cli.web_server as ws

def boom():
raise RuntimeError("boom")

monkeypatch.setattr(ws, "load_config", boom)
client = self._mount_spa_client(tmp_path, monkeypatch)
resp = client.get("/chat")
assert resp.status_code == 200
assert "hermes-theme-bootstrap" not in resp.text
assert "SPA" in resp.text


class TestNormaliseThemeExtensions:
"""Tests for the extended normaliser fields (assets, customCSS,
componentStyles, layoutVariant) — the surfaces themes use to reskin
Expand Down
Loading