sites: scroll-spy tracks scroll position, fires the last section on bottom

The IntersectionObserver-based spy only fired when a section entered a
narrow band 40–45% down the viewport. Short sections at the tail of a
page — Operator key and Roadmap on theseus.x/extensions, whatever came
last on the other pages — never made it into that band because the
page runs out of scroll before their top crosses 40%, so their sidebar
links stayed dim no matter how far the reader scrolled.

Rewrote the spy across every page with a sidebar (theseus.x home /
plug-ins / extensions and sirius.x home / tld / theseus / docs / admin
/ brand): it listens to window scroll, picks the last section whose
top has crossed 35% of the viewport, and — when the window is scrolled
to within 4px of the bottom — snaps to the final section regardless of
where its top is. Every link in the rail can now activate.

theseus.x/extensions also had the spy IIFE bundled with the manifest
loader in one <script> block near the middle of the page, so at run
time the parser hadn't yet reached the last three sections and only
Bundled/Community/Build were ever registered. Moved the spy to its own
<script> just before </body> so it sees every section that ships.
This commit is contained in:
Local Dev 2026-09-20 19:20:30 +02:00
parent 0cb4aec5df
commit f2c3480d43
20 changed files with 1820 additions and 1630 deletions

View file

@ -223,7 +223,7 @@
<footer id="site-footer"></footer>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script type="module">
@ -431,20 +431,31 @@ $("mint-btn").addEventListener("click", async () => {
<script defer src="../js/theme.js?v=20260920compact"></script>
<script defer src="../js/profile-menu.js?v=20260920bits"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
// Uses scroll-position tracking so short sections at the end of the page
// still activate their link when the reader scrolls to them.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const map = new Map();
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) map.set(el, a); });
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
const a = map.get(en.target); if (!a) return;
links.forEach((l) => l.classList.remove("active"));
a.classList.add("active");
});
}, { rootMargin: "-40% 0px -55% 0px", threshold: 0 });
map.forEach((_, el) => io.observe(el));
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>

View file

@ -121,7 +121,7 @@
.font-picker button.active{background:var(--acid);color:var(--bg);font-weight:600}
</style>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
</head>
<body>
@ -279,20 +279,31 @@
<script defer src="../js/theme.js?v=20260920compact"></script>
<script defer src="../js/profile-menu.js?v=20260920bits"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
// Uses scroll-position tracking so short sections at the end of the page
// still activate their link when the reader scrolls to them.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const map = new Map();
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) map.set(el, a); });
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
const a = map.get(en.target); if (!a) return;
links.forEach((l) => l.classList.remove("active"));
a.classList.add("active");
});
}, { rootMargin: "-40% 0px -55% 0px", threshold: 0 });
map.forEach((_, el) => io.observe(el));
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>

View file

@ -125,9 +125,9 @@
.font-picker button.active{background:var(--acid);color:var(--bg);font-weight:600}
</style>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script src="../js/i18n.js?v=20260920f"></script>
<script src="../js/i18n.js?v=20260920g"></script>
</head>
<body>
@ -651,20 +651,31 @@ process.exit(0);"</pre>
<script defer src="../js/theme.js?v=20260920compact"></script>
<script defer src="../js/profile-menu.js?v=20260920bits"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
// Uses scroll-position tracking so short sections at the end of the page
// still activate their link when the reader scrolls to them.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const map = new Map();
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) map.set(el, a); });
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
const a = map.get(en.target); if (!a) return;
links.forEach((l) => l.classList.remove("active"));
a.classList.add("active");
});
}, { rootMargin: "-40% 0px -55% 0px", threshold: 0 });
map.forEach((_, el) => io.observe(el));
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>

View file

@ -609,5 +609,9 @@
"where the page files live (the s3 record)": "wo die Seitendateien liegen (der s3-Eintrag)",
"Not hosted on Sia": "Nicht auf Sia gehostet",
"Sirius.X hosting — free, on Sia storage": "Sirius.X-Hosting — kostenlos, auf Sia-Speicher",
"Another Sia folder (advanced)": "Anderer Sia-Ordner (erweitert)"
"Another Sia folder (advanced)": "Anderer Sia-Ordner (erweitert)",
"Price is fixed in": "Preis festgelegt in",
"US dollars — the BCH amount follows the market": "US-Dollar — der BCH-Betrag folgt dem Markt",
"BCH — the dollar value follows the market": "BCH — der Dollarwert folgt dem Markt",
"Accept between": "Akzeptiere zwischen"
}

View file

@ -609,5 +609,9 @@
"where the page files live (the s3 record)": "πού βρίσκονται τα αρχεία της σελίδας (η εγγραφή s3)",
"Not hosted on Sia": "Δεν φιλοξενείται στο Sia",
"Sirius.X hosting — free, on Sia storage": "Φιλοξενία Sirius.X — δωρεάν, στον αποθηκευτικό χώρο Sia",
"Another Sia folder (advanced)": "Άλλος φάκελος Sia (για προχωρημένους)"
"Another Sia folder (advanced)": "Άλλος φάκελος Sia (για προχωρημένους)",
"Price is fixed in": "Η τιμή είναι σταθερή σε",
"US dollars — the BCH amount follows the market": "Δολάρια ΗΠΑ — το ποσό σε BCH ακολουθεί την αγορά",
"BCH — the dollar value follows the market": "BCH — η αξία σε δολάρια ακολουθεί την αγορά",
"Accept between": "Αποδοχή μεταξύ"
}

View file

@ -609,5 +609,9 @@
"where the page files live (the s3 record)": "dónde viven los archivos de la página (el registro s3)",
"Not hosted on Sia": "No alojado en Sia",
"Sirius.X hosting — free, on Sia storage": "Alojamiento Sirius.X — gratis, en almacenamiento Sia",
"Another Sia folder (advanced)": "Otra carpeta de Sia (avanzado)"
"Another Sia folder (advanced)": "Otra carpeta de Sia (avanzado)",
"Price is fixed in": "El precio se fija en",
"US dollars — the BCH amount follows the market": "Dólares — la cantidad en BCH sigue al mercado",
"BCH — the dollar value follows the market": "BCH — el valor en dólares sigue al mercado",
"Accept between": "Aceptar entre"
}

View file

@ -609,5 +609,9 @@
"where the page files live (the s3 record)": "où se trouvent les fichiers de la page (l'enregistrement s3)",
"Not hosted on Sia": "Pas hébergé sur Sia",
"Sirius.X hosting — free, on Sia storage": "Hébergement Sirius.X — gratuit, sur le stockage Sia",
"Another Sia folder (advanced)": "Un autre dossier Sia (avancé)"
"Another Sia folder (advanced)": "Un autre dossier Sia (avancé)",
"Price is fixed in": "Le prix est fixé en",
"US dollars — the BCH amount follows the market": "Dollars US — le montant en BCH suit le marché",
"BCH — the dollar value follows the market": "BCH — la valeur en dollars suit le marché",
"Accept between": "Accepter entre"
}

View file

@ -609,5 +609,9 @@
"where the page files live (the s3 record)": "onde ficam os ficheiros da página (o registo s3)",
"Not hosted on Sia": "Não alojado no Sia",
"Sirius.X hosting — free, on Sia storage": "Alojamento Sirius.X — grátis, no armazenamento Sia",
"Another Sia folder (advanced)": "Outra pasta Sia (avançado)"
"Another Sia folder (advanced)": "Outra pasta Sia (avançado)",
"Price is fixed in": "O preço é fixado em",
"US dollars — the BCH amount follows the market": "Dólares — o montante em BCH segue o mercado",
"BCH — the dollar value follows the market": "BCH — o valor em dólares segue o mercado",
"Accept between": "Aceitar entre"
}

View file

@ -609,5 +609,9 @@
"where the page files live (the s3 record)": "где лежат файлы страницы (запись s3)",
"Not hosted on Sia": "Не размещено на Sia",
"Sirius.X hosting — free, on Sia storage": "Хостинг Sirius.X — бесплатно, в хранилище Sia",
"Another Sia folder (advanced)": "Другая папка Sia (для опытных)"
"Another Sia folder (advanced)": "Другая папка Sia (для опытных)",
"Price is fixed in": "Цена зафиксирована в",
"US dollars — the BCH amount follows the market": "Доллары США — сумма в BCH следует за рынком",
"BCH — the dollar value follows the market": "BCH — долларовая стоимость следует за рынком",
"Accept between": "Принимать в диапазоне"
}

View file

@ -165,7 +165,7 @@
.verify dd{margin:2px 0 0;font-family:ui-monospace,monospace;font-size:12.5px;word-break:break-all;color:var(--ink)}
footer{border-top:1px solid var(--line);padding:2.4rem 0 3.5rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
</style>
<script src="./js/i18n.js?v=20260920f"></script>
<script src="./js/i18n.js?v=20260920g"></script>
</head>
<body>
@ -538,9 +538,9 @@ process.exit(0);"</pre>
The module injects its own modal into <body> and exposes
window.siriusRegisterName(fullName) for the search-result buttons. -->
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script type="module" src="./js/register-flow.js?v=20260920bits"></script>
<script type="module" src="./js/register-flow.js?v=20260920usd"></script>
<script src="./js/pricing.js?v=20260920bits"></script>
<script src="./js/session.js?v=20260917dash"></script>
@ -550,20 +550,30 @@ process.exit(0);"</pre>
<script defer src="./js/profile-menu.js?v=20260920bits"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
// Uses scroll-position tracking so short sections at the end of the page
// still activate their link when the reader scrolls to them.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const map = new Map();
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) map.set(el, a); });
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
const a = map.get(en.target); if (!a) return;
links.forEach((l) => l.classList.remove("active"));
a.classList.add("active");
});
}, { rootMargin: "-40% 0px -55% 0px", threshold: 0 });
map.forEach((_, el) => io.observe(el));
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>

View file

@ -10,7 +10,7 @@
// is the persistent device session (js/session.js): sign in once, come back
// without a prompt until you sign out.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917market";
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
const API = "https://silentmode.st";
const TLD_API = "https://navigate.st/api/tlds?include_hidden=1";
@ -685,14 +685,34 @@ async function signedHeaders(msg) {
// ---------- sell (marketplace listing) ----------
const RATE = () => window.siriusPricing?.CHIPNET_SATS_PER_USD || 250000;
let activeListing = null;
function sellMode() { return $("sell-mode").value; }
function paintSellMode() {
const usd = sellMode() === "usd";
$("sell-usd-terms").classList.toggle("hidden", !usd);
paintBand();
}
function paintBand() {
const usd = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")) || 0;
const band = Math.min(90, Math.max(5, Number(String($("sell-band").value).replace(/\D/g, "")) || 25));
const sats = Math.round(usd * RATE());
$("sell-band-hint").textContent = usd > 0
? `% of today's rate: you will receive between ${fmtBch(Math.round(sats * (1 - band / 100)))} and ${fmtBch(Math.round(sats * (1 + band / 100)))}`
: "% of today's BCH rate";
}
$("sell-mode").addEventListener("change", paintSellMode);
$("sell-band").addEventListener("input", paintBand);
function paintSell() {
const st = $("sell-status");
paintSellMode();
$("sell-rate").textContent = `1 BCH ≈ ${fmtUsd(Math.round(1e8 / RATE() * 100) / 100)}${window.siriusPricing?.priceInfo ? " · " + window.siriusPricing.priceInfo() : ""}`;
if (activeListing) {
st.className = "status ok"; st.textContent = "This name is listed on the market.";
$("sell-form").classList.add("hidden"); $("sell-active").classList.remove("hidden");
const p = BigInt(activeListing.price_sats);
$("sell-kv").innerHTML = [["Price", `${esc(fmtBch(p))} (≈ ${esc(fmtUsd(Math.round(Number(p) / RATE() * 100) / 100))})`], ["Listed", esc(new Date(activeListing.created_at).toLocaleString())], ["Offer", `<code>${esc(activeListing.partial_tx.slice(0, 40))}…</code>`], ["Market page", `<a href="./market/" target="_blank" rel="noopener">open →</a>`]]
const p = BigInt(activeListing.price_sats || 0);
const priceRow = activeListing.kind === "usd"
? ["Price", `${esc(fmtUsd(activeListing.target_cents / 100))} fixed · today ≈ ${esc(fmtBch(Math.round(activeListing.target_cents / 100 * RATE())))} · band ${esc(fmtBch(activeListing.floor_sats))} – ${esc(fmtBch(activeListing.ceil_sats))}`]
: ["Price", `${esc(fmtBch(p))} (≈ ${esc(fmtUsd(Math.round(Number(p) / RATE() * 100) / 100))})`];
$("sell-kv").innerHTML = [priceRow, ["Listed", esc(new Date(activeListing.created_at).toLocaleString())], ["Offer", `<code>${esc(activeListing.partial_tx.slice(0, 40))}…</code>`], ["Market page", `<a href="./market/" target="_blank" rel="noopener">open →</a>`]]
.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
} else {
st.className = "status"; st.textContent = "Not for sale. Set a price to list it.";
@ -705,7 +725,7 @@ async function loadListing(name) {
if (current.name === name) paintSell();
}
const bchStr = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
$("sell-usd").addEventListener("input", () => { const v = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")); $("sell-bch").value = v > 0 ? bchStr(Math.round(v * RATE())) : ""; });
$("sell-usd").addEventListener("input", () => { const v = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")); $("sell-bch").value = v > 0 ? bchStr(Math.round(v * RATE())) : ""; paintBand(); });
$("sell-bch").addEventListener("input", () => { const b = Number(String($("sell-bch").value).replace(/[^\d.]/g, "")); $("sell-usd").value = b > 0 ? String(Math.round(b * 1e8 / RATE() * 100) / 100) : ""; });
$("sell-list").addEventListener("click", async () => {
if (!current.entry || !wallet) return;
@ -721,6 +741,33 @@ $("sell-list").addEventListener("click", async () => {
const commitment = [...new TextEncoder().encode(raw)].map((b) => b.toString(16).padStart(2, "0")).join("");
const cert = utxos.find((u) => u.token?.category === current.entry.category && u.token?.nft?.commitment === commitment);
if (!cert) throw new Error("certificate UTXO not found in this wallet");
if (sellMode() === "usd") {
// Dollar price: certificate goes into the UsdListing covenant.
const usd = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")) || 0;
if (usd < 0.5) throw new Error("set a dollar price of at least $0.50");
const band = Math.min(90, Math.max(5, Number(String($("sell-band").value).replace(/\D/g, "")) || 25));
const targetCents = Math.round(usd * 100);
const nowSats = Math.round(usd * RATE());
const floorSats = Math.max(10000, Math.round(nowSats * (1 - band / 100))), ceilSats = Math.round(nowSats * (1 + band / 100));
const orc = await (await fetch(`${API}/api/price/oracle`, { cache: "no-store" })).json();
if (!orc.pubkey) throw new Error("the price oracle is not available right now");
push(`moving the certificate into the contract · $${usd} · band ${fmtBch(floorSats)} – ${fmtBch(ceilSats)}`);
const built = BNS.buildUsdListingTx({ name: current.name, certificateUtxo: cert, utxos, sellerAddress: cert.address, oraclePk: orc.pubkey, targetCents, floorSats, ceilSats, records: current.entry.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
const signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const txid = await BNS.broadcast(el, signed.hex);
push("broadcast " + txid.slice(0, 16) + "…");
const listing = { v: 2, kind: "usd", name: raw, seller: cert.address, seller_pkh: built.sellerPkh, category: current.entry.category, target_cents: targetCents, floor_sats: String(floorSats), ceil_sats: String(ceilSats), max_age_secs: BNS.DEFAULT_MAX_AGE_SECS, oracle_pk: orc.pubkey, redeem_hex: built.redeemHex, covenant_address: built.address, outpoint: { txid, vout: 0, satoshis: "1000" }, token: { category: cert.token.category, amount: String(cert.token.amount ?? 0), nft: { capability: cert.token.nft.capability, commitment: cert.token.nft.commitment } } };
let posted = null;
for (let i = 0; i < 6 && !posted; i++) {
const r = await fetch(`${API}/api/market`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(listing) });
const j = await r.json().catch(() => ({}));
if (r.ok) posted = j.listing; else if (r.status === 409) { push("waiting for the contract output to relay…"); await new Promise((res) => setTimeout(res, 4000)); } else throw new Error(j.error || `API ${r.status}`);
}
if (!posted) throw new Error("the market did not see the contract yet — press Refresh in a minute; the certificate is safe in the contract");
activeListing = posted; paintSell(); refreshBalance();
setMsg("sell-msg", `Listed at ${fmtUsd(usd)}. Buyers pay the dollar amount at the oracle rate; you receive between ${fmtBch(floorSats)} and ${fmtBch(ceilSats)}.`, "ok");
return;
}
push("found certificate · signing the offer (SINGLE|ANYONECANPAY)");
const listing = BNS.buildListing({ name: current.name, certificateUtxo: cert, priceSats: sats, sellerAddress: cert.address, addressToLockingBytecode: BNS.addressToLockingBytecode, keyFor: (h) => wallet.keyFor(h) });
const r = await fetch(`${API}/api/market`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(listing) });
@ -736,6 +783,22 @@ $("sell-cancel").addEventListener("click", async () => {
if (!current.entry || !wallet || !activeListing) return;
if (!confirm(`Cancel the listing for ${current.name}? This also moves the certificate once so the signed offer becomes void.`)) return;
const push = logger("sell-log"); const btn = $("sell-cancel"); btn.disabled = true; btn.textContent = "Cancelling…"; setMsg("sell-msg", "");
if (activeListing.kind === "usd") {
let el = null;
try {
el = await BNS.connect(); push("connected to chipnet");
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
const built = BNS.buildUsdCancelTx({ listing: activeListing, sellerAddress: wallet.address, sellerTokenAddress: wallet.tokenAddress, utxos, records: current.entry.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode, keyFor: (h) => wallet.keyFor(h) });
const txid = await BNS.broadcast(el, built.hex);
push("certificate reclaimed · broadcast " + txid.slice(0, 16) + "…");
const h = await signedHeaders(`BNS-MARKET1\n${current.name}\n{ts}`);
await fetch(`${API}/api/market/${encodeURIComponent(current.name)}`, { method: "DELETE", headers: h }).catch(() => {});
activeListing = null; paintSell(); setTimeout(() => { loadHoldings(); refreshBalance(); }, 3000);
setMsg("sell-msg", "Listing cancelled. The certificate is back in your wallet; every quote issued for that listing is void.", "ok");
} catch (e) { setMsg("sell-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
finally { btn.disabled = false; btn.textContent = "Cancel listing →"; try { el?.close?.(); } catch {} }
return;
}
try {
const h = await signedHeaders(`BNS-MARKET1\n${current.name}\n{ts}`);
const r = await fetch(`${API}/api/market/${encodeURIComponent(current.name)}`, { method: "DELETE", headers: h });

View file

@ -22,7 +22,7 @@
(function () {
var STORE = "sirius-lang";
var CACHE = "sirius-i18n:";
var VERSION = "20260920f"; // bump when dictionaries change
var VERSION = "20260920g"; // bump when dictionaries change
var LANGS = {
en: "English",
de: "Deutsch",

View file

@ -7,7 +7,7 @@
// stays valid while their certificate UTXO is unspent, so the gateway prunes
// stale offers and the buyer's broadcast is the final arbiter.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917market";
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
const API = "https://silentmode.st";
const $ = (id) => document.getElementById(id);
@ -16,6 +16,9 @@ const fmtInt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const bch = (sats) => (window.siriusPricing?.formatBch || ((s) => (Number(s) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "") + " BCH"))(sats);
const bits = (sats) => (window.siriusPricing?.formatBits || ((s) => (Number(s) / 100).toLocaleString("en-US") + " bits"))(sats);
const usd = (sats) => { const r = window.siriusPricing?.CHIPNET_SATS_PER_USD || 250000; const v = Number(sats) / r; return (window.siriusPricing?.formatUsd || ((n) => "$" + n.toFixed(2)))(Math.round(v * 100) / 100); };
const fmtUsdC = (cents) => (window.siriusPricing?.formatUsd || ((n) => "$" + n.toFixed(2)))(Math.round(Number(cents)) / 100);
const satsForCents = (cents) => Math.round(Number(cents) / 100 * (window.siriusPricing?.CHIPNET_SATS_PER_USD || 400000));
const clampSats = (sats, l) => Math.min(Number(l.ceil_sats), Math.max(Number(l.floor_sats), sats));
const shortAddr = (a) => (a ? a.replace(/^[^:]+:/, "").slice(0, 8) + "…" + a.slice(-5) : "—");
let listings = [];
@ -53,7 +56,8 @@ function render() {
const keep = tldSel.value;
tldSel.innerHTML = `<option value="">All TLDs</option>` + tlds.map((t) => `<option value="${esc(t)}"${t === keep ? " selected" : ""}>.${esc(t)}</option>`).join("");
let rows = listings.filter((l) => (!q || l.name.includes(q)) && (!tldSel.value || l.tld === tldSel.value));
rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "new" ? Date.parse(b.created_at) - Date.parse(a.created_at) : Number(a.price_sats) - Number(b.price_sats));
const priceOf = (l) => (l.kind === "usd" ? clampSats(satsForCents(l.target_cents), l) : Number(l.price_sats));
rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "new" ? Date.parse(b.created_at) - Date.parse(a.created_at) : priceOf(a) - priceOf(b));
$("count").textContent = `${rows.length} of ${listings.length}`;
const status = $("status");
document.querySelector(".toolbar").hidden = !listings.length;
@ -72,7 +76,9 @@ function render() {
$("list").innerHTML = rows.length ? rows.map((l) => `
<div class="row">
<div class="n">${esc(l.label)}.<span class="tld">${esc(l.tld)}</span> ${wallet && l.seller === wallet.address ? '<span class="badge">yours</span>' : ""}</div>
<div class="price"><b>${esc(bch(l.price_sats))}</b><span>≈ ${esc(usd(l.price_sats))}</span></div>
<div class="price">${l.kind === "usd"
? `<b>${esc(fmtUsdC(l.target_cents))}</b><span>fixed in USD · today ≈ ${esc(bch(satsForCents(l.target_cents)))}</span>`
: `<b>${esc(bch(l.price_sats))}</b><span>≈ ${esc(usd(l.price_sats))}</span>`}</div>
<div><button class="btn acid small" data-buy="${esc(l.name)}" ${wallet && l.seller === wallet.address ? "disabled" : ""}>Buy →</button></div>
<div class="meta"><span>seller ${esc(shortAddr(l.seller))}</span><span>·</span><span>listed ${esc(new Date(l.created_at).toLocaleDateString())}</span><span>·</span><a href="${API}/bns/${encodeURIComponent(l.name)}/" target="_blank" rel="noopener">view site →</a></div>
</div>`).join("") : `<div class="empty">Nothing matches that filter.</div>`;
@ -86,13 +92,15 @@ function push(t) { const log = $("buy-steps"); log.className = "steps on"; const
function openBuy(name) {
selected = listings.find((l) => l.name === name); if (!selected) return;
$("buy-name").textContent = name;
const price = BigInt(selected.price_sats);
const check = BNS.verifyListing(selected, { category: selected.category });
const isUsd = selected.kind === "usd";
const price = BigInt(isUsd ? clampSats(satsForCents(selected.target_cents), selected) : selected.price_sats);
const check = isUsd ? BNS.verifyUsdListing(selected) : BNS.verifyListing(selected, { category: selected.category });
$("buy-kv").innerHTML = [
["Price", `${esc(bch(price))} (≈ ${esc(usd(price))})`],
isUsd ? ["Price", `${esc(fmtUsdC(selected.target_cents))} fixed · about ${esc(bch(price))} at today's rate (the exact amount is set by the oracle quote when you buy, between ${esc(bch(selected.floor_sats))} and ${esc(bch(selected.ceil_sats))})`]
: ["Price", `${esc(bch(price))} (≈ ${esc(usd(price))})`],
["Seller", esc(selected.seller)],
["You receive", "the name's certificate, to your token address"],
["Offer check", check.ok ? "seller signature valid" : "INVALID: " + esc(check.error)],
["Offer check", check.ok ? (isUsd ? "contract terms verified" : "seller signature valid") : "INVALID: " + esc(check.error)],
["Extra", "≈ 16 bits dust + fee"],
].map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
$("buy-steps").className = "steps"; $("buy-steps").innerHTML = ""; setMsg("");
@ -113,9 +121,19 @@ $("buy-go").addEventListener("click", async () => {
el = await BNS.connect(); push("connected to chipnet");
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
push(`wallet has ${utxos.filter((u) => !u.token).length} spendable coin(s)`);
const built = BNS.completeSale({ listing: selected, buyerAddress: wallet.address, buyerTokenAddress: wallet.tokenAddress, utxos, records: selected.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
push(`built sale · price ${bch(built.costs.priceSats)} · fee ${bits(built.costs.chainFeeSats)}`);
const signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [built.sellerInputIndex] });
let built, signed;
if (selected.kind === "usd") {
const q = await (await fetch(`${API}/api/price/oracle?txid=${encodeURIComponent(selected.outpoint.txid)}`, { cache: "no-store" })).json();
if (!q.message) throw new Error(q.error || "no oracle quote");
push(`oracle quote: 1 BCH = $${(q.price_cents / 100).toFixed(2)} (median of ${q.median_of} exchanges)`);
built = BNS.buildUsdBuyTx({ listing: selected, quote: q, buyerAddress: wallet.address, buyerTokenAddress: wallet.tokenAddress, utxos, records: selected.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
push(`you pay ${bch(built.priceSats)} · fee ${bits(built.fee)}`);
signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [built.covenantInputIndex] });
} else {
built = BNS.completeSale({ listing: selected, buyerAddress: wallet.address, buyerTokenAddress: wallet.tokenAddress, utxos, records: selected.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
push(`built sale · price ${bch(built.costs.priceSats)} · fee ${bits(built.costs.chainFeeSats)}`);
signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [built.sellerInputIndex] });
}
push("signed your inputs (the seller's signature is already in place)");
const txid = await BNS.broadcast(el, signed.hex);
push("broadcast: " + txid);

View file

@ -7,7 +7,7 @@
// Injects its own modal HTML into <body> and its own CSS into <head> on load
// so the host page needs nothing but a single script include.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917market";
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));

View file

@ -11,7 +11,7 @@
// The gateway never holds a key: it verifies each upload's signature
// against the current NFT owner and refuses writes outside bns/<name>/.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917market";
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
import { initAssistant } from "./studio-ai.js?v=20260920ai";
const API = "https://silentmode.st";
@ -443,9 +443,17 @@ async function publish() {
await putFile("_studio.json", data, "application/json");
pushStep("saved editor project (_studio.json)");
dirty = false; $("btn-draft").disabled = true;
// Point the name at the folder if it does not already.
// Point the name at the folder if it does not already. Re-read the
// registry first: `entry` from boot can be stale or missing (a name
// indexed after the page opened), and a stale view here means a
// needless on-chain transaction on every publish.
try { const r = await fetch(`${API}/api/name/${encodeURIComponent(name)}`, { cache: "no-store" }); if (r.ok) entry = await r.json(); } catch {}
const rec = entry?.records || {};
if (rec.s3 !== folder) {
const norm = (v) => String(v || "").trim().replace(/\/+$/, "") + "/";
const pointsHere = norm(rec.s3) === folder;
if (!pointsHere && !entry) {
pushStep("the registry has not indexed this name yet — files are up; pointing the name is skipped until it appears (or set Hosting in the dashboard)");
} else if (!pointsHere) {
pushStep(`name points at ${rec.s3 ? `"${rec.s3}"` : "nothing"} — setting s3 = ${folder} on chain`);
const next = { ...rec, s3: folder };
delete next.h; // inline HTML would shadow the Sia site

View file

@ -97,7 +97,7 @@
footer{border-top:1px solid var(--line);padding:2rem 0 3rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
@media (max-width:560px){.row{grid-template-columns:1fr auto}.row .price{grid-column:1/-1;text-align:left}}
</style>
<script src="../js/i18n.js?v=20260920f"></script>
<script src="../js/i18n.js?v=20260920g"></script>
</head>
<body>
<nav class="topnav">
@ -147,11 +147,11 @@
<footer id="site-footer"></footer>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script src="../js/session.js?v=20260917dash"></script>
<script src="../js/pricing.js?v=20260920bits"></script>
<script type="module" src="../js/market.js?v=20260920bits"></script>
<script type="module" src="../js/market.js?v=20260920usd"></script>
<script defer src="../js/theme.js?v=20260920compact"></script>
<script defer src="../js/site-footer.js?v=20260917market"></script>
<script defer src="../js/profile-menu.js?v=20260920bits"></script>

View file

@ -259,7 +259,7 @@
.addrow{grid-template-columns:1fr 1fr}
}
</style>
<script src="./js/i18n.js?v=20260920f"></script>
<script src="./js/i18n.js?v=20260920g"></script>
</head>
<body>
@ -360,11 +360,23 @@
<p class="hint">You sign an offer once: "whoever pays me this price gets the certificate". The offer is stored by the gateway and shown on the <a href="./market" target="_blank" rel="noopener">market</a>. A buyer completes it in one transaction — you are paid in the same transaction that moves the name, or not at all. No escrow, nothing to trust.</p>
<div class="status" id="sell-status">Checking the market…</div>
<div id="sell-form">
<label style="margin-top:0">Price is fixed in</label>
<select id="sell-mode" style="max-width:360px">
<option value="usd">US dollars — the BCH amount follows the market</option>
<option value="bch">BCH — the dollar value follows the market</option>
</select>
<div class="row" style="align-items:flex-end">
<div><label style="margin-top:0">Price (USD)</label><input type="text" id="sell-usd" placeholder="25" inputmode="decimal" style="max-width:160px"></div>
<div><label style="margin-top:0">Price (BCH)</label><input type="text" id="sell-bch" placeholder="0.0625" inputmode="decimal" style="max-width:200px"></div>
<span class="dim" id="sell-rate"></span>
</div>
<div id="sell-usd-terms">
<div class="row" style="align-items:flex-end">
<div><label style="margin-top:0">Accept between</label><input type="text" id="sell-band" value="25" inputmode="numeric" style="max-width:90px"></div>
<span class="dim" id="sell-band-hint"></span>
</div>
<p class="hint">A dollar price lives in a small contract that holds the certificate while it is for sale. A buyer pays the dollar amount at the Sirius.X price oracle's rate, and the contract keeps the payout inside this band whatever the oracle says, so a wrong or old quote can never take the price outside it. Your name's records are frozen while listed; cancelling returns the certificate.</p>
</div>
<div class="row end"><button class="btn acid" id="sell-list">List for sale →</button></div>
</div>
<div id="sell-active" class="hidden">
@ -677,15 +689,15 @@
<footer id="site-footer"></footer>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script src="./js/session.js?v=20260917dash"></script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script src="./js/pricing.js?v=20260920bits"></script>
<script type="module" src="./js/dashboard.js?v=20260920s3"></script>
<script type="module" src="./js/dashboard.js?v=20260920usd2"></script>
<script defer src="./js/theme.js?v=20260920compact"></script>
<script defer src="./js/site-footer.js?v=20260917market"></script>
<script type="module" src="./js/register-flow.js?v=20260920bits"></script>
<script type="module" src="./js/register-flow.js?v=20260920usd"></script>
<script defer src="./js/profile-menu.js?v=20260920bits"></script>
</body>
</html>

View file

@ -131,7 +131,7 @@
.muted{color:var(--mut);font-size:13px}
code{font-family:var(--mono);background:#0e131b;border:1px solid var(--line);border-radius:6px;padding:1px 6px;font-size:12px}
</style>
<script src="./js/i18n.js?v=20260920f"></script>
<script src="./js/i18n.js?v=20260920g"></script>
</head>
<body>
<div class="bar">
@ -229,7 +229,7 @@
</div>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script src="./js/session.js?v=20260917dash"></script>
<script src="./vendor/grapesjs/grapes.min.js?v=0.23.6"></script>
@ -244,6 +244,6 @@
<script src="./vendor/grapesjs/style-bg.js?v=2.0.2"></script>
<script src="./vendor/grapesjs/touch.js?v=0.1.1"></script>
<script src="./vendor/grapesjs/parser-postcss.js?v=1.0.3"></script>
<script type="module" src="./js/studio.js?v=20260920pc"></script>
<script type="module" src="./js/studio.js?v=20260920usd"></script>
</body>
</html>

View file

@ -130,7 +130,7 @@
.font-picker button.active{background:var(--acid);color:var(--bg);font-weight:600}
</style>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
</head>
<body>
@ -228,20 +228,31 @@ sha256sum &lt;downloaded-file&gt;.exe</pre>
<script defer src="../js/theme.js?v=20260920compact"></script>
<script defer src="../js/profile-menu.js?v=20260920bits"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
// Uses scroll-position tracking so short sections at the end of the page
// still activate their link when the reader scrolls to them.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const map = new Map();
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) map.set(el, a); });
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
const a = map.get(en.target); if (!a) return;
links.forEach((l) => l.classList.remove("active"));
a.classList.add("active");
});
}, { rootMargin: "-40% 0px -55% 0px", threshold: 0 });
map.forEach((_, el) => io.observe(el));
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>

View file

@ -148,9 +148,9 @@
footer{border-top:1px solid var(--line);padding:2rem 0 3rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
</style>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js?v=20260920usd" } }
</script>
<script src="./js/i18n.js?v=20260920f"></script>
<script src="./js/i18n.js?v=20260920g"></script>
</head>
<body>
@ -341,7 +341,7 @@
})();
</script>
<script type="module" src="./js/register-flow.js?v=20260920bits"></script>
<script type="module" src="./js/register-flow.js?v=20260920usd"></script>
<script src="./js/pricing.js?v=20260920bits"></script>
<script src="./js/session.js?v=20260917dash"></script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
@ -349,20 +349,31 @@
<script defer src="./js/site-footer.js?v=20260917market"></script>
<script defer src="./js/profile-menu.js?v=20260920bits"></script>
<script>
// Sidenav scroll-spy: highlight the section currently in view.
// Uses scroll-position tracking so short sections at the end of the page
// still activate their link when the reader scrolls to them.
(() => {
const links = document.querySelectorAll(".sidenav a[href^='#']");
if (!links.length) return;
const map = new Map();
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) map.set(el, a); });
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
const a = map.get(en.target); if (!a) return;
links.forEach((l) => l.classList.remove("active"));
a.classList.add("active");
});
}, { rootMargin: "-40% 0px -55% 0px", threshold: 0 });
map.forEach((_, el) => io.observe(el));
const entries = [];
links.forEach((a) => { const el = document.querySelector(a.getAttribute("href")); if (el) entries.push({ el, link: a }); });
if (!entries.length) return;
const setActive = (link) => { links.forEach((l) => l.classList.remove("active")); link.classList.add("active"); };
const update = () => {
const doc = document.documentElement;
const scrollBottom = window.scrollY + window.innerHeight;
if (scrollBottom >= (doc.scrollHeight - 4)) { setActive(entries[entries.length - 1].link); return; }
const line = window.scrollY + window.innerHeight * 0.35;
let active = entries[0].link;
for (const e of entries) {
if (e.el.getBoundingClientRect().top + window.scrollY <= line) active = e.link;
else break;
}
setActive(active);
};
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
})();
</script>
</body>