<!DOCTYPE html>
<html lang="{{ lang }}" dir="{{ dir }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<style>
:root {
--bg: #0e0e1e;
--fg: #d4d4e8;
--card-bg: rgba(255, 255, 255, 0.04);
--card-border: rgba(255, 255, 255, 0.08);
--heading: #ffffff;
--tagline: rgba(212, 212, 232, 0.6);
--label: rgba(212, 212, 232, 0.5);
--value: #d4d4e8;
--divider: rgba(255, 255, 255, 0.05);
--retry: rgba(212, 212, 232, 0.4);
--footer: rgba(212, 212, 232, 0.25);
--footer-hover: rgba(212, 212, 232, 0.55);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f5f0;
--fg: #333340;
--card-bg: rgba(255, 255, 255, 0.8);
--card-border: rgba(0, 0, 0, 0.08);
--heading: #1a1a2e;
--tagline: rgba(30, 30, 50, 0.5);
--label: rgba(30, 30, 50, 0.45);
--value: #333340;
--divider: rgba(0, 0, 0, 0.05);
--retry: rgba(30, 30, 50, 0.35);
--footer: rgba(30, 30, 50, 0.2);
--footer-hover: rgba(30, 30, 50, 0.5);
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.6;
padding: 2rem;
}
.card {
max-width: 480px;
width: 100%;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
padding: 2.5rem 2rem;
text-align: center;
backdrop-filter: blur(12px);
animation: accordionOpen 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes accordionOpen {
0% {
clip-path: inset(32% 0 42% 0);
opacity: 0.6;
}
100% {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
.logo {
width: 64px;
height: 64px;
margin: 0 auto 1rem;
opacity: 0.8;
display: block;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--heading);
}
.tagline {
font-size: 0.875rem;
color: var(--tagline);
margin-bottom: 1.5rem;
}
.status {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 20px;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 1.25rem;
text-align: center;
}
.status--ready {
background: rgba(46, 213, 115, 0.12);
color: #2ed573;
}
.status--working {
background: rgba(255, 165, 0, 0.12);
color: #ffa500;
animation: pulse 2s ease-in-out infinite;
}
.status--landing {
background: rgba(255, 165, 0, 0.12);
color: #ffa500;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.info-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--divider);
font-size: 0.8125rem;
gap: 12px;
}
.info-row:last-of-type { border-bottom: none; }
.info-label {
color: var(--label);
flex-shrink: 0;
min-width: 70px;
text-align: left;
}
.info-value {
color: var(--value);
font-weight: 500;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
text-align: right;
word-break: break-all;
}
.watch-list {
display: flex;
flex-direction: column;
gap: 4px;
text-align: right;
}
.watch-item {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.75rem;
color: var(--value);
max-width: 260px;
position: relative;
cursor: pointer;
}
.watch-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.watch-item:hover .tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(-4px);
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(0);
background: var(--heading);
color: var(--bg);
padding: 8px 14px;
border-radius: 8px;
font-size: 0.7rem;
white-space: normal;
word-break: break-all;
min-width: 280px;
max-width: 420px;
z-index: 10;
pointer-events: auto;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
line-height: 1.6;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--heading);
}
.tooltip-path {
display: block;
word-break: break-all;
line-height: 1.5;
}
.tooltip-hint {
display: block;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255,255,255,0.15);
font-style: italic;
font-size: 0.65rem;
opacity: 0.7;
cursor: pointer;
}
.toast {
position: fixed;
top: -50px;
left: 50%;
transform: translateX(-50%);
background: var(--heading);
color: var(--bg);
padding: 10px 20px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
z-index: 99999;
transition: top 0.3s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
pointer-events: none;
white-space: nowrap;
}
.toast.show {
top: 20px;
}
.portal-tooltip {
position: fixed;
background: var(--heading, #fff);
color: var(--bg, #000);
padding: 8px 14px;
border-radius: 8px;
font-size: 0.7rem;
line-height: 1.5;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
text-align: center;
max-width: min(420px, 80vw);
word-break: break-all;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
pointer-events: auto;
z-index: 999999;
}
.portal-tooltip .tooltip-hint {
display: block;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255,255,255,0.15);
font-style: italic;
font-size: 0.65rem;
opacity: 0.8;
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.retry-hint {
margin-top: 1.25rem;
font-size: 0.75rem;
color: var(--retry);
line-height: 1.8;
}
.cancel-row {
margin-top: 0.5rem;
text-align: center;
}
.countdown {
font-weight: 700;
font-size: 1.5em;
color: var(--countdown-color, #2ed573);
margin: 0 8px;
}
.countdown-unit {
font-size: 0.75rem;
font-weight: 400;
color: var(--retry);
margin-left: 2px;
margin-right: 2px;
}
.cancel-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border: 1px solid var(--card-border);
border-radius: 6px;
background: var(--card-bg);
color: var(--label);
font-size: 0.6875rem;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease;
font-family: inherit;
vertical-align: middle;
}
.cancel-btn:hover {
border-color: var(--divider);
color: var(--fg);
}
.cancel-btn--refresh {
color: var(--retry);
border-color: rgba(255, 165, 0, 0.25);
}
.cancel-btn--refresh:hover {
background: rgba(255, 165, 0, 0.08);
border-color: rgba(255, 165, 0, 0.45);
color: #ffa500;
}
.footer {
margin-top: 1.5rem;
font-size: 0.6875rem;
color: var(--footer);
}
.footer a {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--divider);
text-underline-offset: 3px;
transition: color 0.2s ease, text-decoration-color 0.2s ease;
}
.footer a:hover {
color: var(--footer-hover);
text-decoration-color: var(--footer-hover);
}
.version-line {
margin-top: 0.5rem;
font-size: 0.6875rem;
color: var(--footer);
text-align: center;
}
.binaries {
margin-top: 1rem;
text-align: left;
}
.binaries-title {
font-size: 0.75rem;
color: var(--label);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.binary-row {
display: grid;
grid-template-columns: minmax(70px, auto) 1fr;
gap: 8px 12px;
padding: 6px 0;
border-bottom: 1px solid var(--divider);
font-size: 0.75rem;
align-items: baseline;
}
.binary-row:last-child { border-bottom: none; }
.binary-name {
font-weight: 600;
color: var(--value);
text-align: left;
cursor: pointer;
position: relative;
}
.vtty-icon {
display: inline-flex;
align-items: center;
margin-left: 6px;
color: var(--accent);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
vertical-align: middle;
}
.vtty-icon:hover { opacity: 0.9; }
.vtty-screen { white-space: pre-wrap !important; }
@keyframes vttyDots {
0%, 20% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.vtty-loading {
display: flex; align-items: center; gap: 1px;
animation: vttyDots 1.5s infinite;
}
.vtty-tooltip {
position: fixed;
z-index: 999999;
background: #0c0c18;
border: 1px solid #7c8aff;
border-radius: 6px;
padding: 8px 10px;
font-family: monospace;
font-size: 12px;
line-height: 1.3;
color: #a0a0c0;
max-width: 640px;
max-height: 400px;
overflow: auto;
white-space: pre;
box-shadow: 0 0 16px rgba(124,138,255,0.2);
}
.binary-name-full,
.binary-time-full,
.binary-hash-full {
display: none !important;
}
.watch-item > .tooltip {
display: none !important;
}
.binary-detail {
color: var(--label);
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 0.6875rem;
text-align: right;
}
.binary-time {
cursor: pointer;
}
.binary-hash {
color: rgba(46, 213, 115, 0.7);
cursor: pointer;
position: relative;
}
.binary-hash-short {
display: inline;
}
</style>
</head>
<body>
<div class="card">
{% if logo_base64 %}
<img class="logo" src="data:image/webp;base64,{{ logo_base64 }}" alt="Malkuth" />
{% endif %}
<h1>{{ heading }}</h1>
<p class="tagline">{{ tagline }}</p>
<div class="status {% if landing %}status--landing{% elif ready %}status--ready{% else %}status--working{% endif %}">
{{ status_text }}
</div>
{% if task_label %}
<div class="info-row">
<span class="info-label">{{ task_label }}</span>
<span class="info-value">{{ task }}</span>
</div>
{% endif %}
{% if proxy_endpoint %}
<div class="info-row">
<span class="info-label">{{ proxy_label }}</span>
<span class="info-value">{{ proxy_endpoint }}</span>
</div>
{% endif %}
{% if watch_paths %}
<div class="info-row">
<span class="info-label">{{ watch_label }}</span>
<div class="watch-list">
{% for p in watch_paths %}
<span class="watch-item" data-path="{{ p }}"><span class="watch-text">{{ p }}</span><span class="tooltip"><span class="tooltip-path">{{ p }}</span><span class="tooltip-hint" data-copy-hint="{{ copy_hint }}"></span></span></span>
{% endfor %}
</div>
</div>
{% endif %}
{% if landing and binaries %}
<div class="binaries">
<div class="binaries-title">{{ binaries_title }}</div>
{% for b in binaries %}
<div class="binary-row">
<span class="binary-name" data-copy="{{ b.name }}"><span>{{ b.name }}</span><span class="vtty-icon" data-vtty-name="{{ b.name }}"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span class="binary-name-full">{{ b.name }}<br><span class="tooltip-hint" data-copy-hint="{{ copy_hint }}"></span></span></span>
<span class="binary-detail"><span class="binary-time" data-copy="{{ b.compile_time }}">{{ b.compile_time }}<span class="binary-time-full">{{ b.compile_time }}<br><span class="tooltip-hint" data-copy-hint="{{ copy_hint }}"></span></span></span> · <span class="binary-hash" data-copy="{{ b.hash }}"><span class="binary-hash-short">{{ b.hash_short }}</span><span class="binary-hash-full">{{ b.hash }}<br><span class="tooltip-hint" data-copy-hint="{{ copy_hint }}"></span></span></span></span>
</div>
{% endfor %}
</div>
{% endif %}
<p class="retry-hint" id="retryHint">{% if landing %}{{ redirect_before }}{% else %}{{ retry_before }}{% endif %}<span class="countdown" id="countdown" style="--countdown-color: #ffa500">{% if landing %}3{% else %}10{% endif %}</span><span class="countdown-unit" id="countdownUnit">{% if landing %}{{ redirect_after }}{% else %} {{ retry_unit }}{{ retry_after }}{% endif %}</span></p>
<div class="cancel-row"><button class="cancel-btn" id="cancelBtn">{{ cancel_label }}</button></div>
<p class="footer">{{ footer_prefix }}<a href="https://github.com/celestia-island/malkuth" target="_blank" rel="noopener">Malkuth</a>{{ footer_suffix }}</p>
{% if install_method %}
<p class="version-line">v{{ version }} ({{ install_label }} {{ install_method }})</p>
{% else %}
<p class="version-line">v{{ version }}</p>
{% endif %}
</div>
<script>
(function(){
var probeTime = 2000;
var timer = null;
var countdownTimer = null;
var countdownRemaining = 3;
var statusEl = document.querySelector(".status");
var hintEl = document.getElementById("retryHint");
var btn = document.getElementById("cancelBtn");
var countdownSpan = document.getElementById("countdown");
var countdownUnitEl = document.getElementById("countdownUnit");
var isLanding = {% if landing %}true{% else %}false{% endif %};
var initState = "{% if initial_state %}{{ initial_state }}{% else %}landing{% endif %}";
// Remove clip-path after accordion animation so tooltips aren't clipped
var card = document.querySelector(".card");
if (card) {
card.addEventListener("animationend", function() {
card.style.clipPath = "none";
card.style.overflow = "visible";
}, { once: true });
}
function startCountdown() {
function tick() {
if (countdownSpan) countdownSpan.textContent = countdownRemaining;
if (countdownRemaining <= 0) {
document.cookie = "__malkuth_nonce=1; max-age=1800; path=/";
location.reload();
return;
}
countdownRemaining--;
countdownTimer = setTimeout(tick, 1000);
}
tick();
}
function poll() {
fetch("/", { headers: { "X-Malkuth-Probe": "1" } })
.then(function(r) { return r.json(); })
.then(function(data) {
if (statusEl && data.message) {
statusEl.textContent = data.message;
}
if (data.state === "ready") {
document.cookie = "__malkuth_nonce=1; max-age=1800; path=/";
location.reload();
return;
}
if (data.state === "offline") {
clearInterval(timer);
if (hintEl) { hintEl.innerHTML = ""; }
if (btn) { btn.style.display = "inline-block"; btn.textContent = "{{ refresh_label }}"; }
if (countdownSpan) { countdownSpan.textContent = "\u2014"; }
return;
}
if (data.state === "building") {
if (statusEl && data.message) {
statusEl.textContent = data.message;
}
if (hintEl && data.progress) {
hintEl.innerHTML = "<span class=\"build-log\">" + data.progress + "</span>";
} else if (hintEl) {
hintEl.innerHTML = "";
}
if (countdownSpan) {
countdownSpan.textContent = data.progress || "\u2014";
}
if (btn) { btn.style.display = "inline-block"; btn.textContent = "{{ refresh_label }}"; }
}
if (data.state === "starting") {
if (btn) { btn.style.display = "inline-block"; btn.textContent = "{{ refresh_label }}"; }
}
})
.catch(function() {});
}
if (initState === "ready") {
startCountdown();
timer = setInterval(poll, probeTime);
} else if (initState === "building") {
document.cookie = "__malkuth_nonce=1; max-age=1800; path=/";
if (hintEl) { hintEl.innerHTML = ""; }
if (countdownSpan) { countdownSpan.textContent = "\u2014"; }
if (countdownUnitEl) { countdownUnitEl.textContent = ""; }
if (btn) { btn.style.display = "inline-block"; btn.textContent = "{{ refresh_label }}"; }
poll();
timer = setInterval(poll, probeTime);
} else if (initState === "offline") {
if (countdownSpan) { countdownSpan.textContent = "\u2014"; }
if (btn) { btn.style.display = "inline-block"; btn.textContent = "{{ refresh_label }}"; }
}
if (btn) {
btn.onclick = function() {
document.cookie = "__malkuth_nonce=1; max-age=1800; path=/";
location.reload();
};
}
// ── Tooltip portal ─────────────────────────────────────
var portal = null;
function getPortal() {
if (!portal) { portal = document.createElement("div"); document.body.appendChild(portal); }
return portal;
}
function showPortalTooltip(target, contentHTML) {
hidePortalTooltip();
var p = getPortal();
var rect = target.getBoundingClientRect();
var tip = document.createElement("div");
tip.className = "portal-tooltip";
tip.innerHTML = contentHTML;
var hint = tip.querySelector(".tooltip-hint");
if (hint) {
hint.style.cursor = "pointer";
hint.addEventListener("click", function(ev) { ev.stopPropagation(); doCopy(target.getAttribute("data-copy") || ""); });
}
p.appendChild(tip);
var tw = tip.offsetWidth;
var th = tip.offsetHeight;
var left = rect.left + rect.width / 2 - tw / 2;
if (left < 8) left = 8;
if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;
tip.style.left = Math.max(8, left) + "px";
tip.style.top = (rect.top + window.scrollY - th - 8) + "px";
var arr = document.createElement("span");
arr.cssText = "position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);border:6px solid transparent;border-top-color:" + getComputedStyle(tip).backgroundColor;
tip.appendChild(arr);
var lt = null;
function defer() { lt = setTimeout(hidePortalTooltip, 150); }
function cancel() { clearTimeout(lt); }
target.addEventListener("mouseleave", defer, { once: true });
tip.addEventListener("mouseenter", cancel);
tip.addEventListener("mouseleave", defer);
}
function hidePortalTooltip() { var p = getPortal(); while (p.lastChild) p.removeChild(p.lastChild); }
// Attach portal tooltips to binary fields
[".binary-name", ".binary-time", ".binary-hash"].forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(el) {
var full = el.querySelector("[class$='-full']");
if (!full) return;
el.addEventListener("mouseenter", function(ev) { showPortalTooltip(el, full.innerHTML); ev.stopPropagation(); });
});
});
// Watch-item tooltip also via portal
document.querySelectorAll(".watch-item").forEach(function(el) {
var tt = el.querySelector(".tooltip");
if (!tt) return;
el.addEventListener("mouseenter", function(ev) {
showPortalTooltip(el, tt.querySelector(".tooltip-path").outerHTML + "<br>" + tt.querySelector(".tooltip-hint").outerHTML);
ev.stopPropagation();
});
});
// Extend binary-name tooltip to include VTTY content
document.querySelectorAll(".binary-name").forEach(function(el) {
var origEnter = el.onmouseenter;
var icon = el.querySelector(".vtty-icon");
var name = icon ? icon.getAttribute("data-vtty-name") : "";
el._vttyName = name;
// Override the showPortalTooltip for binary-name to include VTTY
var origShow = showPortalTooltip;
el.addEventListener("mouseenter", function(ev) {
if (!name) return;
var content = "<div style='text-align:center;color:var(--accent);font-weight:600;margin-bottom:6px'>" + name + "</div>";
content += "<div class='vtty-screen' id='vtty-" + name + "' style='width:720px;height:384px;background:#0c0c18;border:1px solid var(--accent);border-radius:4px;padding:8px;font-family:monospace;font-size:11px;line-height:1.3;color:#a0a0c0;overflow:hidden;white-space:pre-wrap;margin-bottom:4px;display:flex;align-items:center;justify-content:center'>";
content += "<span class='vtty-loading' style='color:var(--accent);opacity:0.6;font-size:13px'>Loading";
content += "<span class='vtty-dots'>.</span>";
content += "</span></div>";
content += "<div class='tooltip-hint' style='text-align:center'>Click to copy</div>";
showPortalTooltip(el, content);
// Start polling
(function pollVtty() {
if (!el._vttyPolling) return;
var screen = document.getElementById("vtty-" + name);
if (!screen) return;
fetch("/", { headers: { "X-Malkuth-Probe": "1" } })
.then(function(r) { return r.json(); })
.then(function(d) {
var lines = [];
if (d.vttys) {
var v = d.vttys.find(function(x) { return x.name === name; });
if (v && v.log) lines = v.log;
}
if (lines.length > 0) {
screen.style.display = "block";
screen.style.textAlign = "left";
screen.textContent = lines.join("\n");
}
})
.catch(function() {});
setTimeout(pollVtty, 2000);
})();
el._vttyPolling = true;
});
el.addEventListener("mouseleave", function() {
el._vttyPolling = false;
hidePortalTooltip();
});
});
// ── Copy on click ────────────────────────────────────────
document.querySelectorAll(".tooltip-hint").forEach(function(el) {
el.textContent = el.getAttribute("data-copy-hint") || "Click to copy";
});
function doCopy(text) {
var ok = false;
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() { ok = true; toast("{{ copied_msg }}"); }, function() { fallback(); });
} else { fallback(); }
function fallback() {
var ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", "");
ta.style.cssText = "position:fixed;left:-9999px;top:-9999px";
document.body.appendChild(ta); ta.focus(); ta.select();
try { ok = document.execCommand("copy"); } catch(e) {}
document.body.removeChild(ta);
toast(ok ? "{{ copied_msg }}" : "{{ copy_fail_msg }}");
}
}
[".watch-item", ".binary-name", ".binary-time", ".binary-hash"].forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(el) {
el.addEventListener("click", function(ev) { ev.preventDefault(); doCopy(el.getAttribute("data-copy") || ""); });
});
});
function toast(msg) {
var t = document.getElementById("globalToast");
if (!t) { t = document.createElement("div"); t.id = "globalToast"; t.className = "toast"; document.body.appendChild(t); }
t.textContent = msg; t.classList.remove("show"); void t.offsetWidth; t.classList.add("show");
clearTimeout(t._timer); t._timer = setTimeout(function() { t.classList.remove("show"); }, 2000);
}
})();
</script>
</body>
</html>