diff --git a/README.md b/README.md
index 0559312..65470a5 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,11 @@ allowlisted publishers are shown — the symmetric twin of a denylist, for
invite-only or curated surfaces). Every number in them is a starting point —
see the [integration guide](docs/integration-guide.md) to write your own.
+The package version is exported as `VERSION` and, in browser bundles, as
+`window.bitgate.version`. Both are injected from `package.json` at build time.
+Widget theme tokens use `--bitgate-*` custom properties; the older `--gov-*`
+names remain as fallbacks (see [`packages/widget/README.md`](packages/widget/README.md)).
+
## Quick start — JavaScript
```js
diff --git a/docs/TODO.md b/docs/TODO.md
index 3c4a5d2..7849a03 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -175,7 +175,9 @@ unchanged.
authority or leaking private application context.
- [ ] Decide whether standalone output is generated from the canonical engine
directly or from a separately maintained browser entry point.
-- [ ] Decide how browser builds expose version and integrity metadata.
+- [x] Decide how browser builds expose version and integrity metadata.
+ `VERSION` + `window.bitgate.version` equal `package.json`, injected at
+ build time. Integrity manifests remain open (distribution / D7).
## BitBlocks App Kit compatibility layer
diff --git a/package-lock.json b/package-lock.json
index 2146f08..8f34df3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54,6 +54,10 @@
"resolved": "packages/site",
"link": true
},
+ "node_modules/@bitgate/standalone": {
+ "resolved": "packages/standalone",
+ "link": true
+ },
"node_modules/@bitgate/testing": {
"resolved": "packages/testing",
"link": true
@@ -1837,6 +1841,18 @@
"name": "@bitgate/site",
"version": "0.1.0"
},
+ "packages/standalone": {
+ "name": "@bitgate/standalone",
+ "version": "0.1.0",
+ "license": "GPL-3.0-or-later",
+ "dependencies": {
+ "@bitgate/core": "*",
+ "@bitgate/nostr": "*"
+ },
+ "devDependencies": {
+ "esbuild": "^0.28.2"
+ }
+ },
"packages/testing": {
"name": "@bitgate/testing",
"version": "0.1.0",
diff --git a/packages/core/src/index.js b/packages/core/src/index.js
index a146467..c6b3d24 100644
--- a/packages/core/src/index.js
+++ b/packages/core/src/index.js
@@ -111,6 +111,8 @@ export {
aggregateMutes,
} from "./evaluator.js";
+export { VERSION } from "./version.js";
+
/**
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
* @typedef {import('./identifiers.js').UserTarget} UserTarget
diff --git a/packages/core/src/version.js b/packages/core/src/version.js
new file mode 100644
index 0000000..e9db93c
--- /dev/null
+++ b/packages/core/src/version.js
@@ -0,0 +1,9 @@
+/**
+ * BitGate library version.
+ *
+ * Keep this string in lockstep with the root and `@bitgate/core` `package.json`
+ * versions. `packages/core/test/version.test.js` asserts they match. Browser
+ * bundles replace this module at build time with the same value read from
+ * `package.json`, so the shipped file cannot drift.
+ */
+export const VERSION = "0.1.0";
diff --git a/packages/core/test/version.test.js b/packages/core/test/version.test.js
new file mode 100644
index 0000000..7bb4a28
--- /dev/null
+++ b/packages/core/test/version.test.js
@@ -0,0 +1,17 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+import { VERSION } from "../src/index.js";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const rootPkg = JSON.parse(readFileSync(join(HERE, "..", "..", "..", "package.json"), "utf8"));
+const corePkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
+
+describe("VERSION", () => {
+ it("matches package.json (root and @bitgate/core)", () => {
+ expect(VERSION).toBe(rootPkg.version);
+ expect(VERSION).toBe(corePkg.version);
+ });
+});
diff --git a/packages/site/public/docs.html b/packages/site/public/docs.html
index b395fe9..1af9b36 100644
--- a/packages/site/public/docs.html
+++ b/packages/site/public/docs.html
@@ -181,6 +181,7 @@ provider.runtime.policies.setLocalPolicy(policy);
social | feed, profile, playback | Timelines and posts. Discovery declines to hard-hide. |
commerce | browse, detail, checkout, seller-dashboard | Marketplaces. Checkout decides transactions only. |
admin-only | default | Operator deny lists, with no trust-graph signals at all. |
+ allowlist | default | Only allowlisted publishers are shown; unlisted ones are hidden. Symmetric twin of a denylist, for invite-only or curated surfaces. |
diff --git a/packages/standalone/build.mjs b/packages/standalone/build.mjs
index 58a3bb9..1e96297 100644
--- a/packages/standalone/build.mjs
+++ b/packages/standalone/build.mjs
@@ -15,6 +15,8 @@ import { build, context } from "esbuild";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
+import { bitgateVersionPlugin } from "../../scripts/esbuild-bitgate-version.mjs";
+
const HERE = dirname(fileURLToPath(import.meta.url));
/** @type {import("esbuild").BuildOptions} */
@@ -31,6 +33,7 @@ const options = {
banner: {
js: "/*! BitGate (headless standalone) — GPL-3.0-or-later — https://github.com/PR0M3TH3AN/Nostr-Governance */",
},
+ plugins: [bitgateVersionPlugin()],
};
const watch = process.argv.includes("--watch");
diff --git a/packages/standalone/src/expose-version.js b/packages/standalone/src/expose-version.js
new file mode 100644
index 0000000..7c5bef7
--- /dev/null
+++ b/packages/standalone/src/expose-version.js
@@ -0,0 +1,29 @@
+import { VERSION } from "@bitgate/core";
+
+/**
+ * Publish `window.bitgate.version` for script-tag integrators.
+ *
+ * Additive: creates `window.bitgate` when missing, otherwise writes `version`
+ * onto the existing object so a host-provided namespace is not replaced.
+ * No-op in Node (no `window`).
+ *
+ * @param {Record} [target] - Defaults to `globalThis.window`
+ * @returns {Record|null} The namespace that received `version`
+ */
+export function exposeBitGateVersion(target) {
+ const root =
+ target ??
+ (typeof globalThis.window !== "undefined"
+ ? /** @type {Record} */ (globalThis.window)
+ : null);
+ if (!root || typeof root !== "object") {
+ return null;
+ }
+
+ const existing = root.bitgate;
+ const ns =
+ existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
+ ns.version = VERSION;
+ root.bitgate = ns;
+ return ns;
+}
diff --git a/packages/standalone/src/index.js b/packages/standalone/src/index.js
index 1bebdb3..11cf25b 100644
--- a/packages/standalone/src/index.js
+++ b/packages/standalone/src/index.js
@@ -11,5 +11,10 @@
// Both re-exported packages are dependency-free (@bitgate/verify, the only
// package with third-party deps, is intentionally not pulled in), so the bundle
// carries no external code.
+import { exposeBitGateVersion } from "./expose-version.js";
+
export * from "@bitgate/core";
export * from "@bitgate/nostr";
+export { exposeBitGateVersion };
+
+exposeBitGateVersion();
diff --git a/packages/standalone/test/standalone.test.js b/packages/standalone/test/standalone.test.js
index 019725a..9134058 100644
--- a/packages/standalone/test/standalone.test.js
+++ b/packages/standalone/test/standalone.test.js
@@ -2,10 +2,16 @@
// engine (core) + label codec (nostr) — so a no-build site gets everything from
// the single bundled file. Tests the entry, not the built artifact (dist is
// gitignored); the build itself is exercised by `npm run build:standalone`.
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import * as bitgate from "../src/index.js";
+const HERE = dirname(fileURLToPath(import.meta.url));
+const rootPkg = JSON.parse(readFileSync(join(HERE, "..", "..", "..", "package.json"), "utf8"));
+
describe("standalone entry surface", () => {
it("re-exports the core engine and the Nostr label codec together", () => {
for (const name of [
@@ -17,6 +23,7 @@ describe("standalone entry surface", () => {
"createPolicyDefinition",
"createEventTarget",
"ALLOWLIST_POLICY",
+ "VERSION",
// nostr label codec
"decodeLabels",
"encodeLabel",
@@ -67,4 +74,15 @@ describe("standalone entry surface", () => {
expect(decision.transaction?.effect).toBe("deny");
});
+
+ it("exports VERSION matching package.json", () => {
+ expect(bitgate.VERSION).toBe(rootPkg.version);
+ });
+
+ it("publishes version onto a window-like global without replacing other keys", () => {
+ const target = { bitgate: { extra: true } };
+ bitgate.exposeBitGateVersion(target);
+ expect(target.bitgate.version).toBe(bitgate.VERSION);
+ expect(target.bitgate.extra).toBe(true);
+ });
});
diff --git a/packages/widget/README.md b/packages/widget/README.md
index c824642..5b42ff2 100644
--- a/packages/widget/README.md
+++ b/packages/widget/README.md
@@ -34,10 +34,14 @@ both with every descendant. Nothing else to wire.
| --- | --- |
| `relays` | Comma-separated relay URLs (required) |
| `root` | Root administrator pubkey, from your deployment config |
-| `policy` | `social`, `commerce`, or `admin-only` (default) |
+| `policy` | `social`, `commerce`, `admin-only` (default), or `allowlist` |
| `viewer` | Viewer pubkey, if known at render time |
| `application` / `namespace` | Storage and event namespacing |
+`allowlist` is the symmetric twin of a denylist: only publishers with a
+`user-allow` contribution are shown, unlisted ones are hidden across all four
+dimensions, and protected actors (root, moderators) are never gated.
+
### Target attributes
Any target-bearing element reads its target from markup:
@@ -161,13 +165,44 @@ 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:
+`font` from the host. Override the CSS custom properties or target parts:
+
+| Token | Purpose |
+| --- | --- |
+| `--bitgate-fg` | Foreground / text (`currentColor`) |
+| `--bitgate-muted` | Secondary text |
+| `--bitgate-border` | Control borders |
+| `--bitgate-surface` | Button / panel background |
+| `--bitgate-warn` | Warning copy |
+| `--bitgate-danger` | Danger / destructive accent |
+| `--bitgate-radius` | Control corner radius |
+
+Each token prefers `--bitgate-*`, then the deprecated `--gov-*` name
+(`--gov-fg`, `--gov-muted`, `--gov-border`, `--gov-surface`, `--gov-warn`,
+`--gov-danger`, `--gov-radius`), then the built-in default. Existing `--gov-*`
+overrides keep working; new pages should set `--bitgate-*`. `::part()` names
+are unchanged.
```css
+bitgate-veil { --bitgate-warn: #c2410c; }
bitgate-veil::part(veil) { border: 1px solid red; }
```
+## Version
+
+The library version is the `package.json` version, injected into browser
+bundles at build time:
+
+```js
+import { VERSION } from "@bitgate/widget";
+console.log(VERSION); // e.g. "0.1.0"
+console.log(window.bitgate.version); // same string, for script-tag hosts
+```
+
+`window.bitgate` is created if missing; if a host already put an object there,
+only `.version` is written. Spec / wire versions (`bitgate/1`, policy
+`schemaVersion`) are independent of this package version.
+
## Environment
`defineBitGateElements()` is safe to call repeatedly and safe to import in
diff --git a/packages/widget/build.mjs b/packages/widget/build.mjs
index d49ed86..4ceea9e 100644
--- a/packages/widget/build.mjs
+++ b/packages/widget/build.mjs
@@ -19,6 +19,8 @@ import { build, context } from "esbuild";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
+import { bitgateVersionPlugin } from "../../scripts/esbuild-bitgate-version.mjs";
+
const HERE = dirname(fileURLToPath(import.meta.url));
/** @type {import("esbuild").BuildOptions} */
@@ -37,6 +39,7 @@ const options = {
banner: {
js: "/*! BitGate widget — GPL-3.0-or-later — https://github.com/PR0M3TH3AN/Nostr-Governance */",
},
+ plugins: [bitgateVersionPlugin()],
};
const watch = process.argv.includes("--watch");
diff --git a/packages/widget/src/admin.js b/packages/widget/src/admin.js
index 3a347ea..ab27aa3 100644
--- a/packages/widget/src/admin.js
+++ b/packages/widget/src/admin.js
@@ -11,7 +11,7 @@
// 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";
+import { GovernanceElement, defineElement, escapeHtml, shortenKey, themeVar } from "./base.js";
/**
* @typedef {import('@bitgate/runtime').GovernanceCommands} GovernanceCommands
@@ -213,10 +213,10 @@ export class GovernanceAdminPanel extends GovernanceElement {
this.renderHtml(`
${
diff --git a/packages/widget/src/base.js b/packages/widget/src/base.js
index 485f18a..8452f44 100644
--- a/packages/widget/src/base.js
+++ b/packages/widget/src/base.js
@@ -27,34 +27,75 @@ export function canRegisterElements() {
return typeof globalThis.HTMLElement === "function" && typeof globalThis.customElements === "object";
}
+/**
+ * Default values for widget theme tokens.
+ *
+ * `--gov-*` names are the historical prefix (from Nostr-Governance). `--bitgate-*`
+ * is the agreed prefix. Usage sites prefer the new name, then the old one, then
+ * this default, so existing pages and `--gov-*` overrides keep working.
+ *
+ * @type {Readonly<{
+ * fg: string,
+ * muted: string,
+ * border: string,
+ * surface: string,
+ * warn: string,
+ * danger: string,
+ * radius: string,
+ * }>}
+ */
+export const THEME_DEFAULTS = Object.freeze({
+ fg: "currentColor",
+ muted: "color-mix(in srgb, currentColor 60%, transparent)",
+ border: "color-mix(in srgb, currentColor 20%, transparent)",
+ surface: "color-mix(in srgb, canvas 96%, currentColor)",
+ warn: "#b45309",
+ danger: "#b91c1c",
+ radius: "8px",
+});
+
+/**
+ * CSS value for a theme token: `--bitgate-*` wins, then deprecated `--gov-*`,
+ * then the existing default.
+ *
+ * `--bitgate-*` is deliberately not assigned on `:host`. Assigning it would
+ * make `var(--bitgate-*, …)` always hit and ignore `--gov-*` overrides.
+ *
+ * @param {keyof typeof THEME_DEFAULTS} name
+ * @returns {string}
+ */
+export function themeVar(name) {
+ return `var(--bitgate-${name}, var(--gov-${name}, ${THEME_DEFAULTS[name]}))`;
+}
+
/** 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;
+ --gov-fg: ${THEME_DEFAULTS.fg};
+ --gov-muted: ${THEME_DEFAULTS.muted};
+ --gov-border: ${THEME_DEFAULTS.border};
+ --gov-surface: ${THEME_DEFAULTS.surface};
+ --gov-warn: ${THEME_DEFAULTS.warn};
+ --gov-danger: ${THEME_DEFAULTS.danger};
+ --gov-radius: ${THEME_DEFAULTS.radius};
display: block;
- color: var(--gov-fg);
+ color: ${themeVar("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);
+ background: ${themeVar("surface")};
+ border: 1px solid ${themeVar("border")};
+ border-radius: ${themeVar("radius")};
padding: 0.4em 0.8em;
cursor: pointer;
}
- button:hover:not(:disabled) { border-color: var(--gov-fg); }
+ button:hover:not(:disabled) { border-color: ${themeVar("fg")}; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.reason {
- color: var(--gov-muted);
+ color: ${themeVar("muted")};
font-size: 0.875em;
}
.row { display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; }
diff --git a/packages/widget/src/expose-version.js b/packages/widget/src/expose-version.js
new file mode 100644
index 0000000..7c5bef7
--- /dev/null
+++ b/packages/widget/src/expose-version.js
@@ -0,0 +1,29 @@
+import { VERSION } from "@bitgate/core";
+
+/**
+ * Publish `window.bitgate.version` for script-tag integrators.
+ *
+ * Additive: creates `window.bitgate` when missing, otherwise writes `version`
+ * onto the existing object so a host-provided namespace is not replaced.
+ * No-op in Node (no `window`).
+ *
+ * @param {Record
} [target] - Defaults to `globalThis.window`
+ * @returns {Record|null} The namespace that received `version`
+ */
+export function exposeBitGateVersion(target) {
+ const root =
+ target ??
+ (typeof globalThis.window !== "undefined"
+ ? /** @type {Record} */ (globalThis.window)
+ : null);
+ if (!root || typeof root !== "object") {
+ return null;
+ }
+
+ const existing = root.bitgate;
+ const ns =
+ existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
+ ns.version = VERSION;
+ root.bitgate = ns;
+ return ns;
+}
diff --git a/packages/widget/src/index.js b/packages/widget/src/index.js
index 0e84329..de3da41 100644
--- a/packages/widget/src/index.js
+++ b/packages/widget/src/index.js
@@ -21,9 +21,11 @@ export {
} from "@bitgate/runtime";
export {
+ VERSION,
SOCIAL_POLICY,
COMMERCE_POLICY,
ADMIN_ONLY_POLICY,
+ ALLOWLIST_POLICY,
POLICY_PRESETS,
getPolicyPreset,
createPolicyDefinition,
@@ -42,6 +44,7 @@ export {
export {
BASE_STYLES,
+ THEME_DEFAULTS,
GovernanceElement,
canRegisterElements,
defineElement,
@@ -49,8 +52,11 @@ export {
requestContext,
shortenKey,
targetFromAttributes,
+ themeVar,
} from "./base.js";
+export { exposeBitGateVersion } from "./expose-version.js";
+
export {
BitGateError,
BitGateProvider,
@@ -76,9 +82,12 @@ export {
} from "./admin.js";
import { defineAdminElements } from "./admin.js";
+import { exposeBitGateVersion } from "./expose-version.js";
import { defineProviderElements } from "./provider.js";
import { defineViewerElements } from "./viewer.js";
+exposeBitGateVersion();
+
/**
* Register every governance element.
*
diff --git a/packages/widget/src/provider.js b/packages/widget/src/provider.js
index 0550a07..18a8917 100644
--- a/packages/widget/src/provider.js
+++ b/packages/widget/src/provider.js
@@ -151,7 +151,7 @@ export class BitGateProvider extends ProviderBase {
if (!policy) {
throw new Error(
- `Unknown policy preset "${policyName}". Use social, commerce, admin-only, or set .policy directly.`,
+ `Unknown policy preset "${policyName}". Use social, commerce, admin-only, allowlist, or set .policy directly.`,
);
}
if (relays.length === 0) {
diff --git a/packages/widget/src/viewer.js b/packages/widget/src/viewer.js
index 5e5b74a..d558f8c 100644
--- a/packages/widget/src/viewer.js
+++ b/packages/widget/src/viewer.js
@@ -10,6 +10,7 @@ import {
escapeHtml,
shortenKey,
targetFromAttributes,
+ themeVar,
} from "./base.js";
/**
@@ -161,7 +162,7 @@ export class GovernanceVeil extends GovernanceElement {
position: absolute; inset: 0; display: grid; place-content: center;
gap: 0.5em; text-align: center; padding: 1em;
}
- .warn { color: var(--gov-warn); }
+ .warn { color: ${themeVar("warn")}; }
diff --git a/packages/widget/test/build-version.test.js b/packages/widget/test/build-version.test.js
new file mode 100644
index 0000000..fae137b
--- /dev/null
+++ b/packages/widget/test/build-version.test.js
@@ -0,0 +1,27 @@
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { build } from "esbuild";
+import { describe, expect, it } from "vitest";
+
+import { bitgateVersionPlugin, readLibraryVersion } from "../../../scripts/esbuild-bitgate-version.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const versionJs = join(HERE, "..", "..", "core", "src", "version.js");
+
+describe("build-time version injection", () => {
+ it("replaces the version module with package.json's version", async () => {
+ const version = readLibraryVersion();
+ const result = await build({
+ entryPoints: [versionJs],
+ bundle: true,
+ write: false,
+ format: "esm",
+ platform: "neutral",
+ plugins: [bitgateVersionPlugin(version)],
+ });
+
+ const bundled = result.outputFiles[0].text;
+ expect(bundled).toContain(`VERSION = ${JSON.stringify(version)}`);
+ expect(bundled).not.toContain("Keep this string in lockstep");
+ });
+});
diff --git a/packages/widget/test/provider.test.js b/packages/widget/test/provider.test.js
index fca1dfe..ddffa46 100644
--- a/packages/widget/test/provider.test.js
+++ b/packages/widget/test/provider.test.js
@@ -135,6 +135,11 @@ describe("
", () => {
expect(provider.runtime.policies.policy.id).toBe("bitgate-commerce");
});
+ it("accepts the allowlist policy preset", async () => {
+ const provider = await mountProvider({ policy: "allowlist" });
+ expect(provider.runtime.policies.policy.id).toBe("bitgate-allowlist");
+ });
+
it("defaults to administrative-only governance", async () => {
const provider = await mountProvider({ policy: null });
expect(provider.runtime.policies.policy.id).toBe("bitgate-admin-only");
@@ -154,6 +159,7 @@ describe("", () => {
const provider = await mountProvider({ policy: "invented" });
expect(provider.runtime).toBeNull();
expect(provider.getAttribute("data-error")).toMatch(/Unknown policy preset/);
+ expect(provider.getAttribute("data-error")).toMatch(/allowlist/);
});
it("reports a missing relays attribute", async () => {
diff --git a/packages/widget/test/theme.test.js b/packages/widget/test/theme.test.js
new file mode 100644
index 0000000..0d30a52
--- /dev/null
+++ b/packages/widget/test/theme.test.js
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { BASE_STYLES, THEME_DEFAULTS, themeVar } from "../src/base.js";
+
+describe("theme tokens", () => {
+ it("resolves --bitgate-* first, then deprecated --gov-*, then the default", () => {
+ for (const name of Object.keys(THEME_DEFAULTS)) {
+ expect(themeVar(/** @type {keyof typeof THEME_DEFAULTS} */ (name))).toBe(
+ `var(--bitgate-${name}, var(--gov-${name}, ${THEME_DEFAULTS[name]}))`,
+ );
+ }
+ });
+
+ it("keeps --gov-* host defaults so existing overrides still apply", () => {
+ for (const [name, value] of Object.entries(THEME_DEFAULTS)) {
+ expect(BASE_STYLES).toContain(`--gov-${name}: ${value}`);
+ }
+ });
+
+ it("does not assign --bitgate-* on :host (that would shadow --gov-* overrides)", () => {
+ expect(BASE_STYLES).not.toMatch(/--bitgate-[a-z]+:/);
+ });
+
+ it("uses the fallback chain at every painted token", () => {
+ expect(BASE_STYLES).toContain(themeVar("fg"));
+ expect(BASE_STYLES).toContain(themeVar("muted"));
+ expect(BASE_STYLES).toContain(themeVar("border"));
+ expect(BASE_STYLES).toContain(themeVar("surface"));
+ expect(BASE_STYLES).toContain(themeVar("radius"));
+ });
+});
diff --git a/packages/widget/test/version.test.js b/packages/widget/test/version.test.js
new file mode 100644
index 0000000..f62c267
--- /dev/null
+++ b/packages/widget/test/version.test.js
@@ -0,0 +1,28 @@
+// @vitest-environment happy-dom
+
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+import { VERSION, exposeBitGateVersion } from "../src/index.js";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const rootPkg = JSON.parse(readFileSync(join(HERE, "..", "..", "..", "package.json"), "utf8"));
+
+describe("VERSION", () => {
+ it("matches package.json", () => {
+ expect(VERSION).toBe(rootPkg.version);
+ });
+
+ it("is published on window.bitgate when the widget is imported", () => {
+ expect(/** @type {any} */ (window).bitgate.version).toBe(VERSION);
+ });
+
+ it("writes version onto an existing window.bitgate object", () => {
+ const target = { bitgate: { extra: true } };
+ exposeBitGateVersion(target);
+ expect(target.bitgate.version).toBe(VERSION);
+ expect(target.bitgate.extra).toBe(true);
+ });
+});
diff --git a/scripts/esbuild-bitgate-version.mjs b/scripts/esbuild-bitgate-version.mjs
new file mode 100644
index 0000000..4ce0534
--- /dev/null
+++ b/scripts/esbuild-bitgate-version.mjs
@@ -0,0 +1,30 @@
+/**
+ * esbuild plugin: replace `@bitgate/core`'s version module with the value from
+ * package.json so browser bundles cannot drift from the package.
+ */
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+/** @returns {string} */
+export function readLibraryVersion() {
+ return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
+}
+
+/**
+ * @param {string} [injected]
+ * @returns {import("esbuild").Plugin}
+ */
+export function bitgateVersionPlugin(injected = readLibraryVersion()) {
+ return {
+ name: "bitgate-version",
+ setup(pluginBuild) {
+ pluginBuild.onLoad({ filter: /[/\\]core[/\\]src[/\\]version\.js$/ }, () => ({
+ contents: `export const VERSION = ${JSON.stringify(injected)};\n`,
+ loader: "js",
+ }));
+ },
+ };
+}