openvibers/openvibe.shared Commits
4.111.0: Vibes Pay in the browser (vibes-pay.js) (#289)
Changes
@@@ -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/): abreaking 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).@@@ -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 deployedconsumer 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` namesthe 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 theDropdowns 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@@@ -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"}nnew file mode 100644@@@ -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.@@@ -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@@@ -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",@@@ -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",nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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 };++});
@@ -1,5 +1,19 @@
@@ -1,5 +1,19 @@
# Changelog
# 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
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.
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).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -19,7 +19,7 @@ were reconciled first.
@@ -19,7 +19,7 @@ were reconciled first.
| Kind | Files |
| 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` |
| 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()` |
| 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`) |
| 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
@@ -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.
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`)
### 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
One look for every `<video>` across the network. Mark the element and load both files; without the script the
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.110.0",
+ "version": "4.111.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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 @@
@@ -26,7 +26,8 @@
"browser harness and perf budgets for tests",
"browser harness and perf budgets for tests",
"a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",
"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 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"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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.
@@ -47,6 +47,7 @@ const BROWSER = Object.freeze([
@@ -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
'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile
'tooltip.js',
'tooltip.js',
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
'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-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
'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
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.110.0",
+ "version": "4.111.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.110.0",
+ "version": "4.111.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -90,6 +90,7 @@
@@ -90,6 +90,7 @@
"./theme-sync": "./theme-sync.js",
"./theme-sync": "./theme-sync.js",
"./tooltip": "./tooltip.js",
"./tooltip": "./tooltip.js",
"./player": "./player.js",
"./player": "./player.js",
+ "./vibes-pay": "./vibes-pay.js",
"./trace": "./trace.js",
"./trace": "./trace.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
@@ -118,7 +119,8 @@
@@ -118,7 +119,8 @@
"social/",
"social/",
"templates/",
"templates/",
"CHANGELOG.md",
"CHANGELOG.md",
- "docs/agent-layer.md"
+ "docs/agent-layer.md",
+ "docs/vibes-pay.md"
],
],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -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 };
+});