From 2497ec5126fa936205f6551ff91e406fc5992bcd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:33:48 -0700 Subject: [PATCH 01/14] refactor(web_server): extract dashboard SPA/theme/plugin-hub cluster to web_server_dashboard --- hermes_cli/web_server.py | 1076 +-------------------------- hermes_cli/web_server_dashboard.py | 1096 ++++++++++++++++++++++++++++ 2 files changed, 1112 insertions(+), 1060 deletions(-) create mode 100644 hermes_cli/web_server_dashboard.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a6a37e655e..d186ce559b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -21,7 +21,6 @@ from datetime import datetime, timezone import hashlib import hmac import inspect -import importlib.util import ipaddress import json import logging @@ -80,7 +79,6 @@ from gateway.status import ( # noqa: F401 — late-bound by web_routers/status get_runtime_status_running_pid, read_runtime_status, ) -from utils import env_var_enabled try: from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect @@ -99,8 +97,7 @@ except ImportError: FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware - from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response - from fastapi.staticfiles import StaticFiles + from fastapi.responses import JSONResponse from starlette.concurrency import run_in_threadpool except Exception: raise SystemExit( @@ -7754,569 +7751,21 @@ def _get_console_executor() -> concurrent.futures.ThreadPoolExecutor: return _console_executor -def _normalise_prefix(raw: Optional[str]) -> str: - """Normalise an X-Forwarded-Prefix header value. - - Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix` - — the single source of truth lives in the dashboard_auth package so - the gate middleware, the OAuth routes, the cookie helpers, and the - SPA mount all agree on validation rules. - """ - from hermes_cli.dashboard_auth.prefix import normalise_prefix - 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 "" - - -# Hashed bundle assets (``/assets/-.``) are immutable -# by construction: any content change produces a new filename, and the entry -# point (index.html) is served ``no-store`` so it always references the -# current hashes. A year-long immutable cache lets browsers skip even the -# revalidation round-trip on every dashboard load. -_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" - - -def mount_spa(application: FastAPI): - """Mount the built SPA. Falls back to index.html for client-side routing. - - The session token is injected into index.html via a ``" - "Headless backend (hermes serve): web UI disabled — use " - "`hermes dashboard` for the browser UI." - "", - headers={ - "Cache-Control": "no-store, no-cache, must-revalidate" - }, - ) - return JSONResponse({"error": _msg}, status_code=404) - return - - # A missing WEB_DIST is deliberately NOT a mount-time terminal state - # (#82614): a long-lived `hermes dashboard --skip-build` process that - # survives a `git pull` (or starts before the first build) used to - # install a permanent no_frontend catch-all here and could never - # recover — every route answered 404 "Frontend not built" until the - # process was restarted, even after `npm run build` completed. The SPA - # routes below all cope with a missing dist per-request (`_serve_index` - # returns the same 404 JSON when index.html is unreadable; the asset - # mounts use check_dir=False and 404 on missing files), so mounting - # them unconditionally makes the dashboard recover the moment a build - # appears on disk — no restart needed. - - _index_path = WEB_DIST / "index.html" - - def _serve_index(prefix: str = ""): - """Return index.html with the session token + base-path injected. - - ``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``) - or empty string when served at root. - - When the OAuth auth gate is active (``app.state.auth_required``), - the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads - identity from ``/api/auth/me`` over cookie auth instead. The - ``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right - auth scheme for /api/pty and /api/ws (ticket vs token). - """ - try: - html = _index_path.read_text(encoding="utf-8") - except OSError: - # The dist dir existed at mount time but index.html is missing or - # unreadable now (partial build, wiped dist, permissions). Without - # this guard every request raises FileNotFoundError (500). Return - # the same JSON 404 payload mount_spa uses for a fully-missing - # dist so clients get a clear, consistent signal. - return JSONResponse( - {"error": "Frontend not built. Run: cd web && npm run build"}, - status_code=404, - ) - chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" - gated = bool(getattr(app.state, "auth_required", False)) - gated_js = "true" if gated else "false" - if gated: - bootstrap_script = ( - f"" - ) - else: - bootstrap_script = ( - f'" - ) - if prefix: - # Rewrite absolute asset URLs baked into the Vite build so the - # browser fetches them through the same proxy prefix. - html = html.replace('href="/assets/', f'href="{prefix}/assets/') - html = html.replace('src="/assets/', f'src="{prefix}/assets/') - html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"') - 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, - headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, - ) - - # When served behind a path-prefix proxy, the built CSS contains - # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references. - # Browsers resolve those against the document origin, which means - # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...`` - # (the MC Pages app), not the Hermes backend. Intercept CSS asset - # requests BEFORE the StaticFiles mount and rewrite the absolute paths - # when a prefix is in play. - @application.get("/assets/{filename}.css") - async def serve_css(filename: str, request: Request): - css_path = WEB_DIST / "assets" / f"{filename}.css" - if not css_path.is_file() or not css_path.resolve().is_relative_to( - WEB_DIST.resolve() - ): - return JSONResponse({"error": "not found"}, status_code=404) - prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) - css = css_path.read_text(encoding="utf-8") - if prefix: - for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): - css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") - css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") - css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") - return Response( - content=css, - media_type="text/css", - headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL}, - ) - - class _ImmutableAssetFiles(StaticFiles): - """StaticFiles that marks hashed bundle assets immutable. - - Everything under ``/assets/`` carries a Vite content hash in its - filename, so a given URL's bytes can never change — a rebuild - produces a NEW filename referenced by a fresh (``no-store``) - index.html. Without this header every dashboard load re-validated - each chunk; with it the browser serves reloads straight from its - HTTP cache. - """ - - async def get_response(self, path: str, scope): - response = await super().get_response(path, scope) - if response.status_code == 200: - response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL - return response - - application.mount( - "/assets", - # check_dir=False: the dist (and its assets/ dir) may not exist yet — - # the whole point of the dynamic recheck (#82614). StaticFiles then - # 404s per-request until a build appears instead of raising at mount. - _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), - name="assets", - ) - - @application.get("/{full_path:path}") - async def serve_spa(full_path: str, request: Request): - prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) - # An unmatched /api/* path is a missing/renamed endpoint, NOT a - # client-side route. Falling through to index.html here returns - # `` with status 200, which makes JSON clients (the - # desktop app's fetchJson, dashboard fetch wrappers) blow up with an - # opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON - # so the caller sees a clear "no such endpoint" instead. - if full_path == "api" or full_path.startswith("api/"): - return JSONResponse( - {"detail": f"No such API endpoint: /{full_path}"}, - status_code=404, - ) - file_path = WEB_DIST / full_path - # Prevent path traversal via url-encoded sequences (%2e%2e/) - if ( - full_path - and file_path.resolve().is_relative_to(WEB_DIST.resolve()) - and file_path.exists() - and file_path.is_file() - ): - return FileResponse(file_path) - return _serve_index(prefix) - - -# --------------------------------------------------------------------------- -# Dashboard theme endpoints -# --------------------------------------------------------------------------- - -# Built-in dashboard themes — label + description only. The actual color -# definitions live in the frontend (web/src/themes/presets.ts). -_BUILTIN_DASHBOARD_THEMES = [ - {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, - {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, - {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, - {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, - {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, - {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, - {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, - {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, -] - - -def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: - """Normalise a theme layer spec from YAML into `{hex, alpha}` form. - - Accepts shorthand (a bare hex string) or full dict form. Returns - ``None`` on garbage input so the caller can fall back to a built-in - default rather than blowing up. - """ - if value is None: - return {"hex": default_hex, "alpha": default_alpha} - if isinstance(value, str): - return {"hex": value, "alpha": default_alpha} - if isinstance(value, dict): - hex_val = value.get("hex", default_hex) - alpha_val = value.get("alpha", default_alpha) - if not isinstance(hex_val, str): - return None - try: - alpha_f = float(alpha_val) - except (TypeError, ValueError): - alpha_f = default_alpha - return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} - return None - - -_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { - "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', - "baseSize": "15px", - "lineHeight": "1.55", - "letterSpacing": "0", -} - -_THEME_DEFAULT_LAYOUT: Dict[str, str] = { - "radius": "0.5rem", - "density": "comfortable", -} - -_THEME_OVERRIDE_KEYS = { - "card", "cardForeground", "popover", "popoverForeground", - "primary", "primaryForeground", "secondary", "secondaryForeground", - "muted", "mutedForeground", "accent", "accentForeground", - "destructive", "destructiveForeground", "success", "warning", - "border", "input", "ring", -} - -# Well-known named asset slots themes can populate. Any other keys under -# ``assets.custom`` are exposed as ``--theme-asset-custom-`` CSS vars -# for plugin/shell use. -_THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} - -# Component-style buckets themes can override. The value under each bucket -# is a mapping from camelCase property name to CSS string; each pair emits -# ``--component--`` on :root. The frontend's shell -# components (Card, App header, Backdrop, etc.) consume these vars so themes -# can restyle chrome (clip-path, border-image, segmented progress, etc.) -# without shipping their own CSS. -_THEME_COMPONENT_BUCKETS = { - "card", "header", "footer", "sidebar", "tab", - "progress", "badge", "backdrop", "page", -} - -_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} - -# Cap on customCSS length so a malformed/oversized theme YAML can't blow up -# the response payload or the `` 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 "" + + +# Hashed bundle assets (``/assets/-.``) are immutable +# by construction: any content change produces a new filename, and the entry +# point (index.html) is served ``no-store`` so it always references the +# current hashes. A year-long immutable cache lets browsers skip even the +# revalidation round-trip on every dashboard load. +_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" + + +def mount_spa(application: FastAPI): + """Mount the built SPA. Falls back to index.html for client-side routing. + + The session token is injected into index.html via a ``" + "Headless backend (hermes serve): web UI disabled — use " + "`hermes dashboard` for the browser UI." + "", + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate" + }, + ) + return JSONResponse({"error": _msg}, status_code=404) + return + + # A missing WEB_DIST is deliberately NOT a mount-time terminal state + # (#82614): a long-lived `hermes dashboard --skip-build` process that + # survives a `git pull` (or starts before the first build) used to + # install a permanent no_frontend catch-all here and could never + # recover — every route answered 404 "Frontend not built" until the + # process was restarted, even after `npm run build` completed. The SPA + # routes below all cope with a missing dist per-request (`_serve_index` + # returns the same 404 JSON when index.html is unreadable; the asset + # mounts use check_dir=False and 404 on missing files), so mounting + # them unconditionally makes the dashboard recover the moment a build + # appears on disk — no restart needed. + + _index_path = WEB_DIST / "index.html" + + def _serve_index(prefix: str = ""): + """Return index.html with the session token + base-path injected. + + ``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``) + or empty string when served at root. + + When the OAuth auth gate is active (``app.state.auth_required``), + the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads + identity from ``/api/auth/me`` over cookie auth instead. The + ``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right + auth scheme for /api/pty and /api/ws (ticket vs token). + """ + try: + html = _index_path.read_text(encoding="utf-8") + except OSError: + # The dist dir existed at mount time but index.html is missing or + # unreadable now (partial build, wiped dist, permissions). Without + # this guard every request raises FileNotFoundError (500). Return + # the same JSON 404 payload mount_spa uses for a fully-missing + # dist so clients get a clear, consistent signal. + return JSONResponse( + {"error": "Frontend not built. Run: cd web && npm run build"}, + status_code=404, + ) + chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" + gated = bool(getattr(app.state, "auth_required", False)) + gated_js = "true" if gated else "false" + if gated: + bootstrap_script = ( + f"" + ) + else: + bootstrap_script = ( + f'" + ) + if prefix: + # Rewrite absolute asset URLs baked into the Vite build so the + # browser fetches them through the same proxy prefix. + html = html.replace('href="/assets/', f'href="{prefix}/assets/') + html = html.replace('src="/assets/', f'src="{prefix}/assets/') + html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"') + 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, + headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, + ) + + # When served behind a path-prefix proxy, the built CSS contains + # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references. + # Browsers resolve those against the document origin, which means + # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...`` + # (the MC Pages app), not the Hermes backend. Intercept CSS asset + # requests BEFORE the StaticFiles mount and rewrite the absolute paths + # when a prefix is in play. + @application.get("/assets/{filename}.css") + async def serve_css(filename: str, request: Request): + css_path = WEB_DIST / "assets" / f"{filename}.css" + if not css_path.is_file() or not css_path.resolve().is_relative_to( + WEB_DIST.resolve() + ): + return JSONResponse({"error": "not found"}, status_code=404) + prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) + css = css_path.read_text(encoding="utf-8") + if prefix: + for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): + css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") + css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") + css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") + return Response( + content=css, + media_type="text/css", + headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL}, + ) + + class _ImmutableAssetFiles(StaticFiles): + """StaticFiles that marks hashed bundle assets immutable. + + Everything under ``/assets/`` carries a Vite content hash in its + filename, so a given URL's bytes can never change — a rebuild + produces a NEW filename referenced by a fresh (``no-store``) + index.html. Without this header every dashboard load re-validated + each chunk; with it the browser serves reloads straight from its + HTTP cache. + """ + + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + if response.status_code == 200: + response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL + return response + + application.mount( + "/assets", + # check_dir=False: the dist (and its assets/ dir) may not exist yet — + # the whole point of the dynamic recheck (#82614). StaticFiles then + # 404s per-request until a build appears instead of raising at mount. + _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), + name="assets", + ) + + @application.get("/{full_path:path}") + async def serve_spa(full_path: str, request: Request): + prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) + # An unmatched /api/* path is a missing/renamed endpoint, NOT a + # client-side route. Falling through to index.html here returns + # `` with status 200, which makes JSON clients (the + # desktop app's fetchJson, dashboard fetch wrappers) blow up with an + # opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON + # so the caller sees a clear "no such endpoint" instead. + if full_path == "api" or full_path.startswith("api/"): + return JSONResponse( + {"detail": f"No such API endpoint: /{full_path}"}, + status_code=404, + ) + file_path = WEB_DIST / full_path + # Prevent path traversal via url-encoded sequences (%2e%2e/) + if ( + full_path + and file_path.resolve().is_relative_to(WEB_DIST.resolve()) + and file_path.exists() + and file_path.is_file() + ): + return FileResponse(file_path) + return _serve_index(prefix) + + +# --------------------------------------------------------------------------- +# Dashboard theme endpoints +# --------------------------------------------------------------------------- + +# Built-in dashboard themes — label + description only. The actual color +# definitions live in the frontend (web/src/themes/presets.ts). +_BUILTIN_DASHBOARD_THEMES = [ + {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, + {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, + {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, + {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, + {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, + {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, + {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, + {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, +] + + +def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: + """Normalise a theme layer spec from YAML into `{hex, alpha}` form. + + Accepts shorthand (a bare hex string) or full dict form. Returns + ``None`` on garbage input so the caller can fall back to a built-in + default rather than blowing up. + """ + if value is None: + return {"hex": default_hex, "alpha": default_alpha} + if isinstance(value, str): + return {"hex": value, "alpha": default_alpha} + if isinstance(value, dict): + hex_val = value.get("hex", default_hex) + alpha_val = value.get("alpha", default_alpha) + if not isinstance(hex_val, str): + return None + try: + alpha_f = float(alpha_val) + except (TypeError, ValueError): + alpha_f = default_alpha + return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} + return None + + +_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { + "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', + "baseSize": "15px", + "lineHeight": "1.55", + "letterSpacing": "0", +} +_THEME_DEFAULT_LAYOUT: Dict[str, str] = { + "radius": "0.5rem", + "density": "comfortable", +} +_THEME_OVERRIDE_KEYS = { + "card", "cardForeground", "popover", "popoverForeground", + "primary", "primaryForeground", "secondary", "secondaryForeground", + "muted", "mutedForeground", "accent", "accentForeground", + "destructive", "destructiveForeground", "success", "warning", + "border", "input", "ring", +} + +# Well-known named asset slots themes can populate. Any other keys under +# ``assets.custom`` are exposed as ``--theme-asset-custom-`` CSS vars +# for plugin/shell use. +_THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} + +# Component-style buckets themes can override. The value under each bucket +# is a mapping from camelCase property name to CSS string; each pair emits +# ``--component--`` on :root. The frontend's shell +# components (Card, App header, Backdrop, etc.) consume these vars so themes +# can restyle chrome (clip-path, border-image, segmented progress, etc.) +# without shipping their own CSS. +_THEME_COMPONENT_BUCKETS = { + "card", "header", "footer", "sidebar", "tab", + "progress", "badge", "backdrop", "page", +} +_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} + +# Cap on customCSS length so a malformed/oversized theme YAML can't blow up +# the response payload or the