openvibers/openvibe.shared Commits

4.111.0: Vibes Pay in the browser (vibes-pay.js) (#289)

OP OpenVibers committed 8 days ago · a9b4959a51 · 9 files, +441 −9

OpenVibe.vibes.pay(payUrl) opens the sheet in a popup on a computer and as the full page on a
phone, resolves { status } from an origin-checked openvibe.network postMessage (or 'closed'), and
auto-binds [data-ov-vibes-pay] in the Vibes accent with the label "Pay with Vibes" on both
themes. Adds the export, the browser file, docs and a linkedom test; no tag.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a7a2e4b..8de9ae0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,19 @@
# Changelog
++## 4.111.0 — 2026-11-04
++
++- **`openvibe-shared/vibes-pay` (`vibes-pay.js`): Vibes Pay for any page** (MONEY.md, D12).
++ - `OpenVibe.vibes.pay(payUrl)` opens the sheet in a 520 px popup on a computer and as the full page in a new tab on
++ a phone (a blocked popup falls back to navigating the page). It resolves `{ status }` (plus `charge_id` and
++ `pay_url` when known) from a `postMessage` whose `origin` is `https://openvibe.network` and whose data is
++ `{ type: 'ov-vibes-pay', status, charge_id? }`; a popup closed without a payment resolves `{ status: 'closed' }`.
++ The same result is dispatched as a bubbling `ov-vibes-pay` event on the document. Only
++ `https://openvibe.network/pay/…` URLs are accepted; anything else throws before a window is opened.
++ - `[data-ov-vibes-pay]` elements bind once: the label is "Pay with Vibes", the Vibes green with dark ink and a
++ focus ring (light and dark themes), a 44 px target, disabled while the sheet is open, and re-enabled on the
++ result. `bind(document)` is there for buttons added later; a `MutationObserver` picks them up automatically.
++ - The server side creates the charge with `openvibe-sdk/vibes`. `docs/vibes-pay.md` is the guide; the button is
++ not added to openvibe.design's `/components` page from this repository (that page is OpenVibe.Design's source).
All notable changes to `openvibe-shared`. Versions follow [semver](https://semver.org/): a
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
ddiff --git a/README.md b/README.md
iindex 0a9e076..000432c 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.110.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.111.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -19,7 +19,7 @@ were reconciled first.
| Kind | Files |
|---|---|
--| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
++| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `vibes-pay.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `glyphs` (the network's icons, one registry: `NAMES`, `path(name)`, `svg(name, { size, title })`, `fromFa(name)`, `FA`; the browser drop-in `ov-glyphs.js` shows them in place of Font Awesome's and keeps Font Awesome's classes, animations and `data-fa-transform`; also as fonts, `/shared/ov-glyphs.woff2` with `ov-glyphs-font.css` (`ovi ovi-<name>`) or `ov-glyphs-fa.css` (Font Awesome's names), `glyph-fonts/ov-glyphs.{ttf,otf}` and `glyph-codepoints.json`, and as the sprite `/shared/ov-glyphs.svg#ovg-<name>`), `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
@@@ -798,6 +798,29 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
++### Vibes Pay (`vibes-pay.js`, 4.111.0)
++
++The browser half of Vibes Pay (MONEY.md, D12): a page on any site can sell something for Vibes.
++
++```html
++<script src="https://openvibe.network/shared/vibes-pay.js" defer></script>
++<button data-ov-vibes-pay="https://openvibe.network/pay/vch_…">Pay with Vibes</button>
++```
++
++The button binds itself: the Vibes green (`#4ade80`, dark ink), the label "Pay with Vibes", a 44 px target, and a
++focus ring; it reads on light and dark themes and on every theme's accent. A click opens the sheet in a 520 px popup
++on a computer, or as the full page in a new tab on a phone; if the browser blocks it, the page navigates to the sheet.
++The sheet posts `{ type: 'ov-vibes-pay', status, charge_id }` to the page only from `https://openvibe.network`; the
++promise resolves `{ status }` (also on the bubbling `ov-vibes-pay` event), and a popup closed without paying resolves
++`{ status: 'closed' }`. A pay URL that is not `https://openvibe.network/pay/…` throws.
++
++```js
++const { status } = await OpenVibe.vibes.pay(payUrl); // 'paid', 'cancelled', …
++```
++
++Or call `OpenVibe.vibes.bind(document)` after adding buttons to the DOM yourself. The server side creates the charge
++with `openvibe-sdk/vibes`; see [docs/vibes-pay.md](docs/vibes-pay.md).
++
### The video player (`player.js`, `player.css`)
One look for every `<video>` across the network. Mark the element and load both files; without the script the
ddiff --git a/STATUS.json b/STATUS.json
iindex 7e8cb14..edb226d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.110.0",
++ "version": "4.111.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@@ -26,7 +26,8 @@
"browser harness and perf budgets for tests",
"a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",
"the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went",
-- "the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads"
++ "the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads",
++ "vibes-pay.js: OpenVibe.vibes.pay(payUrl) opens the Vibes Pay sheet (popup on a computer, full page on a phone), resolves { status } from an origin-checked postMessage and binds <button data-ov-vibes-pay> in the Vibes accent, both themes (D12)"
],
-- "updated": "2026-11-03"
++ "updated": "2026-11-04"
}
ddiff --git a/docs/vibes-pay.md b/docs/vibes-pay.md
nnew file mode 100644
iindex 0000000..e0cc296
---- /dev/null
++++ b/docs/vibes-pay.md
@@@ -0,0 +1,66 @@
++# Vibes Pay in the browser (`openvibe-shared/vibes-pay`)
++
++The browser half of Vibes Pay (MONEY.md, D12). An app's server creates a charge with
++`openvibe-sdk/vibes`; its page opens the pay sheet with this file.
++
++```html
++<script src="https://openvibe.network/shared/vibes-pay.js" defer></script>
++
++<button data-ov-vibes-pay="https://openvibe.network/pay/vch_…">Pay with Vibes</button>
++```
++
++The button binds itself once: the Vibes green (`#4ade80`) with dark ink, the label "Pay with Vibes", a 44 px touch
++target and a focus ring. It reads on light and dark themes and under every site theme's `data-theme`. While the sheet
++is open the button is disabled; it is enabled again when the sheet resolves. `OpenVibe.vibes.bind(document)` binds
++buttons added after load; a `MutationObserver` also catches them.
++
++## The promise
++
++```js
++const result = await OpenVibe.vibes.pay(payUrl);
++// { status: 'paid', charge_id: 'vch_…', pay_url: 'https://openvibe.network/pay/vch_…' }
++```
++
++`pay(payUrl)` opens `https://openvibe.network/pay/<id>`:
++
++- **a computer:** a 520 × 720 popup named `ov-vibes-pay`;
++- **a phone (max-width 640 px):** the full page in a new tab, so the shop stays behind it;
++- **a blocked popup:** the page navigates to the sheet instead, and there is no promise result (the return page decides).
++
++The sheet posts to its opener, and only from `https://openvibe.network`:
++
++```js
++{ type: 'ov-vibes-pay', status: 'paid', charge_id: 'vch_…' }
++```
++
++The origin is checked; a message of another type, another origin, or without a status is ignored. A popup the
++person closes without paying resolves `{ status: 'closed' }`. Other statuses are the sheet's (`paid`, `cancelled`,
++`refunded`, `expired`). The same result is dispatched on the document as the bubbling `ov-vibes-pay` event:
++
++```js
++document.addEventListener('ov-vibes-pay', (e) => {
++ if (e.detail.status === 'paid') showThanks();
++});
++```
++
++## Security
++
++- A pay URL must be `https://openvibe.network/pay/…`; any other URL throws before a window opens.
++- The app token stays on the server. The page only receives the charge's `pay_url` and id.
++- Treat `postMessage` as a hint to refresh: the order is confirmed by the server, with the webhook's signature and
++ `GET /api/v1/vibes/charges/:id`, never by the page alone.
++- The sheet never shows a card form: the person pays Vibes they already bought.
++
++## Styling
++
++The button's CSS is injected once (`<style id="ov-vibes-pay-style">`). A page may override `.ov-vibes-pay`; keep the
++44 px target and the contrast of the ink on the fill. The fill is fixed to the Vibes accent rather than a site's
++`--accent`, because Vibes has one colour everywhere; the ink is dark, so it stays readable when a theme is light.
++
++## Server side
++
++Create the charge with `openvibe-sdk/vibes`; verify the webhook with the same client. The full flow, plain HTTP,
++webhooks, sandbox, refunds and payouts are in openvibe.services `/docs/vibes`.
++
++OpenVibe.Design's `/components` page is OpenVibe.Design's source, not this repository's; the button is described here
++and in the README, and `openvibe-shared/components` remains the shared component contract for the kit.
ddiff --git a/files.js b/files.js
iindex e87b91d..c65a5df 100644
---- a/files.js
++++ b/files.js
@@@ -47,6 +47,7 @@ const BROWSER = Object.freeze([
'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile
'tooltip.js',
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
++ 'vibes-pay.js', // Vibes Pay: OpenVibe.vibes.pay() and <button data-ov-vibes-pay> (MONEY.md, D12); 4.111.0
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
ddiff --git a/package-lock.json b/package-lock.json
iindex 728c88c..62b0fda 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.110.0",
++ "version": "4.111.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.110.0",
++ "version": "4.111.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex cb29ed0..aae8bbd 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.110.0",
++ "version": "4.111.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -90,6 +90,7 @@
"./theme-sync": "./theme-sync.js",
"./tooltip": "./tooltip.js",
"./player": "./player.js",
++ "./vibes-pay": "./vibes-pay.js",
"./trace": "./trace.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
@@@ -118,7 +119,8 @@
"social/",
"templates/",
"CHANGELOG.md",
-- "docs/agent-layer.md"
++ "docs/agent-layer.md",
++ "docs/vibes-pay.md"
],
"scripts": {
"test": "node test/run.js",
ddiff --git a/test/vibes-pay.test.js b/test/vibes-pay.test.js
nnew file mode 100644
iindex 0000000..28696bd
---- /dev/null
++++ b/test/vibes-pay.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++// vibes-pay.js in a linkedom page: the auto-bound button is styled and labelled "Pay with Vibes";
++// the sheet opens in a popup on a computer and as a full page on a phone; the promise resolves only
++// from an openvibe.network postMessage ({ type: 'ov-vibes-pay', status }), and a closed popup says so.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { parseHTML } = require('linkedom');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'vibes-pay.js'), 'utf8');
++
++function page({ phone = false, url = 'https://openvibe.network/pay/vch_01TEST' } = {}) {
++ const { window, document } = parseHTML(`<!doctype html><html><head></head><body>
++<button id="buy" data-ov-vibes-pay="${url}">Buy</button>
++<a id="plain" href="${url}">Pay</a></body></html>`);
++ Object.defineProperty(document, 'readyState', { configurable: true, value: 'complete' });
++ const out = { opened: [], assigned: [], listeners: {}, events: [] };
++ const popup = { closed: false, focus() { out.focused = true; } };
++ const ctx = {
++ document, URL, Promise, Object, Array, String, Number, Math, Boolean, TypeError, Error, JSON, Set, Map,
++ console: { error: (...a) => out.events.push(['error', a.join(' ')]) },
++ location: { assign: (u) => out.assigned.push(u), href: 'https://shop.example/' },
++ matchMedia: () => ({ matches: phone }),
++ open: (u, name, features) => { out.opened.push({ url: u, name, features }); return popup; },
++ addEventListener: (type, fn) => { (out.listeners[type] ||= []).push(fn); },
++ setInterval: (fn, ms) => { const id = setInterval(fn, ms); id.unref(); return id; },
++ clearInterval: (id) => clearInterval(id),
++ innerWidth: 1280, innerHeight: 800, screenX: 10, screenY: 20, outerWidth: 1280, outerHeight: 900,
++ Event: window.Event, CustomEvent: window.CustomEvent || class { constructor(t, i = {}) { this.type = t; this.detail = i.detail; this.bubbles = !!i.bubbles; } },
++ };
++ ctx.window = ctx; ctx.globalThis = ctx; ctx.self = ctx;
++ vm.createContext(ctx);
++ vm.runInContext(SRC, ctx, { filename: 'vibes-pay.js' });
++ const open = (fn) => new Promise((resolve) => { const t = setTimeout(() => resolve('timeout'), 1500); fn((v) => { clearTimeout(t); resolve(v); }); });
++ const message = (data, origin = 'https://openvibe.network', source = popup) => {
++ for (const fn of out.listeners.message || []) fn({ data, origin, source });
++ };
++ return { ctx, document, window, out, popup, open, message };
++}
++
++(async () => {
++ {
++ const p = page();
++ const v = p.ctx.OpenVibe.vibes;
++ assert.strictEqual(typeof v.pay, 'function');
++ assert.strictEqual(v.ORIGIN, 'https://openvibe.network');
++
++ // The auto-bound button: styled, labelled, a non-submitting button, one bind only.
++ const btn = p.document.getElementById('buy');
++ assert.strictEqual(btn.textContent, 'Pay with Vibes');
++ assert.ok(btn.classList.contains('ov-vibes-pay'));
++ assert.strictEqual(btn.getAttribute('type'), 'button');
++ assert.ok(p.document.getElementById('ov-vibes-pay-style'), 'the style is injected once');
++ assert.strictEqual(v.bind(p.document), 0, 'binding twice is a no-op');
++ assert.strictEqual(p.document.getElementById('plain').classList.contains('ov-vibes-pay'), false, 'only data-ov-vibes-pay binds');
++
++ // Clicking it opens the popup; a message from openvibe.network resolves it and re-enables the button.
++ const result = await p.open((done) => {
++ btn.click();
++ setImmediate(() => {
++ assert.strictEqual(p.out.opened.length, 1);
++ assert.strictEqual(p.out.opened[0].url, 'https://openvibe.network/pay/vch_01TEST');
++ assert.strictEqual(p.out.opened[0].name, 'ov-vibes-pay');
++ assert.match(p.out.opened[0].features, /width=/);
++ assert.strictEqual(btn.disabled, true);
++ p.message({ type: 'ov-vibes-pay', status: 'paid', charge_id: 'vch_01TEST' });
++ setImmediate(() => { done(p.out.events.slice()); });
++ });
++ });
++ assert.deepStrictEqual(result, [], 'no page errors');
++ assert.strictEqual(btn.disabled, false, 'the button is usable again');
++ assert.strictEqual(p.out.events.find((e) => e[0] === 'error'), undefined);
++ }
++
++ {
++ // pay() itself: origin-checked, type-checked, and it ignores anything else before the valid message.
++ const p = page();
++ const v = p.ctx.OpenVibe.vibes;
++ const promise = v.pay('https://openvibe.network/pay/vch_02TEST');
++ assert.strictEqual(p.out.opened[0].name, 'ov-vibes-pay');
++ p.message({ type: 'ov-vibes-pay', status: 'paid' }, 'https://evil.example');
++ p.message({ type: 'something-else', status: 'paid' });
++ p.message({ type: 'ov-vibes-pay' });
++ p.message({ type: 'ov-vibes-pay', status: 'paid', charge_id: 'vch_02TEST' });
++ const out = await promise;
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(out)), { status: 'paid', pay_url: 'https://openvibe.network/pay/vch_02TEST', charge_id: 'vch_02TEST' });
++ assert.throws(() => v.pay('https://evil.example/pay/vch_1'), /openvibe\.network/);
++ assert.throws(() => v.pay('http://openvibe.network/pay/vch_1'), /openvibe\.network/);
++ assert.throws(() => v.pay('not a url'), /pay/);
++ assert.throws(() => v.pay('https://openvibe.network/wallet'), /pay/);
++ }
++
++ {
++ // A phone gets the full page (a new tab), not the popup; a closed popup resolves { status: 'closed' }.
++ const p = page({ phone: true });
++ const v = p.ctx.OpenVibe.vibes;
++ const promise = v.pay('/pay/vch_03TEST');
++ assert.deepStrictEqual(p.out.opened[0], { url: 'https://openvibe.network/pay/vch_03TEST', name: '_blank', features: undefined });
++ p.popup.closed = true;
++ await new Promise((r) => setTimeout(r, 800));
++ const closed = await promise;
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(closed)), { status: 'closed', pay_url: 'https://openvibe.network/pay/vch_03TEST' });
++ }
++
++ {
++ // A blocked popup falls back to the full page (no throw, no unhandled rejection).
++ const p = page();
++ p.out.opened.length = 0;
++ p.ctx.open = () => null;
++ const promise = p.ctx.OpenVibe.vibes.pay('https://openvibe.network/pay/vch_04TEST');
++ assert.deepStrictEqual(p.out.assigned, ['https://openvibe.network/pay/vch_04TEST']);
++ assert.ok(promise instanceof Promise);
++ }
++
++ console.log('vibes-pay: ok');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/vibes-pay.js b/vibes-pay.js
nnew file mode 100644
iindex 0000000..8c89b76
---- /dev/null
++++ b/vibes-pay.js
@@@ -0,0 +1,208 @@
++'use strict';
++/**
++ * openvibe-shared/vibes-pay.js — Vibes Pay for any page (MONEY.md, D12).
++ *
++ * <script src="https://openvibe.network/shared/vibes-pay.js"></script>
++ *
++ * <button data-ov-vibes-pay="https://openvibe.network/pay/vch_…">Pay with Vibes</button>
++ *
++ * OpenVibe.vibes.pay(payUrl).then(({ status }) => { … });
++ *
++ * A button with data-ov-vibes-pay binds itself: it is styled as "Pay with Vibes" (the Vibes green,
++ * readable on light and dark themes), and a click opens the sheet in a popup on a computer and as a
++ * full page on a phone. The sheet posts { type: 'ov-vibes-pay', status, charge_id } to the page, only
++ * from https://openvibe.network; the promise resolves with { status } (and charge_id when sent), and
++ * a page may listen for the bubbling 'ov-vibes-pay' event instead. A popup closed without a payment
++ * resolves { status: 'closed' }.
++ *
++ * The sheet itself is openvibe.network/pay/<charge id>; a pay URL on another origin is refused.
++ * The button is progressive: without JavaScript (or this file) the link can still be a plain anchor.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibe = root.OpenVibe || {};
++ root.OpenVibe.vibes = Object.assign(root.OpenVibe.vibes || {}, api);
++ root.OVVibesPay = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const ORIGIN = 'https://openvibe.network';
++ const TYPE = 'ov-vibes-pay';
++ const STYLE_ID = 'ov-vibes-pay-style';
++ const POPUP = 'ov-vibes-pay';
++ const PHONE = '(max-width: 640px)';
++
++ let pending = null;
++
++ const isPhone = () => {
++ try { return !!(root.matchMedia && root.matchMedia(PHONE).matches); } catch { return false; }
++ };
++
++ /** `payUrl` must be an https URL on openvibe.network (a relative /pay/… path is taken as one). */
++ function payTarget(payUrl) {
++ let u;
++ try { u = new URL(String(payUrl), ORIGIN); } catch { throw new TypeError('vibes.pay: payUrl is a URL'); }
++ if (u.protocol !== 'https:' || u.origin !== ORIGIN) throw new TypeError(`vibes.pay: payUrl must be on ${ORIGIN}`);
++ if (!/^\/pay\/[^/]+/.test(u.pathname)) throw new TypeError('vibes.pay: payUrl is an openvibe.network/pay/… URL');
++ return u.href;
++ }
++
++ function emit(result) {
++ const doc = root.document;
++ if (!doc || typeof root.CustomEvent !== 'function') return;
++ try { doc.dispatchEvent(new root.CustomEvent(TYPE, { detail: result, bubbles: true })); } catch { /* an old browser */ }
++ }
++
++ function settle(result) {
++ const p = pending;
++ pending = null;
++ if (p && p.timer) clearInterval(p.timer);
++ if (p) { p.resolve(result); emit(result); }
++ }
++
++ function onMessage(event) {
++ if (!event || event.origin !== ORIGIN) return;
++ const data = event.data;
++ if (!data || typeof data !== 'object' || data.type !== TYPE) return;
++ if (typeof data.status !== 'string' || !data.status) return;
++ if (!pending) return;
++ // A popup: only the window we opened may answer. A page that did not keep the WindowProxy skips this.
++ if (pending.popup && event.source && event.source !== pending.popup) return;
++ const result = { status: data.status, pay_url: pending.href };
++ if (data.charge_id) result.charge_id = data.charge_id;
++ settle(result);
++ }
++
++ /**
++ * Open the Vibes Pay sheet and resolve { status } (plus charge_id/pay_url when known).
++ * On a phone it is a full page in a new tab; if the browser blocks it, this page navigates instead.
++ */
++ function pay(payUrl) {
++ const href = payTarget(payUrl);
++ if (pending) return pending.promise;
++ if (!root || typeof root.open !== 'function') throw new TypeError('vibes.pay: this page cannot open a window');
++ let resolve;
++ const promise = new Promise((r) => { resolve = r; });
++ let popup = null;
++ if (isPhone()) {
++ popup = root.open(href, '_blank');
++ } else {
++ const w = Math.min(520, Math.max(360, Number(root.innerWidth) || 1200));
++ const h = Math.min(760, Math.max(560, Number(root.innerHeight) || 800));
++ const left = Math.max(0, Math.round((Number(root.screenX) || 0) + ((Number(root.outerWidth) || w) - w) / 2));
++ const top = Math.max(0, Math.round((Number(root.screenY) || 0) + ((Number(root.outerHeight) || h) - h) / 2));
++ popup = root.open(href, POPUP, `popup=yes,width=${w},height=${h},left=${left},top=${top}`);
++ }
++ if (!popup) {
++ // A blocked popup is not an error: the sheet is a normal page too.
++ if (root.location && typeof root.location.assign === 'function') root.location.assign(href);
++ return promise;
++ }
++ try { popup.focus(); } catch { /* some browsers refuse focus() */ }
++ pending = { href, popup, resolve, promise, timer: null };
++ pending.timer = setInterval(() => {
++ if (pending && pending.popup && pending.popup.closed) settle({ status: 'closed', pay_url: href });
++ }, 700);
++ if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref();
++ return promise;
++ }
++
++ const CSS = `
++.ov-vibes-pay {
++ --ov-vibes-accent: #4ade80;
++ --ov-vibes-accent-light: #86efac;
++ --ov-vibes-ink: #07130a;
++ display: inline-flex;
++ align-items: center;
++ justify-content: center;
++ gap: .5em;
++ min-height: 44px;
++ padding: .62em 1.15em;
++ border: 1px solid rgba(34, 197, 94, .55);
++ border-radius: 999px;
++ background: var(--ov-vibes-accent);
++ color: var(--ov-vibes-ink);
++ font: inherit;
++ font-weight: 700;
++ line-height: 1.2;
++ text-decoration: none;
++ cursor: pointer;
++ box-shadow: 0 6px 18px -10px rgba(34, 197, 94, .8);
++ -webkit-tap-highlight-color: transparent;
++}
++.ov-vibes-pay:hover { background: var(--ov-vibes-accent-light); }
++.ov-vibes-pay:focus-visible { outline: 3px solid color-mix(in srgb, var(--ov-vibes-accent) 55%, transparent); outline-offset: 2px; }
++.ov-vibes-pay:disabled, .ov-vibes-pay[aria-busy="true"] { opacity: .62; cursor: progress; }
++@media (prefers-reduced-motion: no-preference) { .ov-vibes-pay { transition: background-color .15s ease; } }
++`;
++
++ function ensureStyles() {
++ const doc = root.document;
++ if (!doc || doc.getElementById(STYLE_ID)) return;
++ const style = doc.createElement('style');
++ style.id = STYLE_ID;
++ style.textContent = CSS;
++ (doc.head || doc.documentElement).appendChild(style);
++ }
++
++ /**
++ * Bind every [data-ov-vibes-pay] under `scope` (default: the document) once. A bound element is
++ * labelled "Pay with Vibes", disabled while the sheet is open, and dispatches the page event.
++ * Returns how many were bound now.
++ */
++ function bind(scope) {
++ const doc = root.document;
++ if (!doc) return 0;
++ const where = scope || doc;
++ if (!where || typeof where.querySelectorAll !== 'function') return 0;
++ let bound = 0;
++ for (const el of where.querySelectorAll('[data-ov-vibes-pay]')) {
++ if (el.__ovVibesPayBound) continue;
++ const url = el.getAttribute('data-ov-vibes-pay');
++ if (!url) continue;
++ el.__ovVibesPayBound = true;
++ ensureStyles();
++ el.classList.add('ov-vibes-pay');
++ if (String(el.tagName).toUpperCase() === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
++ el.textContent = 'Pay with Vibes';
++ el.addEventListener('click', async (event) => {
++ event.preventDefault();
++ if (el.disabled) return;
++ el.disabled = true;
++ el.setAttribute('aria-busy', 'true');
++ try {
++ await pay(url);
++ } catch (err) {
++ if (root.console && root.console.error) root.console.error('[vibes-pay]', err);
++ } finally {
++ el.disabled = false;
++ el.removeAttribute('aria-busy');
++ }
++ });
++ bound++;
++ }
++ return bound;
++ }
++
++ function autoInit() {
++ const doc = root.document;
++ if (!doc) return;
++ const run = () => { ensureStyles(); bind(doc); };
++ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run, { once: true });
++ else run();
++ if (typeof root.MutationObserver === 'function' && doc.documentElement) {
++ try {
++ new root.MutationObserver(() => bind(doc)).observe(doc.documentElement, { childList: true, subtree: true });
++ } catch { /* no observer, the initial bind still ran */ }
++ }
++ }
++
++ if (root && typeof root.addEventListener === 'function' && !root.__ovVibesPayListening) {
++ root.__ovVibesPayListening = true;
++ root.addEventListener('message', onMessage);
++ autoInit();
++ }
++
++ return { pay, bind, ORIGIN, TYPE };
++});