Skip to content
Closed
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
68 changes: 68 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3827,6 +3827,64 @@ 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("</", "<\\/")
return (
'<style id="hermes-theme-bootstrap">'
":root{"
f"--background-base:{_esc(bg_hex)};"
f"--color-background:{_esc(bg_hex)};"
f"--midground-base:{_esc(mg_hex)};"
f"--color-midground:{_esc(mg_hex)};"
f"--font-sans:{_esc(font_sans)};"
f"--font-base-size:{_esc(base_size)};"
"}"
f"html,body{{background-color:{_esc(bg_hex)};color:{_esc(mg_hex)};"
f"font-family:{_esc(font_sans)};font-size:{_esc(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 @@ -3893,6 +3951,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
Loading