diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9130506..1338e2d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,14 @@ public API changes with it.
bounded subscription counts, and targeted invalidation.
- **Security requirements suite** (`tests/security/`) covering the enumerated
requirements testable at this layer.
+- **`@nostr-governance/widget`**: framework-agnostic custom elements. Viewer
+ surfaces (`governance-veil`, `governance-report`, `governance-status`) and
+ moderator surfaces (`governance-capabilities`, `governance-action`,
+ `governance-admin-panel`), plus a runnable demo page. Elements render
+ decisions and issue commands; they compute no policy.
+- **Runtime wiring**: storage persistence and hydration, signature verification
+ in the load path, trusted mute-list subscriptions, community source
+ resolution, root-policy application, import/export, and capability queries.
- GPL-3.0-or-later `LICENSE`, `README`, and an integration guide.
### Fixed
@@ -59,6 +67,12 @@ public API changes with it.
governed by several targets lost the evidence that drove its verdict.
- `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking
subscribers on teardown. Now `clearOverrides()`.
+- `GovernanceAdminStore` emitted only when reduced denial state changed, so
+ revoking a role that happened to deny nobody announced nothing — leaving
+ capability-gated UI and the decision cache stale. It now also emits on
+ authority changes.
+- `ReportStore.ingest` required a `target` field it never read, since the
+ target is identified by the key argument.
- Evaluation was quadratic in state size: both the snapshot and its fingerprint
were rebuilt for every target, so each call walked every report and mute
list. A 5,000-target pass took ~47s. The runtime now memoizes the snapshot
diff --git a/README.md b/README.md
index 095026d..afb0cb5 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ equivalent to following the operator's seed list.
| `@nostr-governance/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
| `@nostr-governance/runtime` | Stores, transport orchestration, commands. |
| `@nostr-governance/testing` | Conformance harness. |
+| `@nostr-governance/widget` | Drop-in custom elements: viewer controls and a moderator console. |
| `@nostr-governance/bitvid-compat` | Reference application profile for the characterization corpus. |
## Quick start
@@ -116,6 +117,30 @@ address denial that survives republication, malware-driven checkout blocking,
capability-scoped moderator roles, community curator ingestion, and
seller-facing explanations. See [`docs/integration-guide.md`](docs/integration-guide.md).
+## Plug-and-play UI
+
+`@nostr-governance/widget` ships custom elements that render decisions and issue
+commands — and compute no policy of their own:
+
+```html
+
+```
+
+```js
+import { defineGovernanceElements } from "@nostr-governance/widget";
+defineGovernanceElements();
+
+const veil = document.querySelector("governance-veil");
+veil.runtime = runtime;
+veil.target = { type: "event", id, author };
+```
+
+Viewer elements (`governance-veil`, `governance-report`, `governance-status`)
+and moderator elements (`governance-capabilities`, `governance-action`,
+`governance-admin-panel`) are separate, so a site embeds only what it needs.
+See [`packages/widget/README.md`](packages/widget/README.md) and the runnable
+[`examples/commerce/demo.html`](examples/commerce/demo.html).
+
## Authority model
Root-authorized contributors: the root publishes the role roster, each actor
diff --git a/examples/commerce/demo.html b/examples/commerce/demo.html
new file mode 100644
index 0000000..e9cf0cb
--- /dev/null
+++ b/examples/commerce/demo.html
@@ -0,0 +1,190 @@
+
+
+
+
+
+ nostr-governance widget demo
+
+
+
+
nostr-governance widget
+
+ Every element below reads from one shared runtime. Toggle governance state
+ and watch each surface reach its own conclusion from the same evidence.
+
+
+
+
Viewer surface — <governance-veil>
+
+
+
+
Widget — 1000 sats
+
+
+
+
+
+
+
+
+
+
+
+
Same product, four surfaces
+
+ One snapshot, four profiles. This is the state a single
+ hidden boolean cannot express.
+
+
+
+
+
+
Status readout — <governance-status>
+
+
+
+
+
Moderator console
+
+ Signed in as a listing moderator, who may restrict
+ listings but not sellers. Note that the refused control stays visible
+ and says what it needs.
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 108e891..8efde46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
],
"devDependencies": {
"@types/node": "^22.10.0",
+ "happy-dom": "^20.11.2",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
},
@@ -463,6 +464,10 @@
"resolved": "packages/testing",
"link": true
},
+ "node_modules/@nostr-governance/widget": {
+ "resolved": "packages/widget",
+ "link": true
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
@@ -837,6 +842,23 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@@ -960,6 +982,19 @@
"node": "*"
}
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1065,6 +1100,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1176,6 +1224,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.11.2",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz",
+ "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@@ -1833,6 +1900,16 @@
}
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -1866,6 +1943,28 @@
"node": ">=8"
}
},
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -1912,6 +2011,14 @@
"dependencies": {
"@nostr-governance/core": "*"
}
+ },
+ "packages/widget": {
+ "name": "@nostr-governance/widget",
+ "version": "0.1.0",
+ "dependencies": {
+ "@nostr-governance/core": "*",
+ "@nostr-governance/runtime": "*"
+ }
}
}
}
diff --git a/package.json b/package.json
index 1b3d92f..564ecf8 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,10 @@
"test:unit": "vitest run --passWithNoTests",
"test:conformance": "vitest run tests/conformance --passWithNoTests",
"typecheck": "tsc -p tsconfig.json --noEmit",
- "build:types": "tsc -p packages/core/tsconfig.types.json && tsc -p packages/nostr/tsconfig.types.json && tsc -p packages/runtime/tsconfig.types.json && tsc -p packages/bitvid-compat/tsconfig.types.json && tsc -p packages/testing/tsconfig.types.json",
+ "build:types": "tsc -p packages/core/tsconfig.types.json && tsc -p packages/nostr/tsconfig.types.json && tsc -p packages/runtime/tsconfig.types.json && tsc -p packages/bitvid-compat/tsconfig.types.json && tsc -p packages/widget/tsconfig.types.json && tsc -p packages/testing/tsconfig.types.json",
"lint": "npm run check:references && npm run check:types",
"check:references": "node scripts/verify-no-reference-imports.mjs",
- "check:types": "npm run typecheck"
+ "check:types": "tsc -p tsconfig.json --noEmit && tsc -p packages/widget/tsconfig.json --noEmit"
},
"engines": {
"node": ">=22"
@@ -23,6 +23,7 @@
],
"devDependencies": {
"@types/node": "^22.10.0",
+ "happy-dom": "^20.11.2",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
diff --git a/packages/runtime/src/stores.js b/packages/runtime/src/stores.js
index 7f50302..d00f066 100644
--- a/packages/runtime/src/stores.js
+++ b/packages/runtime/src/stores.js
@@ -38,6 +38,8 @@ export class GovernanceAdminStore extends Emitter {
this.state = createEmptyAdminState();
/** @type {string} */
this.fingerprint = fingerprint(serializeAdminState(this.state));
+ /** @type {string} */
+ this.authorityFingerprint = this.#computeAuthorityFingerprint();
}
/**
@@ -53,6 +55,15 @@ export class GovernanceAdminStore extends Emitter {
this.#recompute();
}
+ #computeAuthorityFingerprint() {
+ return fingerprint({
+ root: this.authority.root ?? "",
+ actors: this.authority.actors,
+ roles: this.authority.roles,
+ protectedActors: this.authority.protectedActors,
+ });
+ }
+
/**
* Replace every contribution.
* @param {Contribution[]} contributions
@@ -104,11 +115,22 @@ export class GovernanceAdminStore extends Emitter {
#recompute() {
const next = reduceAdminState(this.contributions, this.authority);
const nextFingerprint = fingerprint(serializeAdminState(next));
+ const nextAuthority = this.#computeAuthorityFingerprint();
+
this.state = next;
- if (nextFingerprint !== this.fingerprint) {
- this.fingerprint = nextFingerprint;
- this.emit("change", { fingerprint: nextFingerprint });
+ // A roster change can alter capabilities and protected actors without
+ // changing a single denial entry. Emitting only on reduced state would
+ // leave capability-gated UI and the decision cache stale after a
+ // revocation that happened to deny nobody.
+ const changed =
+ nextFingerprint !== this.fingerprint || nextAuthority !== this.authorityFingerprint;
+
+ this.fingerprint = nextFingerprint;
+ this.authorityFingerprint = nextAuthority;
+
+ if (changed) {
+ this.emit("change", { fingerprint: nextFingerprint, authorityFingerprint: nextAuthority });
}
}
}
@@ -223,7 +245,10 @@ export class ReportStore extends Emitter {
* structural: re-ingesting the same report cannot inflate a count, which
* matters because relays routinely deliver duplicates.
*
- * @param {{ reporter: string, target: import('@nostr-governance/core').GovernanceTarget, category: string, createdAt?: number }} report
+ * The target is identified by `targetKey`; a decoded report's own `target`
+ * field is accepted but not required, since it would be redundant.
+ *
+ * @param {{ reporter: string, category: string, createdAt?: number, target?: import('@nostr-governance/core').GovernanceTarget }} report
* @param {string} targetKey
*/
ingest(report, targetKey) {
diff --git a/packages/runtime/test/stores.test.js b/packages/runtime/test/stores.test.js
index bba4c78..9dcc547 100644
--- a/packages/runtime/test/stores.test.js
+++ b/packages/runtime/test/stores.test.js
@@ -382,3 +382,37 @@ describe("OverrideStore", () => {
expect(handler).toHaveBeenCalledTimes(3);
});
});
+
+describe("GovernanceAdminStore authority changes", () => {
+ it("emits when a role is revoked even if no denial changes", () => {
+ const store = new GovernanceAdminStore();
+ store.setRoles(roster);
+ const handler = vi.fn();
+ store.on("change", handler);
+
+ // No contributions exist, so effective denial state is empty before and
+ // after. The capability change must still be announced.
+ store.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("emits when protected actors change", () => {
+ const store = new GovernanceAdminStore();
+ store.setRoles(roster);
+ const handler = vi.fn();
+ store.on("change", handler);
+
+ store.setRoles({ ...roster, protectedActors: [CREATOR] });
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("stays quiet when the roster is set to the same thing", () => {
+ const store = new GovernanceAdminStore();
+ store.setRoles(roster);
+ const handler = vi.fn();
+ store.on("change", handler);
+
+ store.setRoles(roster);
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/widget/README.md b/packages/widget/README.md
new file mode 100644
index 0000000..f398eea
--- /dev/null
+++ b/packages/widget/README.md
@@ -0,0 +1,116 @@
+# @nostr-governance/widget
+
+Drop-in custom elements for governance UI. Framework-agnostic, no build step
+required for consumers, and built strictly on the published API.
+
+## The rule these elements follow
+
+**They render decisions and issue commands. They compute no policy.**
+
+If an element here ever needed to compare a count to a threshold, that would
+mean the policy definition is missing a profile — not that the widget needs a
+number. Keeping that line is the whole reason the engine was extracted.
+
+## Elements
+
+### Viewer-facing
+
+| Element | Purpose |
+| --- | --- |
+| `` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` |
+| `` | Report dialog. Requires no capability — anyone may report |
+| `` | Readout of effect, reasons, and evidence |
+
+### Moderator-facing
+
+| Element | Purpose |
+| --- | --- |
+| `` | What the signed-in account may actually do |
+| `` | A capability-gated button that explains refusals |
+| `` | Effective state, contributor attribution, and controls |
+
+## Usage
+
+```html
+
+
+
+
+
+```
+
+Elements receive a runtime; they never construct one. A widget that owned relay
+connections would make it impossible to share one governance runtime across a
+page.
+
+## Events
+
+All events are `composed` so they cross shadow boundaries.
+
+| Event | Fired when |
+| --- | --- |
+| `governance:revealed` | The viewer chose to show hidden content |
+| `governance:reported` / `governance:report-failed` | A report was published or refused |
+| `governance:action-completed` / `:action-refused` / `:action-failed` | An action resolved |
+| `governance:account-restricted` | The admin panel restricted an account |
+
+## Wording
+
+The engine emits stable reason identifiers, never prose, so applications phrase
+them for their own audience:
+
+```js
+veil.reasonText = {
+ "admin-user-deny": "This seller is suspended",
+ "trusted-report-threshold": "Flagged by several buyers you follow",
+};
+```
+
+## Design decisions worth knowing
+
+**Hidden content is removed from the accessibility tree**, not merely covered.
+A screen reader must not read out something the viewer was not meant to see.
+
+**A hidden decision renders a disclosure, not nothing.** Content that silently
+vanishes is indistinguishable from a bug and leaves no route to appeal. Where
+the profile permits an override, a "Show anyway" control appears; where it does
+not, the reason still does.
+
+**Refused actions stay visible and explain themselves.** A moderator who cannot
+see that they lack a capability will assume the tool is broken, and a hidden
+button teaches nothing about the authority model. The command layer enforces
+the same check independently — the UI is an explanation, never the gate.
+
+**Styling is yours.** Elements expose `part` attributes and inherit `color` and
+`font` from the host. Override the CSS custom properties (`--gov-fg`,
+`--gov-surface`, `--gov-warn`, `--gov-danger`, `--gov-radius`) or target parts:
+
+```css
+governance-veil::part(veil) { border: 1px solid red; }
+```
+
+## Environment
+
+`defineGovernanceElements()` is safe to call repeatedly and safe to import in
+Node — registration is a no-op without a DOM, so a server-rendering host can
+import the helpers without guarding.
diff --git a/packages/widget/package.json b/packages/widget/package.json
new file mode 100644
index 0000000..c9d0315
--- /dev/null
+++ b/packages/widget/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@nostr-governance/widget",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.js",
+ "types": "dist/types/index.d.ts",
+ "files": ["src", "dist"],
+ "exports": {
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./src/index.js"
+ },
+ "./dist/types/*": "./dist/types/*"
+ },
+ "scripts": {
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@nostr-governance/core": "*",
+ "@nostr-governance/runtime": "*"
+ }
+}
diff --git a/packages/widget/src/admin.js b/packages/widget/src/admin.js
new file mode 100644
index 0000000..2d0a98b
--- /dev/null
+++ b/packages/widget/src/admin.js
@@ -0,0 +1,295 @@
+// Moderator console elements.
+//
+// Two rules govern this file.
+//
+// First, the UI shows *why* an action is unavailable rather than hiding the
+// control. A moderator who cannot see that they lack a capability will assume
+// the tool is broken, and a hidden button teaches nothing about the authority
+// model. The command layer enforces the same check independently, so the UI is
+// an explanation, never the gate.
+//
+// Second, nothing here computes policy. The console displays effective state
+// and issues commands; every accept/reject decision belongs to the runtime.
+
+import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js";
+
+/**
+ * @typedef {import('@nostr-governance/runtime').GovernanceCommands} GovernanceCommands
+ * @typedef {import('@nostr-governance/core').GovernanceCapability} GovernanceCapability
+ */
+
+/** Human wording for capabilities, used when explaining a refusal. */
+export const CAPABILITY_LABELS = {
+ "manage-roles": "manage roles",
+ "manage-policy": "manage policy",
+ "manage-community-sources": "manage community sources",
+ "contribute-user-allow": "approve accounts",
+ "contribute-user-deny": "restrict accounts",
+ "contribute-event-deny": "restrict posts",
+ "contribute-address-deny": "restrict listings",
+ "contribute-trust-seed": "manage trust seeds",
+ "review-evidence": "review evidence",
+};
+
+/**
+ * `` — what the signed-in moderator may do.
+ *
+ * Worth rendering explicitly: in the previous generation of this system,
+ * moderator status was a UI permission with no cryptographic meaning. Showing
+ * the resolved capability set makes the difference visible.
+ */
+export class GovernanceCapabilities extends GovernanceElement {
+ render() {
+ if (!this.shadowRoot) {
+ return;
+ }
+ if (!this.runtime) {
+ this.renderHtml("");
+ return;
+ }
+
+ const viewer = this.runtime.viewerPubkey;
+ const held = new Set(this.runtime.viewerCapabilities());
+
+ if (!viewer) {
+ this.renderHtml('
+ `);
+
+ this.$("#run")?.addEventListener("click", () => void this._run());
+ }
+
+ async _run() {
+ if (!this.action) {
+ return;
+ }
+ this._status = "Working…";
+ this.render();
+
+ try {
+ const result = await this.action();
+ // Commands return a result object rather than throwing on refusal, so a
+ // refusal is reported with its stable error code.
+ this._status = result?.ok === false ? `Refused: ${result.code}` : "Done";
+ this.emit(result?.ok === false ? "action-refused" : "action-completed", { result });
+ } catch (error) {
+ this._status = `Failed: ${String(error)}`;
+ this.emit("action-failed", { error: String(error) });
+ }
+
+ this.render();
+ }
+}
+
+/**
+ * `` — effective administrative state plus controls.
+ *
+ * Shows what is currently in force and who contributed it, so a moderator can
+ * see that a denial came from a community list rather than from their own team.
+ */
+export class GovernanceAdminPanel extends GovernanceElement {
+ constructor() {
+ super();
+ /** @type {GovernanceCommands|null} */
+ this.commands = null;
+ this._status = "";
+ }
+
+ render() {
+ if (!this.shadowRoot) {
+ return;
+ }
+ if (!this.runtime) {
+ this.renderHtml('
+ `);
+
+ this.$("#deny")?.addEventListener("click", () => void this._deny());
+ }
+
+ async _deny() {
+ const input = /** @type {HTMLInputElement} */ (this.$("#pubkey"));
+ const pubkey = input?.value?.trim() ?? "";
+ if (!pubkey || !this.commands) {
+ return;
+ }
+
+ this._status = "Publishing…";
+ this.render();
+
+ const result = await this.commands.denyUser(pubkey);
+ this._status = result.ok ? "Restricted" : `Refused: ${result.code}`;
+ this.emit(result.ok ? "account-restricted" : "action-refused", { pubkey, result });
+ this.render();
+ }
+}
+
+/**
+ * Register the moderator elements.
+ * @returns {string[]} Element names newly registered
+ */
+export function defineAdminElements() {
+ const registered = [];
+ if (defineElement("governance-capabilities", GovernanceCapabilities)) {
+ registered.push("governance-capabilities");
+ }
+ if (defineElement("governance-action", GovernanceAction)) registered.push("governance-action");
+ if (defineElement("governance-admin-panel", GovernanceAdminPanel)) {
+ registered.push("governance-admin-panel");
+ }
+ return registered;
+}
diff --git a/packages/widget/src/base.js b/packages/widget/src/base.js
new file mode 100644
index 0000000..8885b2d
--- /dev/null
+++ b/packages/widget/src/base.js
@@ -0,0 +1,195 @@
+// Shared base for governance custom elements.
+//
+// Every element here follows one rule: it renders decisions and issues
+// commands, and computes no policy of its own. The moment a component compares
+// a count to a threshold, the duplication this project exists to remove has
+// come back in the view layer.
+//
+// Elements receive a runtime rather than constructing one. A widget that owned
+// relay connections would make it impossible for a host application to share
+// one governance runtime across its whole page.
+
+/**
+ * @typedef {import('@nostr-governance/runtime').GovernanceRuntime} GovernanceRuntime
+ * @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
+ * @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
+ */
+
+/**
+ * Whether custom elements can be defined in this environment.
+ *
+ * The package is importable in Node so that a server-rendering host can pull in
+ * the helpers without a DOM; only registration requires a browser.
+ *
+ * @returns {boolean}
+ */
+export function canRegisterElements() {
+ return typeof globalThis.HTMLElement === "function" && typeof globalThis.customElements === "object";
+}
+
+/** Base styles shared by every element, scoped inside each shadow root. */
+export const BASE_STYLES = `
+ :host {
+ --gov-fg: currentColor;
+ --gov-muted: color-mix(in srgb, currentColor 60%, transparent);
+ --gov-border: color-mix(in srgb, currentColor 20%, transparent);
+ --gov-surface: color-mix(in srgb, canvas 96%, currentColor);
+ --gov-warn: #b45309;
+ --gov-danger: #b91c1c;
+ --gov-radius: 8px;
+ display: block;
+ color: var(--gov-fg);
+ font: inherit;
+ }
+ :host([hidden]) { display: none; }
+ button {
+ font: inherit;
+ color: inherit;
+ background: var(--gov-surface);
+ border: 1px solid var(--gov-border);
+ border-radius: var(--gov-radius);
+ padding: 0.4em 0.8em;
+ cursor: pointer;
+ }
+ button:hover:not(:disabled) { border-color: var(--gov-fg); }
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
+ .reason {
+ color: var(--gov-muted);
+ font-size: 0.875em;
+ }
+ .row { display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; }
+ .stack { display: grid; gap: 0.75em; }
+`;
+
+/**
+ * Escape text for interpolation into element markup.
+ *
+ * Reason identifiers and pubkeys are the only untrusted-ish values these
+ * elements render, but they arrive from relays, so they are escaped rather
+ * than trusted to be well-formed.
+ *
+ * @param {unknown} value
+ * @returns {string}
+ */
+export function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * Shorten a pubkey for display without pretending to resolve a profile.
+ * @param {string} pubkey
+ * @returns {string}
+ */
+export function shortenKey(pubkey) {
+ const value = String(pubkey ?? "");
+ return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
+}
+
+/**
+ * Base element: owns a shadow root, a runtime reference, and change
+ * subscription lifecycle.
+ *
+ * Subclasses implement `render()` and may implement `template()`.
+ */
+export class GovernanceElement extends globalThis.HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ /** @type {GovernanceRuntime|null} */
+ this._runtime = null;
+ /** @type {(() => void)|null} */
+ this._unsubscribe = null;
+ }
+
+ /**
+ * The governance runtime this element reads from.
+ * @returns {GovernanceRuntime|null}
+ */
+ get runtime() {
+ return this._runtime;
+ }
+
+ set runtime(runtime) {
+ if (this._runtime === runtime) {
+ return;
+ }
+ this._unsubscribe?.();
+ this._unsubscribe = null;
+ this._runtime = runtime ?? null;
+
+ if (this._runtime && this.isConnected) {
+ this._subscribe();
+ }
+ this.render();
+ }
+
+ connectedCallback() {
+ if (this._runtime && !this._unsubscribe) {
+ this._subscribe();
+ }
+ this.render();
+ }
+
+ disconnectedCallback() {
+ // Elements are routinely moved or removed during rendering; leaving a
+ // subscription behind would keep a detached element re-rendering forever.
+ this._unsubscribe?.();
+ this._unsubscribe = null;
+ }
+
+ _subscribe() {
+ this._unsubscribe = /** @type {GovernanceRuntime} */ (this._runtime).on("change", () =>
+ this.render(),
+ );
+ }
+
+ /**
+ * Emit a namespaced, composed event so hosts can react across shadow bounds.
+ * @param {string} name
+ * @param {any} detail
+ */
+ emit(name, detail) {
+ this.dispatchEvent(
+ new CustomEvent(`governance:${name}`, { detail, bubbles: true, composed: true }),
+ );
+ }
+
+ /** Replace shadow content. */
+ renderHtml(html) {
+ /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `${html}`;
+ }
+
+ /** @param {string} selector */
+ $(selector) {
+ return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector(selector);
+ }
+
+ /** Subclass hook. */
+ render() {}
+}
+
+/**
+ * Register an element, tolerating repeat registration.
+ *
+ * A page that loads the widget bundle twice should not throw; the first
+ * definition wins.
+ *
+ * @param {string} name
+ * @param {CustomElementConstructor} constructor
+ * @returns {boolean} Whether this call performed the registration
+ */
+export function defineElement(name, constructor) {
+ if (!canRegisterElements()) {
+ return false;
+ }
+ if (globalThis.customElements.get(name)) {
+ return false;
+ }
+ globalThis.customElements.define(name, constructor);
+ return true;
+}
diff --git a/packages/widget/src/index.js b/packages/widget/src/index.js
new file mode 100644
index 0000000..4d2676a
--- /dev/null
+++ b/packages/widget/src/index.js
@@ -0,0 +1,51 @@
+// @nostr-governance/widget
+//
+// Custom elements built strictly on the published API. They render decisions
+// and issue commands; they compute no policy. If an element here ever needs a
+// threshold, that is a signal the policy definition is missing a profile —
+// not that the widget needs a number.
+//
+// Elements receive a runtime rather than creating one, so a host application
+// can share a single governance runtime across an entire page.
+
+export {
+ BASE_STYLES,
+ GovernanceElement,
+ canRegisterElements,
+ defineElement,
+ escapeHtml,
+ shortenKey,
+} from "./base.js";
+
+export {
+ DEFAULT_REASON_TEXT,
+ GovernanceReport,
+ GovernanceStatus,
+ GovernanceVeil,
+ defineViewerElements,
+ describeReasons,
+} from "./viewer.js";
+
+export {
+ CAPABILITY_LABELS,
+ GovernanceAction,
+ GovernanceAdminPanel,
+ GovernanceCapabilities,
+ defineAdminElements,
+} from "./admin.js";
+
+import { defineAdminElements } from "./admin.js";
+import { defineViewerElements } from "./viewer.js";
+
+/**
+ * Register every governance element.
+ *
+ * Safe to call more than once and safe to import in Node: registration is a
+ * no-op without a DOM, so a server-rendering host can import the helpers
+ * without guarding.
+ *
+ * @returns {string[]} Element names newly registered by this call
+ */
+export function defineGovernanceElements() {
+ return [...defineViewerElements(), ...defineAdminElements()];
+}
diff --git a/packages/widget/src/viewer.js b/packages/widget/src/viewer.js
new file mode 100644
index 0000000..bcde5e4
--- /dev/null
+++ b/packages/widget/src/viewer.js
@@ -0,0 +1,381 @@
+// Viewer-facing governance elements.
+//
+// These render a decision the engine already made. They never decide anything:
+// no thresholds, no precedence, no "if reports > n". If one of these needs a
+// number, the number belongs in a policy profile.
+
+import { GovernanceElement, defineElement, escapeHtml, shortenKey } from "./base.js";
+
+/**
+ * @typedef {import('@nostr-governance/core').GovernanceDecision} GovernanceDecision
+ * @typedef {import('@nostr-governance/core').GovernanceTarget} GovernanceTarget
+ */
+
+/**
+ * Default wording for reason identifiers.
+ *
+ * Deliberately overridable: the engine emits stable identifiers precisely so
+ * that applications can phrase them for their own audience. A marketplace says
+ * "this seller is suspended"; a video app says "hidden by moderators".
+ *
+ * @type {Record}
+ */
+export const DEFAULT_REASON_TEXT = {
+ "viewer-block": "You blocked this account",
+ "viewer-mute": "You muted this account",
+ "viewer-override": "You chose to show this",
+ "admin-user-deny": "Restricted by moderators",
+ "admin-event-deny": "This post was restricted by moderators",
+ "admin-address-deny": "This listing was restricted by moderators",
+ "community-user-deny": "Restricted by a community list",
+ "trusted-report": "Reported by people you follow",
+ "trusted-report-threshold": "Reported by enough people you follow",
+ "trusted-mute": "Muted by people you follow",
+ "trusted-mute-threshold": "Muted by enough people you follow",
+ "allowlist-miss": "This account is not approved here",
+ "protected-target": "This account cannot be restricted",
+ "surface-policy-bypass": "Shown here despite restrictions",
+ "policy-disabled": "Moderation is off for this view",
+};
+
+/**
+ * Turn reason identifiers into display text.
+ * @param {GovernanceDecision} decision
+ * @param {Record} [overrides]
+ * @returns {string[]}
+ */
+export function describeReasons(decision, overrides = {}) {
+ const table = { ...DEFAULT_REASON_TEXT, ...overrides };
+ return decision.reasons.map((reason) => table[reason.id] ?? reason.id);
+}
+
+/**
+ * `` — wraps content and applies a decision to it.
+ *
+ * Slotted content is always in the DOM; the element controls whether it is
+ * shown, blurred, or replaced. Hidden content is removed from the accessibility
+ * tree rather than merely visually covered, so a screen reader does not read
+ * out something the viewer was not meant to see.
+ *
+ * Usage:
+ *
+ * veil.runtime = runtime; veil.target = { type: "event", id, author };
+ */
+export class GovernanceVeil extends GovernanceElement {
+ static get observedAttributes() {
+ return ["profile"];
+ }
+
+ constructor() {
+ super();
+ /** @type {GovernanceTarget|null} */
+ this._target = null;
+ /** @type {Record} */
+ this.reasonText = {};
+ this._revealed = false;
+ }
+
+ get target() {
+ return this._target;
+ }
+
+ set target(target) {
+ this._target = target ?? null;
+ this._revealed = false;
+ this.render();
+ }
+
+ get profile() {
+ return this.getAttribute("profile") ?? undefined;
+ }
+
+ attributeChangedCallback() {
+ this.render();
+ }
+
+ /** The decision currently applied, or null when not evaluable. */
+ get decision() {
+ if (!this.runtime || !this._target) {
+ return null;
+ }
+ try {
+ return this.runtime.evaluate(this._target, { profile: this.profile });
+ } catch {
+ return null;
+ }
+ }
+
+ render() {
+ if (!this.shadowRoot) {
+ return;
+ }
+
+ const decision = this.decision;
+ if (!decision) {
+ this.renderHtml("");
+ return;
+ }
+
+ const effect = decision.visibility.effect;
+ const reasons = describeReasons(decision, this.reasonText);
+ const reasonLine = reasons.length ? `
${escapeHtml(reasons[0])}
` : "";
+
+ // A hidden decision the viewer may override renders a disclosure rather
+ // than nothing at all: silently vanishing content is indistinguishable
+ // from a bug, and leaves no way to appeal.
+ const hidden = effect === "hide" || effect === "deny";
+ if (hidden && !this._revealed) {
+ const canReveal = decision.visibility.overridable && effect !== "deny";
+ this.renderHtml(`
+