theseus/approval.html
Local Dev ffeda26345 feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:

- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
  vault is unlocked with a 32-byte HKDF child of the vault root under
  "silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
  preload asks main (sync, against the committed URL) which add-on bridges
  apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
  checkbox}) shows a consent overlay over the tab area (approval.html);
  resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
  messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00

90 lines
5.7 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Approval</title>
<style>
:root { color-scheme: light dark;
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; }
@media (prefers-color-scheme: light) {
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: transparent; }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: grid; place-items: start center; padding-top: 48px; }
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px 14px; width: min(460px, calc(100vw - 32px));
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
.who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
.who .addon { color: var(--mut); }
.title { font-size: 15px; font-weight: 650; letter-spacing: .1px; margin: 0 0 10px; }
.origin { display: inline-flex; align-items: center; gap: 8px; max-width: 100%;
background: var(--surface2); border: 1px solid rgba(214,255,61,.35); color: var(--acid);
border-radius: 8px; padding: 6px 10px; margin-bottom: 12px;
font: 13px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
.origin .lbl { color: var(--dim); font: 11px system-ui, sans-serif; white-space: nowrap; }
.body { white-space: pre-wrap; color: var(--ink); margin-bottom: 12px; }
.rows { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin-bottom: 12px;
background: var(--surface2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
.rows .k { color: var(--dim); font-size: 12px; white-space: nowrap; }
.rows .v { word-break: break-all; }
.rows .v.mono { font: 12.5px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; }
.rows .v.strong { font-weight: 650; font-size: 14px; }
label.chk { display: flex; align-items: center; gap: 8px; color: var(--mut); font-size: 12.5px; margin-bottom: 12px; cursor: pointer; }
.pact { display: flex; gap: 6px; justify-content: flex-end; }
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
.pbtn:hover { border-color: rgba(214,255,61,.35); }
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn.danger { background: var(--danger); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn:focus-visible { outline: 2px solid rgba(214,255,61,.6); outline-offset: 1px; }
</style>
</head>
<body>
<script>
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]);
let current = null;
function finish(action, checked) {
if (!current) return;
const { reqId } = current;
current = null;
document.body.innerHTML = "";
window.approval.pick(reqId, action, checked);
}
window.approval.onShow((req) => {
current = req;
const rows = Array.isArray(req.rows) ? req.rows : [];
const actions = Array.isArray(req.actions) && req.actions.length ? req.actions : [{ id: "ok", label: "OK", primary: true }];
const hasCancel = actions.some((a) => a.id === "cancel");
document.body.innerHTML =
`<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true">
<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div>
<h1 class="title">${esc(req.title || "Approve?")}</h1>
${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""}
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}
${rows.length ? `<div class="rows">${rows.map((r) => `<div class="k">${esc(r.label)}</div><div class="v${r.mono ? " mono" : ""}${r.strong ? " strong" : ""}">${esc(r.value)}</div>`).join("")}</div>` : ""}
${req.checkbox ? `<label class="chk"><input type="checkbox" id="chk"> ${esc(req.checkbox.label || "Always allow")}</label>` : ""}
<div class="pact">
${hasCancel ? "" : `<button class="pbtn" type="button" data-id="cancel">Cancel</button>`}
${actions.map((a) => `<button class="pbtn${a.primary ? " primary" : ""}${a.danger ? " danger" : ""}" type="button" data-id="${esc(a.id)}">${esc(a.label || a.id)}</button>`).join("")}
</div>
</div></div>`;
const mask = document.querySelector(".promptmask");
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish("cancel", false); });
document.querySelectorAll("button[data-id]").forEach((b) => {
b.addEventListener("click", () => finish(b.dataset.id, !!document.getElementById("chk")?.checked));
});
// Focus the non-destructive default so Enter never blindly approves a
// spend; the user has to tab or click onto the primary action.
const first = document.querySelector('button[data-id="cancel"]') || document.querySelector("button");
setTimeout(() => first && first.focus(), 0);
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") finish("cancel", false); });
</script>
</body>
</html>