fix(dashboard): inline critical-CSS bootstrap for user themes to mitigate flash
User themes (`~/.hermes/dashboard-themes/*.yaml`) reach the SPA only
after `/api/dashboard/themes` resolves at React mount. The bundle paints
the first frame with the default Hermes Teal canvas — the
`<link rel="stylesheet">` carries `:root{--background-base:#041c1c}`,
the bundled `presets.ts` defines the same surfaces in JS — and then
`ThemeProvider.applyTheme(<user theme>)` flips the inline CSS variables
on `documentElement` once the API response lands. Visible to the user
as a green canvas behind the loading SPA on every reload when the active
theme is non-default.
Built-in themes do not suffer the same effect because their full
definitions ship inside the bundle, so the SPA already has the palette
before first paint.
This patch closes the gap on the backend side: `_serve_index()` injects
a `<style id="hermes-theme-bootstrap">` block inside `<head>` with the
six critical CSS variables (`--background-base`, `--color-background`,
`--midground-base`, `--color-midground`, `--font-sans`,
`--font-base-size`) plus an `html, body` rule painting the body in the
target palette. Because the inline `<style>` follows the bundle's
`<link>` in DOM order and matches the same `:root` specificity, the
later declaration wins the cascade — the static canvas behind the SPA is
already the right colour before any JavaScript runs.
`_render_active_theme_bootstrap_css()` looks up the active theme through
the existing `_discover_user_themes()` helper. No-op for built-in
active themes (empty string returned, no `<style>` injected). No new
API endpoints, no config flags, no frontend changes.
After `ThemeProvider` mounts and `applyTheme()` writes the same
variables as inline styles on `documentElement`, the values match what
the bootstrap block set, so there is no second-paint discrepancy on the
critical CSS variables.
This commit is contained in:
parent
2fba721ab0
commit
72562be961
1 changed files with 68 additions and 0 deletions
|
|
@ -16304,6 +16304,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.
|
||||
|
||||
|
|
@ -16378,6 +16436,16 @@ def mount_spa(application: FastAPI):
|
|||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue