` back as
+ body siblings (not nested in a `.page` grid with a sidebar aside).
+ `.homepage` is the 2-col grid `minmax(260, 300) 1fr`, hero padded
+ `5vh 1rem 0` as the top of the page again.
+ - CSS: `.sidebar` renamed back to `.left-column`, `.cards-wrap` back
+ to `.right-column`, the `.page` + `.main-col` grid dropped.
+ `.section.chromeless` kept (brought back) — the left column uses it
+ so slots float on the page background like the user had it; the SM
+ cards keep the soft panel surface.
+ - `defaultState().iconSize` is `"large"` (60 px slot tiles) —
+ matches the sizing in the user's screenshot; existing profiles
+ with their own `iconSize` keep that choice.
+ - `.slots-list { grid-template-columns: repeat(3, 72px); width:
+ max-content; margin: 0 auto }` — fixed-cell 232 px grid, centered
+ in its panel. `layoutSlots` back to `minSlots = 9` (3 × 3 rows,
+ matches the SM cards grid height). `minSlotsForFill()` and its
+ `ResizeObserver` are gone.
+ - `.hero .ver-line` CSS + its `body.light` override removed; the
+ `
` markup gone; the init-script
+ block that wired `getVersion()` → `navigate("silentmode.st/releases")`
+ gone. The user asked for "What's new" to be dropped again after I
+ restored it for 0.3.131; `window.home.getVersion` exposure in
+ [home-preload.js](home-preload.js) also removed since nothing calls
+ it any more.
+ - `moveItem()` now strips the `slot` property when a q item moves to
+ sm (link → card). Pin-to-slot is q-only, so leaving the anchor on
+ an sm card re-pinned it back to a slot cell on the next revert.
+ This fixes the user-reported "moving a link to card doesn't work"
+ (actually it moved, but the stale slot anchor flipped it back on
+ the next render).
+
+- **Live user state reset.** Backed up `%APPDATA%\Theseus\home-cards.json`
+ to `.bak-before-reset`, rewrote it to the restored defaults — SM
+ cards list contains Game.X (replacing SilentMode.X in its slot),
+ quicklinks strip holds hello.bch / sia.coin / Pithos.sia /
+ Bergamot.X / SilentMode.X, plus any user-added extras preserved.
+ Preserved: `background` (indigo preset) and `iconSize` (large).
+ User must reload the open home tab (Ctrl+R or close/reopen) to pick
+ up the new file.
+
+- **LANDED: Claude connector no longer rotates its bearer on every
+ stop (0.2.2).** The old `stop()` generated a fresh token on every
+ server stop, so a Theseus quit, a panel toggle-off, or even a 0.3.X
+ → 0.3.X+1 install silently invalidated every registered Claude. The
+ Connect button wrote the new token to `~/.claude.json`, but any
+ already-running Claude Code or Claude Desktop process holds 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. Observed symptom from the connector log: after
+ installing 0.3.131 and clicking Connect, `.claude.json` was
+ updated (correct new token written at 01:02:53) but the running
+ Claude Code session's MCP calls stayed 401 forever. Fix in
+ [bundled-addons/claude-connector/index.js](bundled-addons/claude-connector/index.js):
+ `stop()` keeps the stored token; only the panel's "Revoke token"
+ button rotates. Addon version bumped 0.2.1 → 0.2.2 so
+ `seedBundledAddons` picks up the fix on the first boot of 0.3.132.
+
+- **LANDED: Nostr per-site identities (0.1.1).** The 0.3.131 Nostr add-on
+ always signed with Hermes's one `npub`, which gives sites a single
+ identity they can correlate across the Nostr network. 0.3.132 adds a
+ per-origin profile picked on first approval:
+ - `lib/hermes.js` gains `nostrKeyFromRootForOrigin(messengerRoot, origin)`
+ — HKDF with info `silentmode/nostr-origin/v1/` over the
+ existing 32-byte messengerRoot, so every site that chooses "site-
+ specific identity" sees a different `npub` and clients can't cross-
+ correlate. Deterministic from the mnemonic, no new vault field
+ needed.
+ - [main.js](main.js): `_nostrAPI` methods take `{ profileId, origin }`.
+ `profileId` is `"hermes"` or `"origin"`; the signer cache keys on
+ both. Per-origin secret keys are hot-cached under
+ `nostrOriginSkCache` so the async HKDF only runs once per
+ (vault, origin). Backward-compatible: callers that omit `profileId`
+ get the Hermes identity (what 0.3.131 did).
+ - [bundled-addons/nostr/index.js](bundled-addons/nostr/index.js): grant
+ schema gains `profileId`. First-time approval modal shows BOTH npubs
+ side-by-side and two Allow buttons ("Use site-specific identity" is
+ primary, "Use my Hermes identity" is the opt-in to the shared key).
+ Subsequent calls reuse the stored profile silently. Pre-upgrade
+ grants (no `profileId` field) default to `"hermes"` so existing
+ approvals keep behaving the same.
+ - [bundled-addons/nostr/panel.html](bundled-addons/nostr/panel.html):
+ each grant row shows which identity the site sees — "Shared Hermes
+ identity" or "Site-specific identity" with the derived npub under
+ it.
+ - Addon version bumped 0.1.0 → 0.1.1 so `seedBundledAddons` reseeds
+ the fresh-from-0.3.131 copy on the next Theseus start.
+
## Staged for the next release (post-0.3.130)
Queue of asks for the next cut. Items marked LANDED are edited in the
diff --git a/bundled-addons/claude-connector/addon.json b/bundled-addons/claude-connector/addon.json
index 40c8206d..4f5fa5f5 100644
--- a/bundled-addons/claude-connector/addon.json
+++ b/bundled-addons/claude-connector/addon.json
@@ -1,7 +1,7 @@
{
"id": "claude-connector",
"name": "Claude connector",
- "version": "0.2.1",
+ "version": "0.2.2",
"description": "Lets Claude Code or Claude Desktop on this computer work in your Theseus tabs through a local MCP connection: read pages, click, type, paste and upload. Off until you turn it on; asks before each new site; never types into password fields; logs every action.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iMTQiIGZpbGw9IiNkOTc3NTciLz48cGF0aCBkPSJNMzIgMTN2MzhNMTMgMzJoMzhNMTguNiAxOC42bDI2LjggMjYuOE00NS40IDE4LjYgMTguNiA0NS40IiBzdHJva2U9IiNmZmY0ZWUiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+DQo=",
diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js
index bf59fbb6..565d31b8 100644
--- a/bundled-addons/claude-connector/index.js
+++ b/bundled-addons/claude-connector/index.js
@@ -256,14 +256,18 @@ module.exports = {
await controller.releaseAll();
policy.reset();
try { fs.rmSync(endpointFile, { force: true }); } catch {}
- // Rotate the bearer token on every stop so that re-enabling the
- // connector doesn't resurrect a credential a client (or anyone who
- // read its config while it was off) still has — matches the mental
- // model where "off" means "the keys change". The panel's installed-
- // command strings reflect the new token on the next start.
+ // Token is PERSISTED across stop/start — a Theseus restart or a
+ // panel toggle-off/on does NOT invalidate connected clients. The
+ // old behavior rotated here on every stop, which looked tidy but
+ // broke every registered Claude on every quit: the Connect button
+ // writes the new token to ~/.claude.json, but any already-running
+ // Claude Code or Claude Desktop process is holding the previous
+ // token in memory and only re-reads on launch, so /mcp
+ // reconnect kept sending the stale bearer. For explicit rotation
+ // the panel's "Revoke token" button still exists and still
+ // generates a fresh one.
if (s) {
- api.storage.set("token", newToken());
- log.add({ tool: "connector", args: `${reason || "stopped"}; token rotated`, ok: true });
+ log.add({ tool: "connector", args: reason || "stopped", ok: true });
}
pushStatus();
})().finally(() => { stopping = null; });
diff --git a/bundled-addons/nostr/addon.json b/bundled-addons/nostr/addon.json
index 892d2bdf..1423d2d3 100644
--- a/bundled-addons/nostr/addon.json
+++ b/bundled-addons/nostr/addon.json
@@ -1,7 +1,7 @@
{
"id": "nostr",
"name": "Nostr",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Sign in to Nostr apps with the identity Theseus already holds. Adds window.nostr (NIP-07): sites can ask who you are and ask you to sign, one approval at a time, and your key never leaves the browser.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPHJlY3QgeD0iMSIgeT0iMSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiByeD0iNS41IiBmaWxsPSIjOGU1Y2Y3Ii8+IDxjaXJjbGUgY3g9IjkuMyIgY3k9IjkuMyIgcj0iMy42IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi40Ii8+IDxwYXRoIGQ9Ik0xMS45IDExLjkgMTkgMTkiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPiA8cGF0aCBkPSJNMTYuMiAxNi4ybC0xLjkgMS45IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4gPC9zdmc+",
diff --git a/bundled-addons/nostr/index.js b/bundled-addons/nostr/index.js
index f81b46cb..da7d182f 100644
--- a/bundled-addons/nostr/index.js
+++ b/bundled-addons/nostr/index.js
@@ -13,9 +13,22 @@
// The origin is NOT taken from the page. Main derives it from the tab's
// committed URL and passes it in `ctx`; a page cannot claim to be someone
// else, and only the top-level frame can speak at all.
+//
+// --- Per-site identities (0.3.132) ---------------------------------------
+// Each origin's grant carries a profileId chosen on the first approval:
+// "hermes" — the user's one messenger npub (also used by Hermes DMs and
+// publicly as the np record on their BNS name).
+// "origin" — an HKDF-derived per-origin npub, so sites can't correlate
+// posts across Nostr clients.
+// Grants with no profileId (migrated from pre-0.3.132) default to "hermes"
+// so existing approvals keep behaving the same.
-const STORE_KEY = "grants"; // { "": { level, at, kinds: {} } }
-const LEVELS = { read: 1, sign: 2 }; // sign implies read
+const STORE_KEY = "grants"; // { "": { level, at, profileId, kinds: {} } }
+const LEVELS = { read: 1, sign: 2 }; // sign implies read
+const PROFILE_LABELS = {
+ hermes: "Your Hermes identity",
+ origin: "A site-specific identity (unique to this site)",
+};
// Kinds that rewrite or destroy something, where "you already allowed this
// site" is not good enough — the user should see what is being signed even
@@ -44,9 +57,14 @@ module.exports = {
return g && typeof g === "object" ? g : {};
};
const grantFor = (origin) => allGrants()[origin] || null;
- function setGrant(origin, level) {
+ function setGrant(origin, level, profileId) {
const g = allGrants();
- g[origin] = { level, at: Date.now() };
+ const prev = g[origin] || {};
+ g[origin] = {
+ level,
+ at: Date.now(),
+ profileId: profileId || prev.profileId || "hermes",
+ };
api.storage.set(STORE_KEY, g);
}
function dropGrant(origin) {
@@ -60,30 +78,87 @@ module.exports = {
if (!g) return false;
return (LEVELS[g.level] || 0) >= (LEVELS[need] || 0);
}
+ // profileId for a known-granted origin, with the pre-0.3.132 default.
+ function profileFor(origin) {
+ const g = grantFor(origin);
+ return (g && g.profileId) || "hermes";
+ }
// ---- the prompt ---------------------------------------------------------
// One shape for every request so the user learns to read it: who is
- // asking, what they want, and what exactly gets signed.
- async function ask({ origin, title, body, rows, remember }) {
- const actions = [{ id: "deny", label: "Deny" }, { id: "allow", label: "Allow", primary: true }];
+ // asking, what they want, and what exactly gets signed. On the first
+ // approval for an origin, chooseProfile=true adds a profile-picker row:
+ // two distinct "Allow" buttons for the two identities (the user's Hermes
+ // key vs. a fresh per-site key); subsequent approvals reuse the stored
+ // profile so the modal stays a single-click yes/no.
+ async function ask({ origin, title, body, rows, remember, chooseProfile, previews }) {
+ const actions = chooseProfile
+ ? [
+ { id: "deny", label: "Deny" },
+ { id: "allow-origin", label: "Use site-specific identity", primary: true },
+ { id: "allow-hermes", label: "Use my Hermes identity" },
+ ]
+ : [{ id: "deny", label: "Deny" }, { id: "allow", label: "Allow", primary: true }];
+ // When choosing, show both npubs so the user sees exactly what each
+ // button will reveal to the site. previews is {hermes:{npub}, origin:{npub}}.
+ const rowList = (rows || []).slice();
+ if (chooseProfile && previews) {
+ if (previews.hermes && previews.hermes.npub) {
+ rowList.push({ label: "Your Hermes identity", value: previews.hermes.npub, mono: true });
+ }
+ if (previews.origin && previews.origin.npub) {
+ rowList.push({ label: `Site-specific for ${origin}`, value: previews.origin.npub, mono: true });
+ }
+ }
const r = await api.approvalModal({
- title, body, origin, rows: rows || [],
+ title, body, origin, rows: rowList,
actions,
checkbox: remember ? { id: "always", label: `Always allow this on ${origin}` } : null,
});
const s = String(r || "");
if (!s.startsWith("allow")) return { ok: false };
- return { ok: true, remember: s.includes("+always") };
+ let profileId;
+ if (s.startsWith("allow-origin")) profileId = "origin";
+ else if (s.startsWith("allow-hermes")) profileId = "hermes";
+ return { ok: true, remember: s.includes("+always"), profileId };
}
+ // Compute both npubs so the first-time chooser modal can show them side
+ // by side. Returns null on either if the vault has no identity.
+ async function identityPreviews(origin) {
+ try {
+ const h = await host().status({ profileId: "hermes" });
+ const o = await host().status({ profileId: "origin", origin });
+ return {
+ hermes: h.ready ? { npub: h.npub, pubkey: h.pubkey } : null,
+ origin: o.ready ? { npub: o.npub, pubkey: o.pubkey } : null,
+ ready: !!(h.ready && o.ready),
+ reason: h.ready ? (o.ready ? null : o.reason) : h.reason,
+ };
+ } catch (e) {
+ return { ready: false, reason: e && e.message || String(e) };
+ }
+ }
+
+ // gate() returns {origin, profileId} so callers know which identity to
+ // sign with. First-time gates show the profile chooser; subsequent calls
+ // reuse the stored profileId silently.
async function gate(ctx, need, prompt) {
const origin = ctx && ctx.origin;
if (!origin) throw new Error("this call did not come from a page");
- if (has(origin, need)) return origin;
- const r = await ask({ origin, remember: true, ...prompt });
+ if (has(origin, need)) return { origin, profileId: profileFor(origin) };
+ const previews = await identityPreviews(origin);
+ if (!previews.ready) throw new Error(previews.reason || "no identity available");
+ const r = await ask({
+ origin, remember: true,
+ chooseProfile: !grantFor(origin),
+ previews,
+ ...prompt,
+ });
if (!r.ok) throw new Error("denied");
- if (r.remember) setGrant(origin, need);
- return origin;
+ const profileId = r.profileId || profileFor(origin);
+ if (r.remember) setGrant(origin, need, profileId);
+ return { origin, profileId };
}
// Readable one-line summary of what a sign request actually is.
@@ -103,53 +178,73 @@ module.exports = {
// ---- page-facing methods ------------------------------------------------
api.onMessage("getPublicKey", async (_p, ctx) => {
- const st = await host().status();
- if (!st.ready) throw new Error(st.reason);
- await gate(ctx, "read", {
+ const { origin, profileId } = await gate(ctx, "read", {
title: "Share your Nostr identity?",
body: "This site is asking which Nostr account you are. It will see your public key — "
- + "the same one your notes are signed with. It cannot post or read messages with this alone.",
- rows: [{ label: "Your key", value: st.npub, mono: true }],
+ + "the same one your notes get signed with. It cannot post or read messages with this alone.\n\n"
+ + "Pick the Hermes identity if this site is one you want to be publicly linkable to the rest "
+ + "of your Nostr activity; pick a site-specific identity to keep this site isolated.",
});
- return (await host().getPublicKey()).pubkey;
+ return (await host().getPublicKey({ profileId, origin })).pubkey;
});
api.onMessage("signEvent", async (p, ctx) => {
- const st = await host().status();
- if (!st.ready) throw new Error(st.reason);
const ev = (p && p.event) || {};
const kind = Number(ev.kind);
const origin = ctx && ctx.origin;
if (!origin) throw new Error("this call did not come from a page");
- // Destructive or identity-rewriting kinds are shown every time, even on
- // a site the user already trusts for ordinary posting.
- const special = ALWAYS_ASK_KINDS.get(kind);
- if (special || !has(origin, "sign")) {
+ // A sign request promotes the grant to "sign" level. If no grant yet,
+ // this is also the profile-chooser moment.
+ let profileId;
+ if (!has(origin, "sign")) {
+ const previews = await identityPreviews(origin);
+ if (!previews.ready) throw new Error(previews.reason || "no identity available");
+ const chooseProfile = !grantFor(origin);
const rows = [{ label: "Event", value: describeEvent(ev) }];
if (typeof ev.content === "string" && ev.content) rows.push({ label: "Content", value: preview(ev.content) });
if (Array.isArray(ev.tags) && ev.tags.length) rows.push({ label: "Tags", value: preview(JSON.stringify(ev.tags), 120), mono: true });
+ const special = ALWAYS_ASK_KINDS.get(kind);
const r = await ask({
origin,
title: special ? "Sign something that changes your account?" : "Sign as you on Nostr?",
body: special
? `This site wants to ${special}. This is published under your name and other clients will follow it.`
: "This site wants to publish an event signed with your Nostr key. Anyone can see what you sign.",
- // Never offer "always" for the destructive kinds.
remember: !special,
+ chooseProfile,
+ previews,
rows,
});
if (!r.ok) throw new Error("denied");
- if (r.remember) setGrant(origin, "sign");
+ profileId = r.profileId || profileFor(origin);
+ if (r.remember) setGrant(origin, "sign", profileId);
+ } else {
+ profileId = profileFor(origin);
+ // Destructive kinds re-prompt every time even on a trusted site, but
+ // the profile is fixed (same profile this origin has already been
+ // posting with), so no chooser — just a per-event confirmation.
+ const special = ALWAYS_ASK_KINDS.get(kind);
+ if (special) {
+ const rows = [{ label: "Event", value: describeEvent(ev) }];
+ if (typeof ev.content === "string" && ev.content) rows.push({ label: "Content", value: preview(ev.content) });
+ if (Array.isArray(ev.tags) && ev.tags.length) rows.push({ label: "Tags", value: preview(JSON.stringify(ev.tags), 120), mono: true });
+ const r = await ask({
+ origin,
+ title: "Sign something that changes your account?",
+ body: `This site wants to ${special}. This is published under your name and other clients will follow it.`,
+ remember: false,
+ rows,
+ });
+ if (!r.ok) throw new Error("denied");
+ }
}
- return (await host().signEvent({ event: ev })).event;
+ return (await host().signEvent({ event: ev, profileId, origin })).event;
});
const cryptoCall = (name, need, label, legacy) =>
api.onMessage(name, async (p, ctx) => {
- const st = await host().status();
- if (!st.ready) throw new Error(st.reason);
- await gate(ctx, need, {
+ const { origin, profileId } = await gate(ctx, need, {
title: `Let this site ${label} with your key?`,
body: (legacy
? "This uses NIP-04, the older scheme, which leaks message length and who you are talking to. "
@@ -158,7 +253,7 @@ module.exports = {
rows: [{ label: "Other party", value: String((p && p.peer) || "?"), mono: true }],
});
const fn = host()[name];
- return (await fn.call(host(), p || {})).result;
+ return (await fn.call(host(), { ...(p || {}), profileId, origin })).result;
});
cryptoCall("nip04Encrypt", "sign", "encrypt", true);
@@ -172,16 +267,41 @@ module.exports = {
const panelOnly = (ctx) => {
if (ctx && ctx.from === "page") throw new Error("not available to pages");
};
- api.onMessage("status", async (_p, ctx) => { panelOnly(ctx); return host().status(); });
+ api.onMessage("status", async (p, ctx) => {
+ panelOnly(ctx);
+ // The panel's "status" is always the Hermes profile (that's the one it
+ // shows as "Your identity" at the top). Per-origin keys are listed in
+ // the grants section with their own derived npubs.
+ return host().status({ profileId: "hermes" });
+ });
api.onMessage("listGrants", async (_p, ctx) => {
panelOnly(ctx);
const g = allGrants();
- return {
- ok: true,
- grants: Object.entries(g)
- .map(([origin, v]) => ({ origin, level: v.level, at: v.at || 0 }))
- .sort((a, b) => b.at - a.at),
- };
+ const rows = Object.entries(g).map(([origin, v]) => ({
+ origin,
+ level: v.level,
+ at: v.at || 0,
+ profileId: v.profileId || "hermes",
+ profileLabel: PROFILE_LABELS[v.profileId || "hermes"] || "Hermes identity",
+ }));
+ // Enrich with the derived npub for site-specific grants so the panel
+ // can show the user exactly which key the site sees. Skip if the vault
+ // is locked (status not ready).
+ const h = await host().status({ profileId: "hermes" });
+ if (h.ready) {
+ for (const r of rows) {
+ if (r.profileId === "origin") {
+ try {
+ const o = await host().status({ profileId: "origin", origin: r.origin });
+ if (o.ready) r.npub = o.npub;
+ } catch {}
+ } else {
+ r.npub = h.npub;
+ }
+ }
+ }
+ rows.sort((a, b) => b.at - a.at);
+ return { ok: true, grants: rows };
});
api.onMessage("revoke", async (p, ctx) => {
panelOnly(ctx);
@@ -198,6 +318,6 @@ module.exports = {
return { ok: true };
});
- api.log("registered NIP-07 signer");
+ api.log("registered NIP-07 signer (per-origin profiles)");
},
};
diff --git a/bundled-addons/nostr/panel.html b/bundled-addons/nostr/panel.html
index a232fea7..2f64f95f 100644
--- a/bundled-addons/nostr/panel.html
+++ b/bundled-addons/nostr/panel.html
@@ -128,15 +128,28 @@
"No site has been allowed yet. The first time one asks, Theseus will show you what it wants."));
return;
}
- const rows = list.map((g) => 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" })),
- 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"); }
- } }))));
+ // 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" },
diff --git a/home-preload.js b/home-preload.js
index f387878f..133ec531 100644
--- a/home-preload.js
+++ b/home-preload.js
@@ -24,10 +24,6 @@ contextBridge.exposeInMainWorld("home", {
// Main pushes an updated list here after a successful remote refresh
// (only when the user has not customised locally). home.html re-renders.
onCards: (cb) => ipcRenderer.on("home-cards", (_e, list) => cb(list)),
- // Current Theseus version — the startpage renders a small
- // "Theseus v0.3.X — What's new" link beside the hero that points at
- // the release notes. IPC is the same one Settings uses; no network.
- getVersion: () => ipcRenderer.invoke("app-version"),
});
// Same preload also serves the branded error page (error.html). Handlers in
// main.js sender-check for error.html so a random page seeing the API shape
diff --git a/home.html b/home.html
index bcaf8416..b6a253b9 100644
--- a/home.html
+++ b/home.html
@@ -12,12 +12,12 @@
html, body { margin: 0; padding: 0; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--ink); }
body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); }
- /* Hero (identity + search). Lives inside the right-hand .main-col,
- centered within it. The outer .page grid handles the top padding so
- the hero's own top padding is 0 — this keeps the sidebar's top edge
- flush with the hero's top edge; the slots start at the same height
- as the Theseus logo + search box. */
- .hero { text-align: center; padding: 0 1rem; max-width: 820px; margin: 0 auto; }
+ /* Hero (identity + search). Centered at the top of the page — the
+ user's most-used thing on this screen. The slot + cards grid lives
+ beneath. The mark + wordmark share a translucent panel so they read
+ on any wallpaper; the tag, search box and hint sit outside it,
+ centered. */
+ .hero { text-align: center; padding: 5vh 1rem 0; max-width: 820px; margin: 0 auto; }
.hero .hero-id { display: inline-flex; align-items: center; gap: 14px;
padding: 12px 24px 12px 14px; border-radius: 22px;
background: rgba(11,14,20,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
@@ -39,58 +39,38 @@
.hero .hint { color: var(--mut); font-size: 12.5px; margin-top: .8rem;
display: inline-block; padding: 4px 12px; border-radius: 999px;
background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
- /* "Theseus v0.3.X — What's new ↗" line under the hint. Muted so it does
- not pull attention from the search box, but visible enough that a user
- who just updated has a one-click path to the release notes. Dynamic:
- version comes from the running app via home-preload. */
- .hero .ver-line { margin: .6rem 0 0; font-size: 11.5px; }
- .hero .ver-line a { color: var(--mut); text-decoration: none;
- padding: 2px 10px; border-radius: 999px;
- background: rgba(11,14,20,.3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
- .hero .ver-line a:hover { color: var(--acid); }
- /* Page-level 2-column layout: the sidebar is a left column that starts
- at the top of the page (same top edge as the hero's title) and spans
- the full height of the content, so the slot grid sits beside the
- Theseus logo + search box instead of below them. The main column
- stacks the hero (title + search + version link) on top of the Silent
- Mode cards. Narrow viewports collapse to one column: sidebar first,
- then hero, then cards. */
- .page { display: grid; grid-template-columns: minmax(240px, 280px) minmax(380px, 1fr);
- gap: 28px; max-width: 1680px; margin: 0 auto;
- padding: 32px 32px 48px; align-items: start; }
- .main-col { min-width: 0; display: flex; flex-direction: column; gap: 2rem; }
- @media (max-width: 900px) { .page { grid-template-columns: 1fr; gap: 20px; padding: 14px; }
- .main-col { gap: 1.4rem; } }
+ /* Two sections under the hero: a short "Your links" slot grid on the
+ LEFT (3 cols × 3 rows by default — matches the SM cards grid in
+ height so the two columns read as a single row of panels) and the
+ Silent Mode cards on the RIGHT. Each sits in its own soft panel so
+ they read as grouped collections, not a shared body. Collapses to
+ one column on narrow viewports. */
+ .homepage { display: grid; grid-template-columns: minmax(260px, 300px) minmax(380px, 1fr);
+ gap: 24px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; }
+ @media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
- /* The RIGHT column keeps its subtle panel surface — the Silent Mode cards
- read as a grouped collection and benefit from the soft tint. The LEFT
- column opts out via the chromeless class below, which overrides this. */
.section { background: rgba(255,255,255,.025); border-radius: 16px;
padding: 16px 16px 20px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03);
width: 100%; min-width: 0; }
body.light .section { background: rgba(0,0,0,.03); box-shadow: inset 0 0 0 1px rgba(0,0,0,.03); }
+ /* The slot column opts out of the section panel chrome — slots float on
+ the page background so the icons read as "yours, loose on the desktop"
+ while the SM cards stay in a grouped panel on the right. */
.section.chromeless { background: transparent; box-shadow: none; padding: 4px 0 0; }
body.light .section.chromeless { background: transparent; box-shadow: none; }
.section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px;
color: var(--mut); margin: 0 2px 14px; }
- /* LEFT AREA — a fixed 3-column grid of slots. Each slot holds one link
- tile; empty slots are invisible by default and reveal subtly on
- container hover so the user can see where a dragged tile will land.
- 3 × 72 px tiles + 2 × 8 px gaps = 232 px — the sidebar column is sized
- to that so the grid sits flush with the hero and doesn't float in
- half-empty whitespace. */
- /* The sidebar grid cell stretches to match the main column's height, so
- the sidebar panel is as tall as the hero + Silent Mode cards combined.
- The slots-list fills that full area: 3 columns at 1fr each (stretch to
- sidebar width — the slot's inner tile stays 48 px and centers inside
- the wider cell), and enough rows to cover the sidebar's vertical span
- (row count computed in render() from the main-col height, so a taller
- page gets more rows automatically). */
- .sidebar { align-self: stretch; }
- .slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px;
- width: 100%; align-items: start; justify-content: stretch; }
+ /* LEFT COLUMN — a fixed 3×3 slot grid (72 px cells, 232 px wide). The
+ grid centers inside its panel so it doesn't lean to one side when
+ the panel is wider than the slot grid. Empty slots stay invisible
+ by default and reveal subtly on hover, acting as drop targets AND
+ click-to-add surfaces. */
+ .left-column { align-self: start; }
+ .slots-list { display: grid; grid-template-columns: repeat(3, 72px); gap: 10px 8px;
+ width: max-content; align-items: start; justify-content: start;
+ margin: 0 auto; }
.cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
.slot { position: relative; display: flex; flex-direction: column; align-items: center;
@@ -145,11 +125,10 @@
.slot-empty:hover::before { border-color: rgb(from var(--acid) r g b / .35); background: rgb(from var(--acid) r g b / .03); }
.slot-empty.drop-hot::before { border-color: var(--acid); background: rgb(from var(--acid) r g b / .14); }
- /* Silent Mode cards area — lives under the hero inside .main-col. The
- .cards-wrap keeps the subtle panel surface (background, inset border,
- rounded corners) so the cards read as a grouped collection; the
- sidebar is chromeless so only this side has panel chrome. */
- .cards-wrap { align-self: stretch; }
+ /* RIGHT COLUMN — the Silent Mode cards. Keeps its subtle panel surface
+ (background, inset border, rounded corners) same as the slot panel
+ on the left, so the two columns match. */
+ .right-column { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
.cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-content: start; }
@media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } }
@@ -266,8 +245,6 @@
body.light .card .card-title,
body.light .card .card-sub { color: var(--ink); }
body.light .card .card-sub { color: var(--mut); }
- body.light .hero .ver-line a { background: rgba(255,255,255,.5); color: var(--mut); }
- body.light .hero .ver-line a:hover { color: var(--acid); }
body.light .card-add { background: rgba(255,255,255,.35); color: var(--mut); border-color: var(--line2); }
body.light .card-add:hover { color: var(--acid); border-color: var(--acid); }
body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px rgba(255,255,255,.65); }
@@ -294,35 +271,33 @@
-
-