feat(widget): plug-and-play governance UI as custom elements

Follows bitlogin's layering: the engine stays headless and the widget is a
consumer of the published API, never a dependency of it. Building it was also a
test of that API — an element needing something unexposed would have been a
finding, the way the commerce example was.

Viewer elements: governance-veil (blur on restrict, disclosure on hide, warning
on warn), governance-report, governance-status. Moderator elements:
governance-capabilities, governance-action, governance-admin-panel.

Deliberate behaviors:
- hidden content leaves the accessibility tree rather than being visually
  covered, so a screen reader does not read out what the viewer must not see
- a hidden decision renders a disclosure, not nothing: content that silently
  vanishes is indistinguishable from a bug and leaves no route to appeal
- refused moderator actions stay visible and name the missing capability. A
  hidden button teaches nothing about the authority model, and the command
  layer enforces the same check independently — the UI is an explanation,
  never the gate
- no element computes policy; wording maps from stable reason identifiers and
  is overridable per application

Found while building it: GovernanceAdminStore emitted only when reduced denial
state changed, so revoking a role that denied nobody announced nothing, leaving
capability-gated UI and the decision cache stale.

The root tsconfig deliberately omits the DOM lib — that absence is what keeps
core, nostr, and runtime from reaching for browser globals — so the widget
carries its own DOM-aware config rather than relaxing the guarantee globally.

687 tests; lint, typecheck, and the declaration build pass from a cold checkout.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 19:14:27 -04:00
1 parent da2c1cd923
commit 6957594c36
18 files changed
+2207 -11

No files matched your search

+14
View File
@@ -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
+25
View File
@@ -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
<governance-veil profile="feed"><img src="thumbnail.jpg" alt="" /></governance-veil>
```
```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
+190
View File
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nostr-governance widget demo</title>
<style>
:root { color-scheme: light dark; }
body {
font: 16px/1.5 system-ui, sans-serif;
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1rem 6rem;
}
h1 { margin-bottom: 0.25rem; }
.lede { color: color-mix(in srgb, currentColor 65%, transparent); margin-top: 0; }
section {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
border-radius: 10px;
padding: 1rem 1.25rem;
margin: 1.5rem 0;
}
h2 { font-size: 1.05rem; margin-top: 0; }
.card { max-width: 22rem; border-radius: 8px; overflow: hidden; }
.card img { display: block; width: 100%; background: #8884; aspect-ratio: 16/9; }
.controls { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
button { font: inherit; padding: 0.4em 0.8em; border-radius: 8px; cursor: pointer; }
code { font-size: 0.9em; }
</style>
</head>
<body>
<h1>nostr-governance widget</h1>
<p class="lede">
Every element below reads from one shared runtime. Toggle governance state
and watch each surface reach its own conclusion from the same evidence.
</p>
<section>
<h2>Viewer surface — <code>&lt;governance-veil&gt;</code></h2>
<governance-veil id="veil" profile="public-marketplace">
<div class="card">
<img alt="" />
<p><strong>Widget</strong> — 1000 sats</p>
</div>
</governance-veil>
<div class="controls">
<button id="report">Add a trusted scam report</button>
<button id="deny">Restrict the seller</button>
<button id="reset">Reset</button>
</div>
</section>
<section>
<h2>Same product, four surfaces</h2>
<p class="lede">
One snapshot, four profiles. This is the state a single
<code>hidden</code> boolean cannot express.
</p>
<div id="surfaces"></div>
</section>
<section>
<h2>Status readout — <code>&lt;governance-status&gt;</code></h2>
<governance-status id="status" profile="seller-dashboard"></governance-status>
</section>
<section>
<h2>Moderator console</h2>
<p class="lede">
Signed in as a <strong>listing moderator</strong>, who may restrict
listings but not sellers. Note that the refused control stays visible
and says what it needs.
</p>
<governance-capabilities id="caps"></governance-capabilities>
<governance-admin-panel id="panel"></governance-admin-panel>
</section>
<script type="module">
import {
createCommands,
createGovernanceRuntime,
createMemoryTransport,
} from "../../packages/runtime/src/index.js";
import { defineGovernanceElements } from "../../packages/widget/src/index.js";
import { COMMERCE_POLICY, COMMERCE_ROLE_CAPABILITIES } from "./src/policy.js";
defineGovernanceElements();
const key = (seed) => seed.repeat(64).slice(0, 64);
const ROOT = key("a1");
const LISTING_MOD = key("b2");
const SELLER_MOD = key("c3");
const SELLER = key("d4");
const NOW = Math.floor(Date.now() / 1000);
const trusted = (i) => key(i.toString(16).padStart(2, "0"));
const runtime = createGovernanceRuntime({
applicationId: "commerce-demo",
namespace: "commerce",
root: ROOT,
transport: createMemoryTransport(),
policy: COMMERCE_POLICY,
now: () => NOW,
});
function resetRoster() {
runtime.admin.setRoles({
root: ROOT,
roles: COMMERCE_ROLE_CAPABILITIES,
actors: {
[ROOT]: ["super_admin"],
[LISTING_MOD]: ["listing_moderator"],
[SELLER_MOD]: ["seller_moderator"],
},
});
runtime.admin.upsertContribution({
actor: ROOT,
kind: "user-allow",
targets: [{ type: "user", pubkey: SELLER }],
createdAt: NOW,
});
}
resetRoster();
// Signed in as a listing moderator: they may restrict listings, not sellers.
runtime.setViewer(LISTING_MOD);
runtime.trust.setContacts(Array.from({ length: 8 }, (_, i) => trusted(i + 1)));
const commands = createCommands(runtime);
const target = { type: "address", kind: "30078", pubkey: SELLER, identifier: "sku-001" };
const targetKey = `address:30078:${SELLER}:sku-001`;
for (const [id, node] of [
["veil", document.getElementById("veil")],
["status", document.getElementById("status")],
]) {
node.runtime = runtime;
node.target = target;
}
const caps = document.getElementById("caps");
const panel = document.getElementById("panel");
caps.runtime = runtime;
panel.runtime = runtime;
panel.commands = commands;
const PROFILES = ["public-marketplace", "product-detail", "checkout", "seller-dashboard"];
const surfaces = document.getElementById("surfaces");
function renderSurfaces() {
surfaces.innerHTML = PROFILES.map((profile) => {
const decision = runtime.evaluate(target, { profile });
return `<p><code>${profile}</code> — visibility:
<strong>${decision.visibility.effect}</strong>, transaction:
<strong>${decision.transaction?.effect ?? "n/a"}</strong>, rank:
<strong>${decision.ranking.effect}</strong></p>`;
}).join("");
}
runtime.on("change", renderSurfaces);
renderSurfaces();
let reportCount = 0;
document.getElementById("report").addEventListener("click", () => {
reportCount += 1;
runtime.reports.ingest(
{ reporter: trusted(reportCount), category: "scam", createdAt: NOW },
targetKey,
);
});
document.getElementById("deny").addEventListener("click", () => {
runtime.admin.upsertContribution({
actor: SELLER_MOD,
kind: "user-deny",
targets: [{ type: "user", pubkey: SELLER }],
createdAt: NOW + reportCount,
});
});
document.getElementById("reset").addEventListener("click", () => {
reportCount = 0;
runtime.reports.clearTarget(targetKey);
runtime.admin.setContributions([]);
resetRoster();
});
</script>
</body>
</html>
+107
View File
@@ -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": "*"
}
}
}
}
+3 -2
View File
@@ -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"
}
+29 -4
View File
@@ -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) {
+34
View File
@@ -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();
});
});
+116
View File
@@ -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 |
| --- | --- |
| `<governance-veil>` | Wraps content and applies the decision: blur on `restrict`, disclosure on `hide`, warning on `warn` |
| `<governance-report>` | Report dialog. Requires no capability — anyone may report |
| `<governance-status>` | Readout of effect, reasons, and evidence |
### Moderator-facing
| Element | Purpose |
| --- | --- |
| `<governance-capabilities>` | What the signed-in account may actually do |
| `<governance-action>` | A capability-gated button that explains refusals |
| `<governance-admin-panel>` | Effective state, contributor attribution, and controls |
## Usage
```html
<script type="module">
import { createGovernanceRuntime, createCommands } from "@nostr-governance/runtime";
import { defineGovernanceElements } from "@nostr-governance/widget";
defineGovernanceElements();
const runtime = createGovernanceRuntime({
applicationId: "my-app",
namespace: "myapp",
root: ROOT_PUBKEY,
transport: myRelayAdapter,
policy: myPolicy,
now: () => Math.floor(Date.now() / 1000),
});
await runtime.loadAdministrativeState();
const veil = document.querySelector("governance-veil");
veil.runtime = runtime;
veil.target = { type: "event", id: eventId, author: authorPubkey };
</script>
<governance-veil profile="feed">
<img src="thumbnail.jpg" alt="" />
</governance-veil>
```
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.
+22
View File
@@ -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": "*"
}
}
+295
View File
@@ -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",
};
/**
* `<governance-capabilities>` — 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('<p class="reason">Not signed in.</p>');
return;
}
const rows = Object.entries(CAPABILITY_LABELS)
.map(([capability, label]) => {
const has = held.has(/** @type {GovernanceCapability} */ (capability));
return `<li part="capability">
<span aria-hidden="true">${has ? "✓" : "·"}</span>
<span${has ? "" : ' class="reason"'}>${escapeHtml(label)}</span>
</li>`;
})
.join("");
this.renderHtml(`
<style>
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25em; }
li { display: grid; grid-template-columns: 1.2em 1fr; align-items: baseline; }
</style>
<div class="stack" part="capabilities">
<p class="reason">Signed in as ${escapeHtml(shortenKey(viewer))}</p>
<ul>${rows}</ul>
${held.size === 0 ? '<p class="reason">This account holds no governance capabilities.</p>' : ""}
</div>
`);
}
}
/**
* `<governance-action>` — a single capability-gated action button.
*
* When the actor lacks the capability the button is disabled *and* labelled
* with what is missing, which is the difference between "broken" and "not
* yours to do".
*/
export class GovernanceAction extends GovernanceElement {
static get observedAttributes() {
return ["capability", "label"];
}
constructor() {
super();
/** @type {GovernanceCommands|null} */
this.commands = null;
/** @type {(() => Promise<any>)|null} */
this.action = null;
this._status = "";
}
attributeChangedCallback() {
this.render();
}
get capability() {
return /** @type {GovernanceCapability} */ (this.getAttribute("capability") ?? "");
}
render() {
if (!this.shadowRoot) {
return;
}
const capability = this.capability;
const label = this.getAttribute("label") ?? "Apply";
const viewer = this.runtime?.viewerPubkey ?? "";
const permitted = Boolean(this.runtime && viewer && this.runtime.can(viewer, capability));
const missing = CAPABILITY_LABELS[capability] ?? capability;
this.renderHtml(`
<div class="row" part="action">
<button type="button" id="run" ${permitted ? "" : "disabled"}>${escapeHtml(label)}</button>
${
permitted
? ""
: `<span class="reason" part="explanation">Requires permission to ${escapeHtml(missing)}</span>`
}
<span class="reason" id="status">${escapeHtml(this._status)}</span>
</div>
`);
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();
}
}
/**
* `<governance-admin-panel>` — 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('<p class="reason">No runtime attached.</p>');
return;
}
const state = this.runtime.admin.state;
const authority = this.runtime.admin.authority;
const viewer = this.runtime.viewerPubkey;
const canDenyUser = Boolean(viewer && this.runtime.can(viewer, "contribute-user-deny"));
const denyRows = Array.from(state.userDeny)
.map((key) => {
const pubkey = key.slice("user:".length);
const sources = state.communitySources.get(key) ?? [];
const origin = sources.length
? `community list (${escapeHtml(shortenKey(sources[0]))})`
: "moderator";
return `<tr>
<td><code>${escapeHtml(shortenKey(pubkey))}</code></td>
<td class="reason">${origin}</td>
</tr>`;
})
.join("");
this.renderHtml(`
<style>
table { border-collapse: collapse; width: 100%; }
td, th { text-align: left; padding: 0.3em 0.5em; border-bottom: 1px solid var(--gov-border); }
code { font-size: 0.9em; }
h3 { margin: 0; font-size: 1em; }
.stale { color: var(--gov-warn); }
</style>
<div class="stack" part="panel">
${
this.runtime.stale
? '<p class="stale" part="stale">Showing cached state — relays unreachable.</p>'
: ""
}
<section class="stack">
<h3>Roster</h3>
<p class="reason">
Root: ${escapeHtml(authority.root ? shortenKey(authority.root) : "not configured")} ·
${escapeHtml(Object.keys(authority.actors).length)} actor(s) ·
${escapeHtml(authority.protectedActors.length)} protected
</p>
</section>
<section class="stack">
<h3>Restricted accounts (${escapeHtml(state.userDeny.size)})</h3>
${
denyRows
? `<table part="deny-table"><tbody>${denyRows}</tbody></table>`
: '<p class="reason">None.</p>'
}
</section>
<section class="stack">
<h3>Restrict an account</h3>
<div class="row">
<input id="pubkey" type="text" placeholder="hex pubkey" size="24" part="input" />
<button type="button" id="deny" ${canDenyUser ? "" : "disabled"}>Restrict</button>
</div>
${
canDenyUser
? ""
: '<p class="reason">Requires permission to restrict accounts.</p>'
}
<span class="reason" id="status">${escapeHtml(this._status)}</span>
</section>
<section class="stack">
<h3>Other state</h3>
<p class="reason">
${escapeHtml(state.eventDeny.size)} restricted post(s) ·
${escapeHtml(state.addressDeny.size)} restricted listing(s) ·
${escapeHtml(state.userAllow.size)} approved account(s) ·
${escapeHtml(state.trustSeeds.size)} trust seed(s)
</p>
</section>
</div>
`);
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;
}
+195
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* 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 = `<style>${BASE_STYLES}</style>${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;
}
+51
View File
@@ -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()];
}
+381
View File
@@ -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<string, string>}
*/
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<string, string>} [overrides]
* @returns {string[]}
*/
export function describeReasons(decision, overrides = {}) {
const table = { ...DEFAULT_REASON_TEXT, ...overrides };
return decision.reasons.map((reason) => table[reason.id] ?? reason.id);
}
/**
* `<governance-veil>` — 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:
* <governance-veil><img src="..."></governance-veil>
* 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<string, string>} */
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("<slot></slot>");
return;
}
const effect = decision.visibility.effect;
const reasons = describeReasons(decision, this.reasonText);
const reasonLine = reasons.length ? `<p class="reason">${escapeHtml(reasons[0])}</p>` : "";
// 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(`
<div class="veil stack" part="veil">
<p part="notice">Content hidden</p>
${reasonLine}
${canReveal ? '<div class="row"><button type="button" id="reveal">Show anyway</button></div>' : ""}
<div hidden aria-hidden="true"><slot></slot></div>
</div>
`);
this.$("#reveal")?.addEventListener("click", () => {
this._revealed = true;
this.emit("revealed", { target: this._target, decision });
this.render();
});
return;
}
const blurred = effect === "restrict" && !this._revealed;
const warned = effect === "warn";
this.renderHtml(`
<style>
.blurred { filter: blur(18px); pointer-events: none; user-select: none; }
.overlay { position: relative; }
.overlay .controls {
position: absolute; inset: 0; display: grid; place-content: center;
gap: 0.5em; text-align: center; padding: 1em;
}
.warn { color: var(--gov-warn); }
</style>
<div class="overlay" part="container">
<div class="${blurred ? "blurred" : ""}" part="content"><slot></slot></div>
${
blurred
? `<div class="controls">
${reasonLine}
<div class="row" style="justify-content:center">
<button type="button" id="reveal">Show anyway</button>
</div>
</div>`
: ""
}
</div>
${warned && reasons.length ? `<p class="reason warn" part="warning">${escapeHtml(reasons[0])}</p>` : ""}
`);
this.$("#reveal")?.addEventListener("click", () => {
this._revealed = true;
this.emit("revealed", { target: this._target, decision });
this.render();
});
}
}
/**
* `<governance-report>` — a report dialog.
*
* Reporting requires no capability: anyone may report, and whether the report
* counts is decided later by each viewer's own trust graph. The element makes
* that explicit rather than implying a report is an enforcement action.
*/
export class GovernanceReport extends GovernanceElement {
constructor() {
super();
/** @type {GovernanceTarget|null} */
this._target = null;
/** @type {string[]} */
this.categories = ["spam", "nudity", "profanity", "illegal", "malware", "impersonation", "other"];
/** @type {import('@nostr-governance/runtime').GovernanceCommands|null} */
this.commands = null;
this._status = "";
}
get target() {
return this._target;
}
set target(target) {
this._target = target ?? null;
this._status = "";
this.render();
}
render() {
if (!this.shadowRoot) {
return;
}
const options = this.categories
.map((category) => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`)
.join("");
this.renderHtml(`
<form class="stack" part="form">
<label class="stack">
<span>Reason</span>
<select id="category" part="select">${options}</select>
</label>
<label class="stack">
<span>Details <span class="reason">(optional, public)</span></span>
<textarea id="details" rows="3" part="textarea"></textarea>
</label>
<p class="reason">
Reports are public Nostr events. Whether a report affects what someone
sees depends on their own trust graph.
</p>
<div class="row">
<button type="submit" id="submit" ${this._target && this.commands ? "" : "disabled"}>
Submit report
</button>
<span class="reason" id="status">${escapeHtml(this._status)}</span>
</div>
</form>
`);
this.$("form")?.addEventListener("submit", (fromEvent) => {
fromEvent.preventDefault();
void this._submit();
});
}
async _submit() {
if (!this._target || !this.commands) {
return;
}
const category = /** @type {HTMLSelectElement} */ (this.$("#category"))?.value ?? "";
const details = /** @type {HTMLTextAreaElement} */ (this.$("#details"))?.value ?? "";
this._status = "Publishing…";
this.render();
const result = await this.commands.report(this._target, category, details);
// Partial relay acceptance is success: the event exists on the network.
this._status = result.ok ? "Report published" : `Failed: ${result.code}`;
this.emit(result.ok ? "reported" : "report-failed", { target: this._target, category, result });
this.render();
}
}
/**
* `<governance-status>` — a compact readout of why something was affected.
*
* Shows counts when the profile exposes evidence, and contributor identities
* only when it does. A surface that redacts evidence gets "reported by people
* you follow" and no names.
*/
export class GovernanceStatus extends GovernanceElement {
constructor() {
super();
/** @type {GovernanceTarget|null} */
this._target = null;
/** @type {Record<string, string>} */
this.reasonText = {};
}
static get observedAttributes() {
return ["profile"];
}
get target() {
return this._target;
}
set target(target) {
this._target = target ?? null;
this.render();
}
get profile() {
return this.getAttribute("profile") ?? undefined;
}
attributeChangedCallback() {
this.render();
}
render() {
if (!this.shadowRoot) {
return;
}
if (!this.runtime || !this._target) {
this.renderHtml("");
return;
}
let decision;
try {
decision = this.runtime.evaluate(this._target, { profile: this.profile });
} catch {
this.renderHtml("");
return;
}
const reasons = describeReasons(decision, this.reasonText);
const evidence = decision.evidence;
const reporters = evidence?.trustedReporterPubkeys ?? [];
const muters = evidence?.trustedMuterPubkeys ?? [];
this.renderHtml(`
<div class="stack" part="status">
<div class="row">
<strong part="effect">${escapeHtml(decision.visibility.effect)}</strong>
${
decision.ranking.effect === "downrank"
? `<span class="reason">downranked (${escapeHtml(decision.ranking.weight)})</span>`
: ""
}
${
decision.transaction
? `<span class="reason">transaction: ${escapeHtml(decision.transaction.effect)}</span>`
: ""
}
</div>
${
reasons.length
? `<ul part="reasons">${reasons.map((text) => `<li>${escapeHtml(text)}</li>`).join("")}</ul>`
: '<p class="reason">No governance signals</p>'
}
${
evidence
? `<p class="reason">
${escapeHtml(evidence.trustedReportTotal)} trusted report(s),
${escapeHtml(evidence.trustedMuteTotal)} trusted mute(s)
</p>`
: ""
}
${
reporters.length
? `<p class="reason">Reported by: ${reporters.map((key) => escapeHtml(shortenKey(key))).join(", ")}</p>`
: ""
}
${
muters.length
? `<p class="reason">Muted by: ${muters.map((key) => escapeHtml(shortenKey(key))).join(", ")}</p>`
: ""
}
</div>
`);
}
}
/**
* Register the viewer elements.
* @returns {string[]} Element names newly registered
*/
export function defineViewerElements() {
const registered = [];
if (defineElement("governance-veil", GovernanceVeil)) registered.push("governance-veil");
if (defineElement("governance-report", GovernanceReport)) registered.push("governance-report");
if (defineElement("governance-status", GovernanceStatus)) registered.push("governance-status");
return registered;
}
+316
View File
@@ -0,0 +1,316 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import {
createCommands,
createGovernanceRuntime,
createMemoryTransport,
} from "@nostr-governance/runtime";
import { CAPABILITY_LABELS } from "../src/admin.js";
import { defineGovernanceElements } from "../src/index.js";
import { shortenKey } from "../src/base.js";
const ROOT = "a1".repeat(32);
const MODERATOR = "b2".repeat(32);
const CURATOR = "c3".repeat(32);
const CREATOR = "d4".repeat(32);
const STRANGER = "f6".repeat(32);
const NOW = 1_750_000_000;
const POLICY = createPolicyDefinition({
id: "widget",
version: "1.0.0",
profiles: { feed: { name: "feed", reports: {}, mutes: {} } },
});
defineGovernanceElements();
function makeRuntime(viewer) {
const runtime = createGovernanceRuntime({
applicationId: "widget-test",
namespace: "widget",
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
root: ROOT,
});
runtime.admin.setRoles({
root: ROOT,
actors: {
[ROOT]: ["super_admin"],
[MODERATOR]: ["moderator"],
[CURATOR]: ["curator"],
},
});
if (viewer) {
runtime.setViewer(viewer);
}
return runtime;
}
/**
* @param {string} tag
* @param {any} runtime
* @param {Record<string, string>} [attributes]
* @returns {any}
*/
function mount(tag, runtime, attributes = {}) {
const element = /** @type {any} */ (document.createElement(tag));
for (const [name, value] of Object.entries(attributes)) {
element.setAttribute(name, value);
}
document.body.append(element);
element.runtime = runtime;
return element;
}
beforeEach(() => {
document.body.innerHTML = "";
});
describe("<governance-capabilities>", () => {
it("says when nobody is signed in", () => {
const panel = mount("governance-capabilities", makeRuntime());
expect(panel.shadowRoot.textContent).toContain("Not signed in");
});
it("lists what a moderator holds", () => {
const panel = mount("governance-capabilities", makeRuntime(MODERATOR));
const text = panel.shadowRoot.textContent;
expect(text).toContain(CAPABILITY_LABELS["contribute-user-deny"]);
expect(text).toContain(shortenKey(MODERATOR));
});
it("shows capabilities an actor lacks rather than omitting them", () => {
const panel = mount("governance-capabilities", makeRuntime(CURATOR));
// A curator holds only contribute-user-deny, but every capability is
// listed so the boundary is legible.
for (const label of Object.values(CAPABILITY_LABELS)) {
expect(panel.shadowRoot.textContent).toContain(label);
}
});
it("marks held capabilities distinctly from unheld ones", () => {
const panel = mount("governance-capabilities", makeRuntime(CURATOR));
const items = [...panel.shadowRoot.querySelectorAll("li")];
const held = items.filter((item) => item.textContent.includes("✓"));
expect(held).toHaveLength(1);
expect(held[0].textContent).toContain(CAPABILITY_LABELS["contribute-user-deny"]);
});
it("tells an account with no authority that it has none", () => {
const panel = mount("governance-capabilities", makeRuntime(STRANGER));
expect(panel.shadowRoot.textContent).toContain("no governance capabilities");
});
it("grants the root everything", () => {
const panel = mount("governance-capabilities", makeRuntime(ROOT));
const held = [...panel.shadowRoot.querySelectorAll("li")].filter((item) =>
item.textContent.includes("✓"),
);
expect(held).toHaveLength(Object.keys(CAPABILITY_LABELS).length);
});
});
describe("<governance-action>", () => {
it("enables the control when the actor holds the capability", () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
label: "Restrict",
});
expect(action.shadowRoot.querySelector("#run").disabled).toBe(false);
});
it("disables it and explains why when they do not", () => {
const action = mount("governance-action", makeRuntime(CURATOR), {
capability: "contribute-event-deny",
label: "Restrict post",
});
expect(action.shadowRoot.querySelector("#run").disabled).toBe(true);
expect(action.shadowRoot.textContent).toContain("Requires permission to");
expect(action.shadowRoot.textContent).toContain(CAPABILITY_LABELS["contribute-event-deny"]);
});
it("keeps the control visible rather than hiding it", () => {
const action = mount("governance-action", makeRuntime(CURATOR), {
capability: "manage-roles",
label: "Edit roles",
});
expect(action.shadowRoot.querySelector("#run")).not.toBeNull();
expect(action.shadowRoot.textContent).toContain("Edit roles");
});
it("runs the action and announces completion", async () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
label: "Restrict",
});
action.action = vi.fn().mockResolvedValue({ ok: true });
const completed = vi.fn();
action.addEventListener("governance:action-completed", completed);
action.shadowRoot.querySelector("#run").click();
await vi.waitFor(() => expect(completed).toHaveBeenCalled());
expect(action.shadowRoot.textContent).toContain("Done");
});
it("reports a refusal with its stable code", async () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
label: "Restrict",
});
action.action = vi.fn().mockResolvedValue({ ok: false, code: "protected-target" });
const refused = vi.fn();
action.addEventListener("governance:action-refused", refused);
action.shadowRoot.querySelector("#run").click();
await vi.waitFor(() => expect(refused).toHaveBeenCalled());
expect(action.shadowRoot.textContent).toContain("protected-target");
});
it("reports a thrown failure without breaking the element", async () => {
const action = mount("governance-action", makeRuntime(MODERATOR), {
capability: "contribute-user-deny",
});
action.action = vi.fn().mockRejectedValue(new Error("relay down"));
const failed = vi.fn();
action.addEventListener("governance:action-failed", failed);
action.shadowRoot.querySelector("#run").click();
await vi.waitFor(() => expect(failed).toHaveBeenCalled());
expect(action.shadowRoot.textContent).toContain("relay down");
});
it("re-evaluates permission when the roster changes", () => {
const runtime = makeRuntime(MODERATOR);
const action = mount("governance-action", runtime, {
capability: "contribute-user-deny",
});
expect(action.shadowRoot.querySelector("#run").disabled).toBe(false);
runtime.admin.setRoles({ root: ROOT, actors: { [ROOT]: ["super_admin"] } });
expect(action.shadowRoot.querySelector("#run").disabled).toBe(true);
});
});
describe("<governance-admin-panel>", () => {
/**
* @param {any} runtime
* @param {Object} [options]
* @param {string} [options.source]
*/
function denyCreator(runtime, { source } = {}) {
runtime.admin.upsertContribution({
actor: source ? CURATOR : MODERATOR,
kind: "user-deny",
targets: [{ type: "user", pubkey: CREATOR }],
createdAt: NOW,
...(source ? { source } : {}),
});
}
it("reports when no runtime is attached", () => {
const panel = /** @type {any} */ (document.createElement("governance-admin-panel"));
document.body.append(panel);
expect(panel.shadowRoot.textContent).toContain("No runtime attached");
});
it("shows the roster summary", () => {
const panel = mount("governance-admin-panel", makeRuntime(MODERATOR));
expect(panel.shadowRoot.textContent).toContain(shortenKey(ROOT));
expect(panel.shadowRoot.textContent).toContain("actor(s)");
});
it("lists restricted accounts", () => {
const runtime = makeRuntime(MODERATOR);
denyCreator(runtime);
const panel = mount("governance-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain(shortenKey(CREATOR));
});
it("distinguishes a community list from a moderator action", () => {
const runtime = makeRuntime(MODERATOR);
denyCreator(runtime, { source: "curated-list" });
const panel = mount("governance-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("community list");
});
it("attributes a direct action to a moderator", () => {
const runtime = makeRuntime(MODERATOR);
denyCreator(runtime);
const panel = mount("governance-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("moderator");
expect(panel.shadowRoot.textContent).not.toContain("community list");
});
it("disables restriction for an actor without the capability", () => {
const panel = mount("governance-admin-panel", makeRuntime(STRANGER));
expect(panel.shadowRoot.querySelector("#deny").disabled).toBe(true);
expect(panel.shadowRoot.textContent).toContain("Requires permission");
});
it("issues the command through the runtime", async () => {
const runtime = makeRuntime(MODERATOR);
const commands = createCommands(runtime);
vi.spyOn(commands, "denyUser").mockResolvedValue({ ok: true });
const panel = mount("governance-admin-panel", runtime);
panel.commands = commands;
panel.shadowRoot.querySelector("#pubkey").value = CREATOR;
const restricted = vi.fn();
panel.addEventListener("governance:account-restricted", restricted);
panel.shadowRoot.querySelector("#deny").click();
await vi.waitFor(() => expect(restricted).toHaveBeenCalled());
expect(commands.denyUser).toHaveBeenCalledWith(CREATOR);
});
it("surfaces a refusal from the command layer", async () => {
const runtime = makeRuntime(MODERATOR);
const commands = createCommands(runtime);
vi.spyOn(commands, "denyUser").mockResolvedValue({ ok: false, code: "protected-target" });
const panel = mount("governance-admin-panel", runtime);
panel.commands = commands;
panel.shadowRoot.querySelector("#pubkey").value = ROOT;
const refused = vi.fn();
panel.addEventListener("governance:action-refused", refused);
panel.shadowRoot.querySelector("#deny").click();
await vi.waitFor(() => expect(refused).toHaveBeenCalled());
expect(panel.shadowRoot.textContent).toContain("protected-target");
});
it("warns when serving cached state", () => {
const runtime = makeRuntime(MODERATOR);
runtime.stale = true;
const panel = mount("governance-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("relays unreachable");
});
it("updates when administrative state changes", () => {
const runtime = makeRuntime(MODERATOR);
const panel = mount("governance-admin-panel", runtime);
expect(panel.shadowRoot.textContent).toContain("Restricted accounts (0)");
denyCreator(runtime);
expect(panel.shadowRoot.textContent).toContain("Restricted accounts (1)");
});
});
describe("element registration", () => {
it("is idempotent", () => {
// Elements were registered at module load; a second call must not throw.
expect(() => defineGovernanceElements()).not.toThrow();
expect(defineGovernanceElements()).toEqual([]);
});
});
+385
View File
@@ -0,0 +1,385 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPolicyDefinition } from "@nostr-governance/core";
import {
createCommands,
createGovernanceRuntime,
createMemoryTransport,
} from "@nostr-governance/runtime";
import { DEFAULT_REASON_TEXT, describeReasons } from "../src/viewer.js";
import { defineGovernanceElements } from "../src/index.js";
import { escapeHtml, shortenKey } from "../src/base.js";
const ROOT = "a1".repeat(32);
const MODERATOR = "b2".repeat(32);
const CREATOR = "d4".repeat(32);
const TRUSTED = "01".repeat(32);
const EVENT_ID = "1b".repeat(32);
const NOW = 1_750_000_000;
const POLICY = createPolicyDefinition({
id: "widget",
version: "1.0.0",
defaultProfile: "feed",
profiles: {
feed: {
name: "feed",
administrativeDeny: { visibility: "hide", interaction: "deny" },
reports: { spam: { warn: 1, restrict: 2 } },
mutes: {},
},
locked: {
name: "locked",
administrativeDeny: { visibility: "deny", interaction: "deny" },
allowViewerOverride: false,
reports: {},
mutes: {},
},
detail: {
name: "detail",
administrativeDeny: { visibility: "restrict", interaction: "deny" },
exposeEvidence: true,
reports: { spam: { restrict: 1 } },
mutes: {},
},
},
});
defineGovernanceElements();
function makeRuntime() {
const runtime = createGovernanceRuntime({
applicationId: "widget-test",
namespace: "widget",
transport: createMemoryTransport(),
policy: POLICY,
now: () => NOW,
root: ROOT,
});
runtime.admin.setRoles({
root: ROOT,
actors: { [ROOT]: ["super_admin"], [MODERATOR]: ["moderator"] },
});
runtime.trust.setContacts([TRUSTED]);
return runtime;
}
function denyCreator(runtime) {
runtime.admin.upsertContribution({
actor: MODERATOR,
kind: "user-deny",
targets: [{ type: "user", pubkey: CREATOR }],
createdAt: NOW,
});
}
/**
* Mount an element with content and wait for its initial render.
* @param {string} tag
* @param {Object} [options]
* @param {any} [options.runtime]
* @param {any} [options.target]
* @param {string} [options.profile]
* @param {string} [options.inner]
* @returns {any}
*/
function mount(tag, { runtime, target, profile, inner = "<p>content</p>" } = {}) {
const element = /** @type {any} */ (document.createElement(tag));
if (profile) {
element.setAttribute("profile", profile);
}
element.innerHTML = inner;
document.body.append(element);
if (runtime) element.runtime = runtime;
if (target) element.target = target;
return element;
}
beforeEach(() => {
document.body.innerHTML = "";
});
describe("helpers", () => {
it("escapes markup", () => {
expect(escapeHtml('<img src=x onerror="alert(1)">')).not.toContain("<img");
expect(escapeHtml("a&b")).toBe("a&amp;b");
});
it("handles nullish input", () => {
expect(escapeHtml(null)).toBe("");
expect(escapeHtml(undefined)).toBe("");
});
it("shortens long keys and leaves short ones alone", () => {
expect(shortenKey(CREATOR)).toMatch(/^d4d4d4d4…d4d4$/);
expect(shortenKey("short")).toBe("short");
});
});
describe("describeReasons", () => {
it("maps stable identifiers to wording", () => {
const decision = { reasons: [{ id: "admin-user-deny" }] };
expect(describeReasons(/** @type {any} */ (decision))).toEqual([
DEFAULT_REASON_TEXT["admin-user-deny"],
]);
});
it("lets an application override the wording", () => {
const decision = { reasons: [{ id: "admin-user-deny" }] };
expect(
describeReasons(/** @type {any} */ (decision), { "admin-user-deny": "Seller suspended" }),
).toEqual(["Seller suspended"]);
});
it("falls back to the raw identifier for unknown reasons", () => {
const decision = { reasons: [{ id: "future-reason" }] };
expect(describeReasons(/** @type {any} */ (decision))).toEqual(["future-reason"]);
});
});
describe("<governance-veil>", () => {
it("renders content untouched when nothing objects", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
});
expect(veil.shadowRoot.querySelector("slot")).not.toBeNull();
expect(veil.shadowRoot.textContent).not.toContain("Content hidden");
});
it("hides content behind a disclosure when the decision hides", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
expect(veil.shadowRoot.textContent).toContain("Content hidden");
expect(veil.shadowRoot.querySelector("#reveal")).not.toBeNull();
});
it("explains why rather than vanishing silently", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
expect(veil.shadowRoot.textContent).toContain(DEFAULT_REASON_TEXT["admin-user-deny"]);
});
it("keeps hidden content out of the accessibility tree", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
const wrapper = veil.shadowRoot.querySelector("[aria-hidden='true']");
expect(wrapper).not.toBeNull();
expect(wrapper.hasAttribute("hidden")).toBe(true);
});
it("reveals on request and announces it", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
const revealed = vi.fn();
veil.addEventListener("governance:revealed", revealed);
veil.shadowRoot.querySelector("#reveal").click();
expect(revealed).toHaveBeenCalled();
expect(veil.shadowRoot.textContent).not.toContain("Content hidden");
});
it("offers no reveal when the profile forbids overrides", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "locked",
});
expect(veil.shadowRoot.textContent).toContain("Content hidden");
expect(veil.shadowRoot.querySelector("#reveal")).toBeNull();
});
it("blurs rather than hides on a restrict decision", () => {
const runtime = makeRuntime();
runtime.reports.ingest(
{ reporter: TRUSTED, category: "spam", createdAt: NOW },
`user:${CREATOR}`,
);
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "detail",
});
expect(veil.shadowRoot.querySelector(".blurred")).not.toBeNull();
expect(veil.shadowRoot.textContent).not.toContain("Content hidden");
});
it("re-renders when governance state changes", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
expect(veil.shadowRoot.textContent).not.toContain("Content hidden");
denyCreator(runtime);
expect(veil.shadowRoot.textContent).toContain("Content hidden");
});
it("stops re-rendering once detached", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
veil.remove();
denyCreator(runtime);
expect(veil.shadowRoot.textContent).not.toContain("Content hidden");
});
it("renders plainly without a runtime", () => {
const veil = mount("governance-veil", {});
expect(veil.shadowRoot.querySelector("slot")).not.toBeNull();
});
it("tolerates an invalid target instead of throwing", () => {
const runtime = makeRuntime();
const veil = mount("governance-veil", { runtime });
expect(() => {
veil.target = { type: "user", pubkey: "not-a-key" };
}).not.toThrow();
});
});
describe("<governance-report>", () => {
it("disables submission with no target or commands", () => {
const report = mount("governance-report", { runtime: makeRuntime() });
expect(report.shadowRoot.querySelector("#submit").disabled).toBe(true);
});
it("states plainly that reports are public", () => {
const report = mount("governance-report", { runtime: makeRuntime() });
expect(report.shadowRoot.textContent).toContain("public");
});
it("publishes a report and announces success", async () => {
const runtime = makeRuntime();
const commands = createCommands(runtime);
vi.spyOn(commands, "report").mockResolvedValue({ ok: true, accepted: ["wss://a"] });
const report = mount("governance-report", { runtime });
report.commands = commands;
report.target = { type: "event", id: EVENT_ID };
const reported = vi.fn();
report.addEventListener("governance:reported", reported);
report.shadowRoot.querySelector("#category").value = "spam";
report.shadowRoot.querySelector("form").dispatchEvent(new Event("submit"));
await vi.waitFor(() => expect(reported).toHaveBeenCalled());
expect(commands.report).toHaveBeenCalledWith({ type: "event", id: EVENT_ID }, "spam", "");
expect(report.shadowRoot.textContent).toContain("Report published");
});
it("surfaces the stable error code on refusal", async () => {
const runtime = makeRuntime();
const commands = createCommands(runtime);
vi.spyOn(commands, "report").mockResolvedValue({ ok: false, code: "invalid-target" });
const report = mount("governance-report", { runtime });
report.commands = commands;
report.target = { type: "event", id: EVENT_ID };
const failed = vi.fn();
report.addEventListener("governance:report-failed", failed);
report.shadowRoot.querySelector("form").dispatchEvent(new Event("submit"));
await vi.waitFor(() => expect(failed).toHaveBeenCalled());
expect(report.shadowRoot.textContent).toContain("invalid-target");
});
});
describe("<governance-status>", () => {
it("reports the effect and reasons", () => {
const runtime = makeRuntime();
denyCreator(runtime);
const status = mount("governance-status", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
expect(status.shadowRoot.textContent).toContain("hide");
expect(status.shadowRoot.textContent).toContain(DEFAULT_REASON_TEXT["admin-user-deny"]);
});
it("says so when there is nothing to report", () => {
const status = mount("governance-status", {
runtime: makeRuntime(),
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
expect(status.shadowRoot.textContent).toContain("No governance signals");
});
it("withholds contributor identities on a redacting profile", () => {
const runtime = makeRuntime();
runtime.reports.ingest(
{ reporter: TRUSTED, category: "spam", createdAt: NOW },
`user:${CREATOR}`,
);
const status = mount("governance-status", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "feed",
});
expect(status.shadowRoot.textContent).toContain("1 trusted report");
expect(status.shadowRoot.textContent).not.toContain("Reported by:");
});
it("shows contributor identities when the profile exposes evidence", () => {
const runtime = makeRuntime();
runtime.reports.ingest(
{ reporter: TRUSTED, category: "spam", createdAt: NOW },
`user:${CREATOR}`,
);
const status = mount("governance-status", {
runtime,
target: { type: "user", pubkey: CREATOR },
profile: "detail",
});
expect(status.shadowRoot.textContent).toContain("Reported by:");
expect(status.shadowRoot.textContent).toContain(shortenKey(TRUSTED));
});
it("renders no status without a target", () => {
const status = mount("governance-status", { runtime: makeRuntime() });
expect(status.shadowRoot.querySelector("[part='status']")).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"noEmit": true
},
"include": [
"src/**/*.js",
"test/**/*.js"
],
"exclude": [
"node_modules",
"dist"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": [
"src/**/*.js"
]
}
+11 -5
View File
@@ -3,7 +3,9 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"lib": [
"ES2022"
],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
@@ -11,7 +13,10 @@
"checkJs": true,
"noImplicitAny": false,
"resolveJsonModule": true,
"types": ["node", "vitest/globals"]
"types": [
"node",
"vitest/globals"
]
},
"include": [
"packages/**/src/**/*.js",
@@ -20,10 +25,11 @@
"vitest.config.js"
],
"exclude": [
"node_modules",
"dist",
"coverage",
"**/dist/**",
"coverage",
"dist",
"node_modules",
"packages/widget/**",
"scripts/**"
]
}