theseus/bundled-addons/nostr/panel.html
Theseus Dev f76ca4f9f4 Theseus 0.3.132: layout revert, Nostr per-site identities, Mail as sidebar quicklink, connector token stays persistent
Startpage layout reverted to the pre-0.3.131 shape. 0.3.131's full-page-
height sidebar was a mistake — slots in a short strip under a hero at
the top, cards and slots sharing one row of panels at the same height,
is what matches the design. .page/.main-col/.sidebar structure dropped,
hero back as a body sibling of .homepage, slots-list back to repeat(3,
72px) with minSlots=9. Two of the user's own tweaks baked into the
defaults: .section.chromeless on the left column so slot icons float
on the page background instead of sitting in a boxed panel, and
defaultState().iconSize = "large" (60 px tiles).

Game.X in the SM cards (replaces SilentMode.X in its slot, stub
description + a built-in gamepad brand mark until game.x goes live).
SilentMode.X moved to the quick-links strip as the 5th slot. moveItem
now strips the pinned-slot anchor when a q item moves to sm — a link
dragged onto the cards used to flip back on the next render because
its slot number was still set.

Nostr NIP-07 signer grows per-site identities (0.1.0 → 0.1.1). First
approval modal picks between the Hermes identity (one npub for DMs
and public notes, the one published as the np record on your BNS
name) and a site-specific HKDF-derived npub (unique per origin, so
Nostr clients can't cross-correlate your activity). The chooser shows
both npubs so the user sees what each button reveals. Grants without
a stored profileId (migrated from 0.3.131) default to the Hermes
identity so existing approvals keep behaving the same.
lib/hermes.js:nostrKeyFromRootForOrigin HKDFs with info
silentmode/nostr-origin/v1/<origin> over the existing 32-byte
messengerRoot. main.js _nostrAPI methods take {profileId, origin};
signer cache per profile.

Silent Mode Mail retired as an extension — now a Sidebar quicklink
(id: silentmail). Its 0.1.0 panel iframed mail.silentmode.st, which
sends X-Frame-Options: DENY; the browser rendered the block page and
STILL fired the iframe's load event, so the 5 s "did it embed?"
watchdog saw it as loaded and the panel stayed black. The whole
extension was just a landing page with an Open Mail button — exactly
what a sidebar quicklink does, without taking a panel slot or an
Extensions row. migrateAegisRename auto-retires any existing
extensions/mail/ to addons-backups/mail-migrated-<stamp>/ on first
boot of this Theseus; "mail" added to the first-party reserved-ids
list so a community add-on can't claim the id.

Claude connector 0.2.1 → 0.2.2: the bearer token no longer rotates on
every server stop. The old stop() generated a fresh token every time
Theseus quit, the panel toggled off, or a 0.3.X install replaced the
app — the Connect button wrote the new token to ~/.claude.json but
any already-running Claude Code or Claude Desktop process held the
previous token in memory and only re-reads config on launch, so /mcp
reconnect kept sending the stale bearer and the server rejected it
forever. Infinite loop: install Theseus upgrade, click Connect, MCP
still 401, close Claude, open Claude, install another upgrade a day
later, repeat. The explicit Revoke token button still rotates for
users who want fresh keys; everything else is persistent.

Sidebar panel apps blank to about:blank after 2 min of being closed to
free their render tree (iframes, media, listeners). Fast reopen within
the window still reuses the loaded page.

Light theme palette flip under body.light — the full set of
--ink / --mut / --card / --line tokens flip plus hero + card
translucent panels switch to light-glass, so hero titles, SM card
text, slot labels and the ".bch" tld read correctly on the light
background preset instead of as pale ghosts.
2026-10-30 03:45:32 +02:00

169 lines
7.6 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nostr</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--btn:#1c2432; --btn-h:#242e40; --err:#ff9081; --purple:#8e5cf7; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; --acid:#088A66; --err:#c81f0f; --purple:#6d28d9; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; }
header { display: flex; align-items: center; gap: 8px;
padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
header .t svg { width: 16px; height: 16px; display: block; }
header .m { color: var(--dim); font-size: 11.5px; flex: 1; text-align: right; }
main { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
color: var(--dim); margin: 0 0 6px; font-weight: 600; }
.card { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 12px; }
.key { font-family: ui-monospace, Consolas, monospace; font-size: 11.5px;
word-break: break-all; color: var(--ink); }
.muted { color: var(--mut); font-size: 12px; }
.warn { color: var(--err); }
button { border: 1px solid var(--line); border-radius: 7px; background: var(--btn);
color: var(--ink); cursor: pointer; font: inherit; padding: 6px 10px; }
button:hover:not(:disabled) { background: var(--btn-h); }
button:disabled { opacity: .45; cursor: default; }
button.sm { font-size: 11.5px; padding: 4px 8px; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.spread { justify-content: space-between; }
table { width: 100%; border-collapse: collapse; }
td { padding: 7px 0; border-top: 1px solid var(--line); vertical-align: middle; }
tr:first-child td { border-top: 0; }
td.o { word-break: break-all; }
.lvl { font-size: 10.5px; color: var(--dim); }
.empty { color: var(--dim); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--purple); flex: none; }
</style>
</head>
<body>
<header>
<div class="t">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="1" y="1" width="22" height="22" rx="5.5" fill="#8e5cf7"/>
<circle cx="9.3" cy="9.3" r="3.6" fill="none" stroke="#fff" stroke-width="2.1"/>
<path d="M11.9 11.9 19 19M16.2 16.2l-2 2M18.1 18.1l-1.6 1.6" stroke="#fff" stroke-width="2.1" stroke-linecap="round"/>
</svg>
<span>Nostr</span>
</div>
<div class="m" id="hdr"></div>
</header>
<main>
<div>
<h2>Your identity</h2>
<div class="card" id="idCard"><span class="muted">Checking…</span></div>
</div>
<div>
<h2>Sites using your key</h2>
<div class="card" id="grants"><span class="empty">Loading…</span></div>
</div>
<div class="muted">
Theseus signs with the same key Hermes uses, so a note you post and a message
someone sends you belong to one identity. Your key never leaves Theseus — sites
ask it to sign and get back only the signature.
</div>
</main>
<script>
(() => {
"use strict";
const S = window.silentmode;
const $ = (id) => document.getElementById(id);
const el = (tag, props, ...kids) => {
const n = Object.assign(document.createElement(tag), props || {});
for (const k of kids.flat()) if (k != null && k !== false) n.append(k);
return n;
};
const put = (node, ...kids) => { node.textContent = ""; for (const k of kids.flat()) if (k != null && k !== false) node.append(k); };
async function copy(text, msg) {
try { await navigator.clipboard.writeText(text); flash(msg || "Copied"); }
catch { flash("Could not copy"); }
}
let flashT = null;
function flash(t) {
$("hdr").textContent = t;
clearTimeout(flashT);
flashT = setTimeout(() => { $("hdr").textContent = ""; }, 1600);
}
async function drawIdentity() {
let st;
try { st = await S.invoke("status", {}); }
catch (e) { put($("idCard"), el("span", { className: "warn", textContent: e?.message || String(e) })); return; }
if (!st || !st.ready) {
put($("idCard"),
el("div", { className: "muted", textContent: st?.reason || "No Nostr identity yet." }));
return;
}
put($("idCard"),
el("div", { className: "row spread" },
el("div", { className: "row" }, el("span", { className: "dot" }), el("strong", { textContent: "Connected" })),
el("button", { className: "sm", textContent: "Copy npub", onclick: () => copy(st.npub, "npub copied") })),
el("div", { className: "key", style: "margin-top:8px", textContent: st.npub }),
el("div", { className: "muted", style: "margin-top:6px" },
"Shared with Hermes — the same identity signs your messages."));
}
async function drawGrants() {
let r;
try { r = await S.invoke("listGrants", {}); }
catch (e) { put($("grants"), el("span", { className: "warn", textContent: e?.message || String(e) })); return; }
const list = (r && r.grants) || [];
if (!list.length) {
put($("grants"), el("span", { className: "empty" },
"No site has been allowed yet. The first time one asks, Theseus will show you what it wants."));
return;
}
// Per-origin row: origin + capability + which identity it sees (Hermes
// key vs. a site-specific derived key with its own npub) + revoke. The
// identity label matters — a user glancing down the list should see at
// once which sites share their "public" Hermes npub and which are
// isolated per-site.
const rows = list.map((g) => {
const profileLine = g.profileId === "origin"
? el("div", { className: "lvl" },
document.createTextNode("Site-specific identity"),
g.npub ? el("div", { className: "key", style: "margin-top:2px;font-size:10.5px;opacity:.8", textContent: g.npub }) : null)
: el("div", { className: "lvl", textContent: "Shared Hermes identity" });
return el("tr", {},
el("td", { className: "o" },
el("div", { textContent: g.origin }),
el("div", { className: "lvl", textContent: g.level === "sign" ? "can sign and encrypt" : "can see your public key" }),
profileLine),
el("td", { style: "text-align:right;width:84px" },
el("button", { className: "sm", textContent: "Revoke", onclick: async () => {
try { await S.invoke("revoke", { origin: g.origin }); flash("Revoked"); refresh(); }
catch (e) { flash(e?.message || "Failed"); }
} })));
});
put($("grants"),
el("table", {}, el("tbody", {}, rows)),
el("div", { className: "row", style: "margin-top:10px;justify-content:flex-end" },
el("button", { className: "sm", textContent: "Revoke all", onclick: async () => {
try { await S.invoke("revokeAll", {}); flash("All revoked"); refresh(); }
catch (e) { flash(e?.message || "Failed"); }
} })));
}
function refresh() { drawIdentity(); drawGrants(); }
refresh();
// The list changes when a site is allowed from a prompt in another tab.
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
})();
</script>
</body>
</html>