Files
openclaw/ui/index.html
Peter Steinberger 9ccbbf83f2 perf(control-ui): load built-in theme palettes on demand (#130473)
* perf(control-ui): load built-in theme palettes on demand

Every built-in theme's tokens shipped in the startup stylesheet, so the default
path downloaded six palettes it never painted and each new theme taxed everyone.
That is what pushed the startup CSS ceiling from 45 to 47 KiB when Tide, Beacon,
and Phosphor landed.

Moves the twelve non-default palettes into public/themes/<family>.css, one file
per family covering both modes. Claw stays inline because its tokens are the
:root defaults, so the default path loses nothing and gains the bytes back.

The first-paint story this needed: index.html's boot script now links the active
family's palette during head parsing, which makes it render-blocking exactly
like the app stylesheet, so a persisted theme paints its own colours on the
first frame instead of flashing the default. The href is built from the mount
prefix the gateway already stamps on <html>, so it follows a configured Control
UI base path without the script having to know one. theme.ts keeps the link
correct when the theme changes at runtime, reusing the helper the webfont
stylesheets already use.

The nested resolve-theme ternary became a family table in the same script, since
it now picks an asset as well as a data-theme value.

  startup CSS  45.8 -> 42.2 KiB gzip, below the 44.3 KiB it measured before the
               three themes landed; ceiling restored 47 -> 45 KiB
  base.css     64.0 -> 35.8 KiB raw

Adds a regression test that blocks every bundle script and asserts the palette
still applies, so moving this back into the app bundle fails instead of silently
reintroducing the flash. Verified it catches that: with the boot-script link
removed the assertion reports `expected null to be '/themes/tide.css'`.

* fix(control-ui): publish themes after their palettes load

* fix(control-ui): clean up palette completion listeners

* refactor(control-ui): consolidate theme name resolution
2026-08-26 18:56:14 -07:00

517 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>OpenClaw Control</title>
<meta name="color-scheme" content="dark light" />
<!-- These mirror the claw theme's --bg tokens for first paint; runtime theme application keeps them synchronized. -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf9f7" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0e1015" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<script>
// Configure Zod before module evaluation so its JIT probe cannot violate strict CSP.
globalThis.__zod_globalConfig ??= {};
globalThis.__zod_globalConfig.jitless = true;
(function () {
var THEMES = { claw: 1, knot: 1, dash: 1, absolutely: 1, tide: 1, beacon: 1, phosphor: 1 };
var MODES = { system: 1, light: 1, dark: 1 };
var LEGACY = {
dark: "claw:dark",
light: "claw:light",
openknot: "knot:dark",
fieldmanual: "dash:dark",
clawdash: "dash:light",
system: "claw:system",
};
try {
var keys = Object.keys(localStorage);
var raw;
for (var i = 0; i < keys.length; i++) {
if (keys[i].indexOf("openclaw.control.settings.v1") === 0) {
raw = localStorage.getItem(keys[i]);
if (raw) break;
}
}
if (!raw) return;
var s = JSON.parse(raw);
var t = s && s.theme;
var m = s && s.themeMode;
if (typeof t !== "string") t = "";
if (typeof m !== "string") m = "";
var legacy = LEGACY[t];
var theme = THEMES[t] ? t : legacy ? legacy.split(":")[0] : "claw";
var mode = MODES[m] ? m : legacy ? legacy.split(":")[1] : "system";
if (mode === "system") {
mode = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
// family -> [dark, light] resolved names. Kept in lockstep with
// resolveTheme() in app/theme.ts and the files in public/themes.
var FAMILIES = {
knot: ["openknot", "openknot-light"],
dash: ["dash", "dash-light"],
absolutely: ["absolutely", "absolutely-light"],
tide: ["tide", "tide-light"],
beacon: ["beacon", "beacon-light"],
phosphor: ["phosphor", "phosphor-light"],
};
var pair = FAMILIES[theme];
var resolved = pair
? pair[mode === "light" ? 1 : 0]
: mode === "light"
? "light"
: "dark";
if (pair) {
// Script-created links need explicit render blocking before first paint.
// The gateway stamps the mount prefix on <html> before this runs.
var base =
document.documentElement.getAttribute("data-openclaw-control-ui-base-path") || "";
var palette = document.createElement("link");
palette.rel = "stylesheet";
palette.setAttribute("blocking", "render");
palette.id = "openclaw-theme-palette-" + theme;
palette.onerror = function () {
palette.remove();
};
palette.href = base + "/themes/" + theme + ".css";
document.head.appendChild(palette);
}
document.documentElement.setAttribute("data-theme", resolved);
var resolvedMode = resolved.includes("light") ? "light" : "dark";
document.documentElement.setAttribute("data-theme-mode", resolvedMode);
// Carapace CSS selects on [data-theme-resolved]; keep in lockstep.
document.documentElement.setAttribute("data-theme-resolved", resolvedMode);
} catch (e) {}
})();
</script>
<style>
/* Paint before the app stylesheet arrives so WebKit never exposes a white
document between its native dark canvas and the persisted UI theme. */
html,
body {
min-height: 100%;
}
html {
background: #0e1015;
color-scheme: dark;
}
html[data-theme-mode="light"] {
background: #faf9f7;
color-scheme: light;
}
html[data-theme="openknot"] {
background: #080808;
}
html[data-theme="openknot-light"] {
background: #f9f9fb;
}
html[data-theme="dash"] {
background: #1a1210;
}
html[data-theme="dash-light"] {
background: #f7f2ec;
}
html[data-theme="absolutely"] {
background: #1c1c1a;
}
html[data-theme="absolutely-light"] {
background: #faf9f5;
}
html[data-theme="tide"] {
background: #10151b;
}
html[data-theme="tide-light"] {
background: #f7f9fb;
}
html[data-theme="beacon"] {
background: #000000;
}
html[data-theme="beacon-light"] {
background: #ffffff;
}
html[data-theme="phosphor"] {
background: #0a0f0a;
}
html[data-theme="phosphor-light"] {
background: #f4f7f4;
}
body {
margin: 0;
min-height: 100%;
background: inherit;
color-scheme: inherit;
}
body.openclaw-mount-fallback-active {
margin: 0;
min-width: 320px;
color: #eef4f8;
background: #101418;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
body.openclaw-mount-fallback-active openclaw-app {
display: none;
}
.mount-fallback {
box-sizing: border-box;
/* The app stylesheet clips the root scrollers, so this pre-app error
surface must own its scrolling on short windows. dvh keeps the
scroll range inside the visible mobile viewport; vh is the fallback. */
height: 100vh;
height: 100dvh;
overflow-y: auto;
padding: 24px;
place-items: center;
place-items: safe center;
}
.mount-fallback:not([hidden]) {
display: grid;
}
.mount-fallback__panel {
box-sizing: border-box;
width: min(100%, 640px);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: #151b21;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36);
padding: 28px;
}
.mount-fallback__eyebrow {
margin: 0 0 10px;
color: #9fb0bd;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mount-fallback__panel h1 {
margin: 0;
color: inherit;
font-size: clamp(1.5rem, 3vw, 2rem);
line-height: 1.15;
}
.mount-fallback__panel p {
margin: 16px 0 0;
color: #c8d3da;
font-size: 1rem;
line-height: 1.6;
}
.mount-fallback__panel ul {
margin: 18px 0 0;
padding-left: 1.2rem;
color: #c8d3da;
line-height: 1.6;
}
.mount-fallback__panel a {
color: #8bd3ff;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
}
.mount-fallback__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 24px;
}
.mount-fallback__button {
min-height: 42px;
border: 1px solid rgba(148, 163, 184, 0.36);
border-radius: 6px;
padding: 0 16px;
color: inherit;
background: transparent;
font: inherit;
font-weight: 700;
}
.mount-fallback__button--primary {
border-color: #66c2ff;
color: #061019;
background: #8bd3ff;
}
.mount-fallback__panel:focus {
outline: none;
}
.mount-fallback__button:focus-visible,
.mount-fallback__panel a:focus-visible {
outline: 3px solid #f9c74f;
outline-offset: 3px;
}
html[data-theme-mode="light"] body.openclaw-mount-fallback-active {
color: #151b21;
background: #f5f7fa;
}
html[data-theme-mode="light"] .mount-fallback__panel {
border-color: rgba(71, 85, 105, 0.22);
background: #ffffff;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
}
html[data-theme-mode="light"] .mount-fallback__eyebrow,
html[data-theme-mode="light"] .mount-fallback__panel p,
html[data-theme-mode="light"] .mount-fallback__panel ul {
color: #4b5963;
}
html[data-theme-mode="light"] .mount-fallback__panel a {
color: #0369a1;
}
</style>
</head>
<body>
<openclaw-app></openclaw-app>
<section
id="openclaw-mount-fallback"
class="mount-fallback"
data-openclaw-mount-timeout-ms="12000"
role="alert"
aria-labelledby="openclaw-mount-fallback-title"
hidden
>
<div class="mount-fallback__panel" tabindex="-1">
<p class="mount-fallback__eyebrow">OpenClaw Control UI</p>
<h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
<p id="openclaw-mount-fallback-summary">
The browser loaded the static page, but the app bundle did not start. The gateway may be
restarting, or this page may reference assets from a different OpenClaw version.
</p>
<ul>
<li>OpenClaw will retry the current app bundle automatically.</li>
<li>If this persists, reload or try a clean browser profile.</li>
<li>
See
<a
href="https://docs.openclaw.ai/web/control-ui#blank-control-ui-page"
target="_blank"
rel="noopener noreferrer"
>Control UI troubleshooting</a
>.
</li>
</ul>
<div class="mount-fallback__actions">
<button
type="button"
id="openclaw-mount-retry"
class="mount-fallback__button mount-fallback__button--primary"
>
Try again
</button>
<button type="button" id="openclaw-mount-wait" class="mount-fallback__button">
Keep waiting
</button>
</div>
</div>
</section>
<script>
(function () {
var app = document.querySelector("openclaw-app");
var fallback = document.getElementById("openclaw-mount-fallback");
if (!app || !fallback) return;
var panel = fallback.querySelector(".mount-fallback__panel");
var summary = document.getElementById("openclaw-mount-fallback-summary");
var retry = document.getElementById("openclaw-mount-retry");
var wait = document.getElementById("openclaw-mount-wait");
var rawDelay = Number(fallback.getAttribute("data-openclaw-mount-timeout-ms"));
var delay = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 12000;
var maxRecoveryAttempts = 6;
var timer;
var recoveryTimer;
var recoveryAttempt = 0;
var recoveryInFlight = false;
var recoveryNavigation = false;
var appStarted = false;
try {
var initialUrl = new URL(window.location.href);
recoveryNavigation = initialUrl.searchParams.has("openclaw_mount_recovery");
if (recoveryNavigation) {
initialUrl.searchParams.delete("openclaw_mount_recovery");
window.history.replaceState(null, "", initialUrl.href);
}
} catch (e) {}
function hideFallback() {
fallback.hidden = true;
document.body.classList.remove("openclaw-mount-fallback-active");
}
function setSummary(text) {
if (summary) summary.textContent = text;
}
function scheduleRecovery() {
window.clearTimeout(recoveryTimer);
if (appStarted || recoveryAttempt >= maxRecoveryAttempts) return;
var retryDelay = Math.min(delay, 1000 * Math.pow(2, recoveryAttempt));
recoveryTimer = window.setTimeout(retryCurrentDocument, retryDelay);
}
function finishRecoveryAttempt() {
recoveryInFlight = false;
if (appStarted) return;
if (recoveryAttempt >= maxRecoveryAttempts) {
setSummary(
"The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
);
return;
}
setSummary(
"The gateway is not reachable yet. OpenClaw will keep retrying while it restarts.",
);
scheduleRecovery();
}
function unregisterServiceWorkers() {
if (!navigator.serviceWorker?.getRegistrations) return;
// Recovery must not depend on a possibly broken worker. Navigations bypass it,
// so a reload can self-heal after the registrations are removed.
void navigator.serviceWorker
.getRegistrations()
.then(function (registrations) {
return Promise.all(
registrations.map(function (registration) {
return registration.unregister();
}),
);
})
.catch(function () {});
}
function retryCurrentDocument() {
if (
appStarted ||
recoveryInFlight ||
recoveryAttempt >= maxRecoveryAttempts ||
typeof window.fetch !== "function"
)
return;
var documentUrl = new URL(window.location.href);
if (recoveryNavigation) {
setSummary(
"A fresh page still could not start the Control UI. Try again, then check the troubleshooting guide if the problem persists.",
);
return;
}
recoveryInFlight = true;
recoveryAttempt += 1;
documentUrl.searchParams.set("openclaw_mount_recovery", String(Date.now()));
var recoveryController = new AbortController();
var requestTimer = window.setTimeout(function () {
recoveryController.abort();
}, delay);
window
.fetch(documentUrl.href, {
cache: "no-store",
credentials: "same-origin",
signal: recoveryController.signal,
})
.then(function (response) {
if (!response.ok) throw new Error("gateway unavailable");
if (!appStarted) window.location.replace(documentUrl.href);
})
.catch(function () {
unregisterServiceWorkers();
finishRecoveryAttempt();
})
.finally(function () {
window.clearTimeout(requestTimer);
});
}
function showFallback() {
if (appStarted) return;
retryCurrentDocument();
fallback.hidden = false;
document.body.classList.add("openclaw-mount-fallback-active");
if (panel && typeof panel.focus === "function") {
try {
panel.focus({ preventScroll: true });
} catch (e) {
panel.focus();
}
}
}
function armFallbackTimer() {
window.clearTimeout(timer);
timer = window.setTimeout(showFallback, delay);
}
armFallbackTimer();
window.addEventListener(
"openclaw-control-ui-rendered",
function () {
appStarted = true;
window.clearTimeout(timer);
window.clearTimeout(recoveryTimer);
hideFallback();
},
{ once: true },
);
if (retry) {
retry.addEventListener("click", function () {
unregisterServiceWorkers();
window.location.reload();
});
}
if (wait) {
wait.addEventListener("click", function () {
hideFallback();
recoveryAttempt = 0;
retryCurrentDocument();
armFallbackTimer();
});
}
})();
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>