theseus/error.html
Local Dev ffc4d4d79e Theseus 0.3.123: error page's Did-you-mean row gains a Search chip
The "Did you mean" row on name-unreachable / name-not-registered used to
show only URL suggestions (BCNR warm-index matches + ICANN TLD swaps).
It now also carries a trailing "🔎 Search '<host>'" chip that defers to
the user's default search engine, so a DNS failure where none of the URL
alternatives look right still gives a one-click recovery path.

When no URL alternative surfaces at all — just the search chip is enough
on its own — the row's label softens from "Did you mean" to "Not sure
what you were after?" so a bare search chip reads naturally.
2026-10-27 20:55:11 +02:00

332 lines
16 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;
/* Darker acid for light backgrounds — ~5.5:1 on white. */
--acid: #0AC18E; }
}
* { 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 rgb(from var(--acid) r g b / .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: rgb(from var(--acid) r g b / .55); filter: brightness(1.05); }
.suggest a.search { font-family: inherit; color: var(--ink); }
.suggest a.search:hover { color: var(--acid); }
.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: rgb(from var(--acid) r g b / .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"],
},
"cf-blocked": {
icon: "⛔", iconCls: "warn",
title: "This site blocks Electron browsers",
sub: host ? (host + " returned 503 — Cloudflare Bot Fight Mode") : "Cloudflare Bot Fight Mode",
body: (b) => {
b.append("The server at ");
b.append(hostSpan(host));
b.append(" is behind Cloudflare's Bot Fight Mode. Cloudflare fingerprints the browser's TLS handshake below HTTP and refuses Electron-based browsers like Theseus, no matter what user-agent is sent.");
b.append(document.createElement("br"));
b.append(document.createElement("br"));
const mirror = q.get("mirror");
if (mirror) {
b.append("A ");
const s = el("b", null, ".bch mirror");
b.append(s);
b.append(" is registered on chain for this site: ");
const a = el("a", "host", mirror);
a.href = "#";
a.style.cursor = "pointer";
a.addEventListener("click", (ev) => {
ev.preventDefault();
// Preserve the original path/search/hash so /faq on the upstream
// becomes /faq on the mirror, not the mirror's root.
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
window.errorpage.retry("https://" + mirror + origPath);
});
b.append(a);
b.append(". Theseus proxies the request through Node's HTTP stack, which Cloudflare doesn't block — the page will load.");
} else {
b.append("No .bch mirror is registered for this site yet. See ");
const a = el("a", "host", "silentmode.st/mirrors");
a.href = "#";
a.style.cursor = "pointer";
a.addEventListener("click", (ev) => {
ev.preventDefault();
window.errorpage.openExternal("https://silentmode.st/mirrors/");
});
b.append(a);
b.append(" — it explains why this happens and how to mint one.");
}
},
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" — two sources, merged into one row, plus a search chip:
// • BCNR warm shared index (Levenshtein ≤ 2, same TLD) — for a BCDN name
// typo like "games.x" → "game.x". Local index; works offline.
// • ICANN alternative-TLD swaps + history base-name matches — a DNS
// failure on my.mynymbox.com surfaces my.mynymbox.io, .net, .org, …,
// and any related host the user has visited. Local too.
// • A trailing "Search '<host>'" chip that defers to the default
// search engine when none of the URL alternatives looks right.
// Clicking a URL chip retries at that host (keeps the original path);
// clicking the search chip opens a search for the host text.
if ((kind === "name-not-registered" || kind === "name-unreachable") && host) {
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
const sources = [
window.errorpage?.bnsSimilar ? window.errorpage.bnsSimilar(host) : Promise.resolve([]),
window.errorpage?.icannAlternatives ? window.errorpage.icannAlternatives(host) : Promise.resolve([]),
];
Promise.all(sources).then(([bns, icann]) => {
const seen = new Set();
const matches = [];
for (const name of [...(bns || []), ...(icann || [])]) {
if (!name || seen.has(name)) continue;
seen.add(name); matches.push(name);
if (matches.length >= 4) break;
}
const row = $("suggestRow");
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);
}
// Search chip. Shown whenever we have a host, even when no URL match
// surfaced — "search for it on your default engine" is the universal
// fallback for a DNS miss.
const s = el("a", "search", "🔎 Search '" + host + "'");
s.href = "#";
s.title = "Open your default search engine with this as the query";
s.addEventListener("click", (ev) => {
ev.preventDefault();
window.errorpage.searchFor(host);
});
row.appendChild(s);
// Replace the row's "Did you mean" label with a softer one when the
// only chip is the search fallback — nothing to actually suggest.
if (!matches.length) {
const lbl = $("suggest").querySelector(".lbl");
if (lbl) lbl.textContent = "Not sure what you were after?";
}
$("suggest").hidden = false;
}).catch(() => {});
}
</script>
</body>
</html>