Files
openclaw/ui/index.html
T
Vyctor H. Brzezowski 32ced3053d fix(ui): scope the Control UI cursor convention to app-like display modes (#121258)
* fix(ui): scope the Control UI cursor convention to app-like display modes

The app-chrome cursor convention from #103357/#103411 was applied
unconditionally, so an ordinary browser tab lost the pointer hand on
buttons, menus, tabs, rails, selects and accordion summaries — the only
hover affordance a page owns. The convention is correct for the installed
window the manifest declares ("display": "standalone"), not for every
window the same bundle is served into.

base.css now owns one policy token, --cursor-action, selected by display
mode: pointer by default, the desktop arrow under standalone, minimal-ui
and window-controls-overlay. A low-specificity rule maps generic
actionable controls onto that token, restoring the affordance on the
surfaces #103411 stripped bare, while every component rule that owns a
semantic cursor (not-allowed, disabled, grab, resize, zoom-in, text,
wait) keeps winning without !important.

The 92 cursor: pointer declarations that had drifted back into ui/src
since July now consume the token instead of hardcoding the hand, so they
stop contradicting the policy in an installed window. Real hyperlinks
keep the pointer in every mode. The pre-boot mount fallback repeats the
policy locally because it must render when the bundle fails to load.

Closes #121242

* test(ui): run the cursor policy browser test in the node-driven project

ui/vitest.config.ts routes Playwright-from-Node .browser.test.ts files to the
unit-node project; without registering the new cursor policy test there, the
in-browser chromium project tried to import it and failed on node:fs/playwright.

* fix(ui): keep the desktop arrow in the native app hosts

The macOS dashboard embeds the Control UI in a plain web view, which
reports `display-mode: browser`, so the display-mode-only policy would
have handed it the browser-tab pointer. It already announces itself with
`openclaw-native-macos`/`-nav`/`-web-chrome` on `<html>`, the same markers
`ui/src/styles/layout.css` matches on, so the policy reads those too.
2026-08-09 22:23:00 -03:00

474 lines
15 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" />
<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 };
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";
}
var resolved =
theme === "knot"
? mode === "light"
? "openknot-light"
: "openknot"
: theme === "dash"
? mode === "light"
? "dash-light"
: "dash"
: mode === "light"
? "light"
: "dark";
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;
}
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;
}
/* The fallback renders when the bundle fails to load, so it cannot read
base.css tokens; it repeats the cursor policy locally for that reason. */
.mount-fallback__button {
cursor: pointer;
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;
}
@media (display-mode: standalone),
(display-mode: minimal-ui),
(display-mode: window-controls-overlay) {
.mount-fallback__button {
cursor: default;
}
}
html:is(.openclaw-native-macos, .openclaw-native-nav, .openclaw-native-web-chrome)
.mount-fallback__button {
cursor: default;
}
.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 tagName = "openclaw-app";
var app = document.querySelector(tagName);
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;
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 appMounted() {
try {
return Boolean(
app.isConnected &&
window.customElements &&
typeof window.customElements.get === "function" &&
window.customElements.get(tagName),
);
} catch (e) {
return false;
}
}
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 (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
var retryDelay = Math.min(delay, 1000 * Math.pow(2, recoveryAttempt));
recoveryTimer = window.setTimeout(retryCurrentDocument, retryDelay);
}
function finishRecoveryAttempt() {
recoveryInFlight = false;
if (appMounted()) 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 retryCurrentDocument() {
if (
appMounted() ||
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");
window.location.replace(documentUrl.href);
})
.catch(function () {
finishRecoveryAttempt();
})
.finally(function () {
window.clearTimeout(requestTimer);
});
}
function showFallback() {
if (appMounted()) 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();
if (window.customElements && typeof window.customElements.whenDefined === "function") {
window.customElements.whenDefined(tagName).then(
function () {
window.clearTimeout(timer);
window.clearTimeout(recoveryTimer);
hideFallback();
},
function () {},
);
}
if (retry) {
retry.addEventListener("click", function () {
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>