(() => {
const script = document.currentScript;
const wsUrl = (() => {
const url = new URL(script.src);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = "/ws";
url.search = "";
url.hash = "";
return url.href;
})();
const enabled = script.dataset.statusIndicator !== "false";
const MUTED = "#a1a1aa";
const ERROR = "#fca5a5";
const FONT = "Topcoat Dev";
const FONT_URL =
"https://cdn.jsdelivr.net/fontsource/fonts/lexend-deca@latest/latin-";
if (enabled) {
for (const weight of ["400", "600"]) {
const font = new FontFace(
FONT,
`url(${FONT_URL}${weight}-normal.woff2) format("woff2")`,
{ weight, display: "swap" }
);
document.fonts.add(font);
font.load().catch(() => {});
}
}
const lucide = (paths) =>
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"' +
' viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"' +
` stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
const X_ICON = lucide('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>');
const SPINNER_ICON = lucide('<path d="M21 12a9 9 0 1 1-6.219-8.56"/>');
const HOST_STYLE = `
all: initial;
position: fixed;
bottom: 16px;
left: 16px;
z-index: 2147483647;
display: flex;
align-items: center;
gap: 7px;
padding: 7px 8px 7px 14px;
background: #0a0a0a;
color: #fff;
border: 1px solid #000;
border-radius: 8px;
font: 12px/1 "${FONT}", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
`;
const SHADOW_HTML = `
<style>
.brand {
color: ${MUTED};
}
b {
font-weight: 600;
}
/* all:unset strips the UA button styles. */
.dismiss {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
cursor: pointer;
color: ${MUTED};
transition: color 0.15s ease;
}
.dismiss:hover {
color: #fff;
}
.busy {
color: #a5f3fc;
}
.spinner {
display: flex;
color: #a5f3fc;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error labels catch the eye with a soft highlight sweeping across
the text once every three seconds. */
.error {
background-image:
linear-gradient(100deg, ${ERROR} 30%, #fecaca 50%, ${ERROR} 70%);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer 3s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
25% { background-position: 0 0; }
100% { background-position: 0 0; }
}
</style>
<span class="brand">topcoat</span>
<b></b>
<span class="spinner">${SPINNER_ICON}</span>
<button class="dismiss" aria-label="Dismiss">${X_ICON}</button>
`;
let pill = null;
let statusEl = null;
let spinnerEl = null;
function createPill() {
pill = document.createElement("topcoat-dev-status");
pill.style.cssText = HOST_STYLE;
const shadow = pill.attachShadow({ mode: "open" });
shadow.innerHTML = SHADOW_HTML;
statusEl = shadow.querySelector("b");
spinnerEl = shadow.querySelector(".spinner");
shadow.querySelector("button").onclick = hideStatus;
}
function showStatus(label, isError) {
if (!enabled) return;
if (!document.body) {
document.addEventListener(
"DOMContentLoaded",
() => showStatus(label, isError),
{ once: true }
);
return;
}
if (!pill) createPill();
statusEl.textContent = label;
statusEl.className = isError ? "error" : "busy";
spinnerEl.style.display = isError ? "none" : "";
if (!pill.isConnected) document.body.append(pill);
}
function hideStatus() {
pill?.remove();
}
const MESSAGES = {
"reload": () => window.location.reload(),
"rebuilding": () => showStatus("rebuilding", false),
"build-failed": () => showStatus("build failed", true),
"app-exited": () => showStatus("app exited", true),
};
function connect() {
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => MESSAGES[e.data]?.();
ws.onclose = reconnect;
}
function reconnect() {
setTimeout(() => {
const probe = new WebSocket(wsUrl);
probe.onopen = () => {
probe.close();
window.location.reload();
};
probe.onerror = () => setTimeout(connect, 1000);
}, 500);
}
connect();
})();