theseus/error.html
Local Dev 7aae2b2f3b feat(theseus/error): offline banner + BCNR did-you-mean on typos
Two problems bundled into the branded error page:

1) "fetch failed" on no network — the tab kept falling through to
   name-not-registered (and other "we asked and got nothing" verdicts)
   because a totally offline browser can't reach the beacon to know.
   Add a first-class `offline` kind that runs before every other
   verdict: navigator.onLine === false wins; the page says "You appear
   to be offline" and keeps the address so the user can Retry after
   reconnecting.

2) Typos on BCNR names (games.x when they meant game.x) — the page
   only offered Search / Register. Now the page asks main for the top
   near-matches from the warm sharedIndex (Levenshtein ≤ 2, same TLD)
   over an origin-gated `error-bns-similar` IPC. Up to three matches
   render as chip-links; clicking one retries at that host, preserving
   the original path. Works offline too — the index is local.

Origin-gate on the IPC uses the existing isErrorPageSender check, and
error-preload.js exposes only the invoke — no arbitrary index access.
2026-09-04 20:21:30 +02:00

251 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Load error</title>
<style>
:root { color-scheme: light dark;
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--warn:#ffc75f; --err:#f6768a; --info:#4b7bec; }
@media (prefers-color-scheme: light) {
:root { --bg:#f6f8fb; --panel:#ffffff; --panel2:#f0f4f9; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
}
* { box-sizing: border-box }
html, body { margin: 0; height: 100% }
body {
background: radial-gradient(1100px 560px at 50% -12%, #16202e, var(--bg));
color: var(--ink); font: 15px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: grid; place-items: center; padding: 2rem 1.2rem;
}
.card { max-width: 640px; width: 100%; background: var(--panel); border: 1px solid var(--line);
border-radius: 14px; padding: 2rem 2.2rem 1.7rem; }
.head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 1rem; }
.icon { width: 42px; height: 42px; flex: none; border-radius: 10px; display: grid; place-items: center;
font-size: 22px; background: rgba(246,118,138,.10); }
.icon.warn { background: rgba(255,199,95,.10) }
.icon.info { background: rgba(75,123,236,.10) }
h1 { font-size: 1.15rem; font-weight: 600; margin: .1rem 0 .1rem; letter-spacing: .1px }
.sub { color: var(--mut); font-size: 13.5px; margin: 0; }
.body { color: var(--ink); font-size: 14px; line-height: 1.62; margin: 1rem 0 .6rem; }
.body b { color: var(--acid); }
.body .host { font-family: ui-monospace, monospace; background: var(--panel2); padding: 1px 6px;
border-radius: 5px; color: var(--acid); word-break: break-all; }
.tried { margin: 1rem 0 0; padding: 12px 14px; background: var(--panel2);
border: 1px dashed var(--line); border-radius: 10px; font-size: 12.5px; color: var(--mut); }
.tried .t { display: flex; gap: 8px; align-items: center; padding: 3px 0; }
.tried .t .m { width: 12px; text-align: center; color: var(--err); }
.tried .t .m.ok { color: var(--acid) }
.suggest { margin: 1rem 0 0; padding: 12px 14px; background: var(--panel2);
border: 1px solid rgba(214,255,61,.25); border-radius: 10px; font-size: 13px; color: var(--ink); }
.suggest .lbl { color: var(--mut); font-size: 12px; margin-bottom: 6px; }
.suggest .row { display: flex; flex-wrap: wrap; gap: 6px 8px; }
.suggest a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px;
background: var(--panel); border: 1px solid var(--line); color: var(--acid);
font-family: ui-monospace, monospace; font-size: 12.5px; text-decoration: none; cursor: pointer; }
.suggest a:hover { border-color: rgba(214,255,61,.55); filter: brightness(1.05); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 1.3rem; }
.btn { font-family: inherit; font-size: 13.5px; padding: 9px 16px; border-radius: 9px;
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
cursor: pointer; transition: filter .1s, background .1s, border-color .1s; }
.btn:hover { background: var(--panel); border-color: rgba(214,255,61,.35); filter: brightness(1.05); }
.btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 600; }
.btn.primary:hover { background: var(--acid); filter: brightness(1.08); }
.details { margin-top: 1.2rem; padding-top: .9rem; border-top: 1px solid var(--line);
font: 11.5px/1.55 ui-monospace, monospace; color: var(--dim); }
.details .row { display: grid; grid-template-columns: 78px 1fr; gap: 8px; }
.details .row b { color: var(--mut); font-weight: 500; }
</style>
</head>
<body>
<div class="card">
<div class="head">
<div class="icon" id="icon"></div>
<div>
<h1 id="title">Couldn't load this page</h1>
<p class="sub" id="sub">The tab kept its address so you can edit and try again.</p>
</div>
</div>
<div class="body" id="body"></div>
<div class="tried" id="tried" hidden>
<div class="t"><span class="m"></span> <span>Bitcoin Cash Domain Names (BCDN) — no record on chain</span></div>
<div class="t"><span class="m"></span> <span>Clearnet DNS — no host at this name either</span></div>
</div>
<div class="suggest" id="suggest" hidden>
<div class="lbl">Did you mean</div>
<div class="row" id="suggestRow"></div>
</div>
<div class="actions" id="actions"></div>
<div class="details">
<div class="row"><b>URL</b><span id="dUrl"></span></div>
<div class="row"><b>Error</b><span id="dErr"></span></div>
</div>
</div>
<script>
const q = new URLSearchParams(location.search);
let kind = q.get("kind") || "generic"; // offline | name-not-registered | name-unreachable | unreachable | tls | generic
const host = q.get("host") || "";
const url = q.get("url") || "";
const code = q.get("code") || "";
const desc = q.get("desc") || "";
// Offline is a special case: if the browser process itself has no
// network, every load will fail here and we'd otherwise mis-report
// BCNR-eligible names as "not registered" (we couldn't reach the
// beacon to know). Detect it up front so the user sees a clear
// "you're offline" instead of a false verdict about the name.
if (typeof navigator !== "undefined" && navigator.onLine === false) kind = "offline";
const $ = (id) => document.getElementById(id);
const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
const hostSpan = (h) => { const s = el("span", "host"); s.textContent = h || "(no host)"; return s; };
document.title = host ? "Error — " + host : "Load error";
$("dUrl").textContent = url || "(none)";
$("dErr").textContent = (code ? code + " " : "") + (desc || "");
// Kind → title, subtitle, body, actions.
const configs = {
"offline": {
icon: "⌀", iconCls: "info",
title: "You appear to be offline",
sub: "No network connection detected",
body: (b) => {
b.append("This browser can't reach the network. Wi-Fi may be off, the cable may be unplugged, or a VPN / proxy may be dropping traffic. ");
b.append("Reconnect and press ");
const kbd = document.createElement("b"); kbd.textContent = "Retry"; b.append(kbd);
b.append(".");
if (host) {
b.append(document.createElement("br"));
b.append(document.createElement("br"));
b.append("Trying to open ");
b.append(hostSpan(host));
b.append(" — the address is kept so you can retry once you're back online.");
}
},
actions: ["retry", "home"],
},
"name-not-registered": {
icon: "?", iconCls: "warn",
title: "That name isn't registered",
sub: "No BCDN record on chain, and no clearnet host at " + host,
body: (b) => {
b.append("Nothing answered when we asked for ");
b.append(hostSpan(host));
b.append(". Bitcoin Cash Domain Names had no record for it, and ordinary DNS has no host at that name either.");
b.append(document.createElement("br"));
b.append(document.createElement("br"));
b.append("If it's supposed to be yours, register it on the chain. If you were guessing, try a search instead.");
},
tried: true,
actions: ["register", "search", "retry", "home"],
},
"name-unreachable": {
icon: "⌘", iconCls: "warn",
title: "This name didn't resolve",
sub: "Your DNS resolver didn't know " + host,
body: (b) => {
b.append("We couldn't turn ");
b.append(hostSpan(host));
b.append(" into an address. Your network's resolver may be blocking it, offline, or the name simply doesn't exist. BCDN was tried first — if the host is a BCDN name, it's not registered on the chain either.");
},
actions: ["retry", "search", "register", "home"],
},
"unreachable": {
icon: "✕", iconCls: "",
title: "This site is unreachable",
sub: host ? ("Couldn't connect to " + host) : "The server didn't respond",
body: (b) => {
b.append("The name resolved, but the server at ");
b.append(hostSpan(host));
b.append(" didn't answer. It may be down, blocking your network, or reachable only through Tor.");
},
actions: ["retry", "tor-hint", "home"],
},
"tls": {
icon: "!", iconCls: "warn",
title: "The site's certificate isn't trustworthy",
sub: host ? ("HTTPS handshake with " + host + " failed") : "Certificate error",
body: (b) => {
b.append("The server at ");
b.append(hostSpan(host));
b.append(" presented a certificate the browser couldn't verify. This can mean the certificate has expired, doesn't match the hostname, was self-signed, or that someone is intercepting the connection.");
},
actions: ["retry", "home"],
},
"generic": {
icon: "✕", iconCls: "",
title: "Couldn't load this page",
sub: host ? ("Something went wrong loading " + host) : "Load failed",
body: (b) => {
b.append("Something interrupted the load of ");
b.append(hostSpan(host));
b.append(". Details are at the bottom of this page.");
},
actions: ["retry", "home"],
},
};
const c = configs[kind] || configs.generic;
$("icon").textContent = c.icon;
$("icon").className = "icon" + (c.iconCls ? " " + c.iconCls : "");
$("title").textContent = c.title;
$("sub").textContent = c.sub;
c.body($("body"));
if (c.tried) $("tried").hidden = false;
// Actions — order matters (first is primary). "register" only when we can
// construct a Sirius URL (need a host).
const btnDefs = {
retry: { label: "Retry", primary: true, fn: () => window.errorpage.retry(url) },
search: { label: "Search for '" + host + "'", primary: false, fn: () => window.errorpage.searchFor(host) },
register: { label: "Register on Sirius", primary: false, fn: () => window.errorpage.registerOnSirius(host) },
home: { label: "Home", primary: false, fn: () => window.errorpage.goHome() },
"tor-hint": {
label: "Open Tor guide", primary: false,
fn: () => window.errorpage.openExternal("https://silentmode.st/tools/#tor"),
},
};
const actionsBox = $("actions");
let primaryUsed = false;
for (const a of c.actions) {
const def = btnDefs[a]; if (!def) continue;
if (a === "register" && !host) continue;
if (a === "search" && !host) continue;
const b = el("button", "btn" + (def.primary && !primaryUsed ? " primary" : ""), def.label);
if (def.primary) primaryUsed = true;
b.addEventListener("click", def.fn);
actionsBox.appendChild(b);
}
// "Did you mean" — for BCNR-name failures, ask main for near-matches in
// the warm shared index (Levenshtein ≤ 2, same TLD). No network — the
// index is local, so this works offline too. Clicking a suggestion
// retries the load at that host, preserving the original path if any.
if ((kind === "name-not-registered" || kind === "name-unreachable") && host && window.errorpage?.bnsSimilar) {
window.errorpage.bnsSimilar(host).then((matches) => {
if (!Array.isArray(matches) || !matches.length) return;
const row = $("suggestRow");
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
for (const name of matches) {
const a = el("a", null, name);
a.href = "#";
a.addEventListener("click", (ev) => {
ev.preventDefault();
window.errorpage.retry("https://" + name + origPath);
});
row.appendChild(a);
}
$("suggest").hidden = false;
}).catch(() => {});
}
</script>
</body>
</html>