fix(dashboard): inline critical-CSS bootstrap for user themes to mitigate flash - #36024
fix(dashboard): inline critical-CSS bootstrap for user themes to mitigate flash#36024nnnet wants to merge 1 commit into
Conversation
…gate 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.
|
Thanks for targeting a real first-paint gap: current Problems
Suggested changes
Automated hermes-sweeper review. |
|
Good diagnosis — the first-paint flash for user YAML themes is real (built-ins are bundled in
With those addressed this is a nice UX win — happy to re-review quickly. |
…le flows through vars Review fixes for the inline critical-CSS bootstrap (PR #36024): 1. Variable names now match what the bundle actually consumes. --color-background, --color-midground, --font-sans and --font-base-size appear nowhere in web/src; the real tokens are: --background-base / --midground-base (layerVars(), context.tsx) --theme-font-sans / --theme-base-size (typographyVars(), and index.css html{font-family:var(--theme-font-sans); font-size:var(--theme-base-size)}) 2. Stale-rule bug: the injected html,body rule previously baked in literal hex/font values. Because the <style> block sits after the bundle's <link> at equal specificity and is never removed, switching themes in the picker left the old canvas/font until reload. The rule now references the same CSS variables instead of literals — applyTheme() writes those vars as inline styles on documentElement, which outrank this block in the cascade, so runtime theme switches re-resolve the rule automatically. No frontend change needed.
…ction Server-side coverage for the critical-CSS shim (PR #36024 salvage): - user theme → style block emitted with ONLY real bundle variable names (--background-base/--midground-base from layerVars(), --theme-font-sans/--theme-base-size from typographyVars()/index.css), and an html,body rule expressed via those vars so runtime theme switches never leave a stale canvas/font - built-in / unknown / non-string active theme → no block - malformed theme YAML and load_config() exceptions → no crash, index still serves - </style> breakout attempt in a theme value stays escaped - mount_spa integration: block present in <head> for user themes, absent for built-ins
|
Merged via #65695 (rebase-merged onto main, head commit 7edaaf4) — your commit was cherry-picked with authorship preserved; on top we switched the injected block to the real bundle variables (--theme-font-sans / --theme-base-size — the --color-* names didn't exist in web/src) and made the html,body rule var-driven so live theme switches re-resolve with nothing stale. Thanks for the contribution! |
…le flows through vars Review fixes for the inline critical-CSS bootstrap (PR NousResearch#36024): 1. Variable names now match what the bundle actually consumes. --color-background, --color-midground, --font-sans and --font-base-size appear nowhere in web/src; the real tokens are: --background-base / --midground-base (layerVars(), context.tsx) --theme-font-sans / --theme-base-size (typographyVars(), and index.css html{font-family:var(--theme-font-sans); font-size:var(--theme-base-size)}) 2. Stale-rule bug: the injected html,body rule previously baked in literal hex/font values. Because the <style> block sits after the bundle's <link> at equal specificity and is never removed, switching themes in the picker left the old canvas/font until reload. The rule now references the same CSS variables instead of literals — applyTheme() writes those vars as inline styles on documentElement, which outrank this block in the cascade, so runtime theme switches re-resolve the rule automatically. No frontend change needed.
…ction Server-side coverage for the critical-CSS shim (PR NousResearch#36024 salvage): - user theme → style block emitted with ONLY real bundle variable names (--background-base/--midground-base from layerVars(), --theme-font-sans/--theme-base-size from typographyVars()/index.css), and an html,body rule expressed via those vars so runtime theme switches never leave a stale canvas/font - built-in / unknown / non-string active theme → no block - malformed theme YAML and load_config() exceptions → no crash, index still serves - </style> breakout attempt in a theme value stays escaped - mount_spa integration: block present in <head> for user themes, absent for built-ins
…le flows through vars Review fixes for the inline critical-CSS bootstrap (PR NousResearch#36024): 1. Variable names now match what the bundle actually consumes. --color-background, --color-midground, --font-sans and --font-base-size appear nowhere in web/src; the real tokens are: --background-base / --midground-base (layerVars(), context.tsx) --theme-font-sans / --theme-base-size (typographyVars(), and index.css html{font-family:var(--theme-font-sans); font-size:var(--theme-base-size)}) 2. Stale-rule bug: the injected html,body rule previously baked in literal hex/font values. Because the <style> block sits after the bundle's <link> at equal specificity and is never removed, switching themes in the picker left the old canvas/font until reload. The rule now references the same CSS variables instead of literals — applyTheme() writes those vars as inline styles on documentElement, which outrank this block in the cascade, so runtime theme switches re-resolve the rule automatically. No frontend change needed.
…ction Server-side coverage for the critical-CSS shim (PR NousResearch#36024 salvage): - user theme → style block emitted with ONLY real bundle variable names (--background-base/--midground-base from layerVars(), --theme-font-sans/--theme-base-size from typographyVars()/index.css), and an html,body rule expressed via those vars so runtime theme switches never leave a stale canvas/font - built-in / unknown / non-string active theme → no block - malformed theme YAML and load_config() exceptions → no crash, index still serves - </style> breakout attempt in a theme value stays escaped - mount_spa integration: block present in <head> for user themes, absent for built-ins
Problem
User themes (`~/.hermes/dashboard-themes/*.yaml`) reach the SPA only after the `/api/dashboard/themes` round-trip completes at React mount. The bundle paints the first frame with the default Hermes Teal canvas because:
The user sees a green canvas behind the loading SPA on every reload whenever the active theme is a user theme. This makes the user-themes extension point — the documented way to ship a custom palette without rebuilding the bundle — feel second-class compared to built-ins.
Built-in themes do not have this problem. Their full definitions are bundled inside `presets.ts`, so the SPA owns the palette before first paint.
Solution (backend only)
`_serve_index()` injects a small critical-CSS `<style>` block inside `` whenever the active theme is a user theme. The block carries the six CSS variables that determine the canvas/text colour and base typography:
…and an `html, body` rule that paints the canvas using those variables.
Because the inline `<style>` follows the bundle's `` in DOM order and matches the same `:root` specificity (0,0,1,0), the later declaration wins the CSS cascade. The static canvas behind the SPA is already in the user theme's palette before any JavaScript runs.
When `ThemeProvider` later mounts and `applyTheme()` writes the same variables as inline styles on `documentElement`, the values are identical to what the bootstrap block set — there is no second-paint discrepancy on the critical variables.
`_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 endpoints, no config flags, no frontend changes.
Benefits
Test plan
Notes
The patch is intentionally conservative: it covers only the canvas / text / font variables. A residual short flash is still possible if the bundle's `ThemeProvider` re-applies the default theme as inline styles on `documentElement` between mount and the API response — that race is best fixed on the frontend (e.g. having `ThemeProvider` read a backend-injected JS global with the active theme's full definition). Filing that as a separate PR keeps each change focused and reviewable in isolation; this PR removes the static green-canvas case that every user-theme operator hits on every reload today.