openvibers/openvibe.shared Commits
4.172.0: notices read as what happened, each service with its own face (#390)
Changes
@@@ -1,5 +1,19 @@# Changelog++## 4.172.0 — 2026-11-09++++- **Notices read as what happened** (`notification-ui.js`, the bell, its toasts and the Dock's Activity tab): an++ agent's notice shows its task, not the words it was given ("Done: <prompt>" is the prompt; "approve: <step>++ (<task>)" is the step with an "Approval" label), and rich_content's `task_title` and `step` win when Actor sends++ them; two finished tasks are two rows. A message holding a value's JSON (a watch on a feed sends the entry, cut at++ 300 characters) reads as its title and summary, and JSON with neither is left out instead of shown as code.++- **Faces per service**: Actor's notices wear an agent, Watch's an eye, Connect's and Link's a computer, Billing's a++ coin and the welcome a spark, instead of every service sharing the grid; their rows say Agents, Watch, Connect,++ Link, Community, Wallet, Blog, Design, AI or Events. The bell script's budget is 25.5 KB brotli (25.24 measured).++- **The account menu asks only the Network for Vibes** (`navbar.js`): the direct `billing.openvibe.network/api/me/vibes`++ fallback (for a Network older than 10-28) and the `billingBase` option are gone. No site's CSP allowed that host, so++ it could only fail. A Network answer without Vibes shows no Vibes chip.++## 4.171.0 — 2026-11-09- **The page head's icon and actions sit on the title's first line** (`kit.pageHead`): a title that wraps to three@@@ -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.171.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.172.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.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.171.0",++ "version": "4.172.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)",@@@ -4,7 +4,7 @@'use strict';let _config = {-- service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', billingBase: null, frameBase: null,++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', frameBase: null,onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,brand: null, brandName: null, brandIcon: null, compact: 'auto',links: null, menu: null, recent: true,@@@ -572,7 +572,7 @@const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'fddb428eb75a', 'nav-slots.js': 'a5af310d62e0' };++ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'fddb428eb75a', 'nav-slots.js': 'a5af310d62e0' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -1293,10 +1293,8 @@const bal = Number(both.balance ?? (both.wallet && both.wallet.balance));if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()} <small>OpenCoins</small></a>`);}-- if (both && 'vibes' in both) { if (both.vibes != null) showVibes(Number(both.vibes)); return; }-- // An older Network: Billing itself (409 until it is the ledger: no chip then).-- await fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })-- .then(async (r) => { if (r.ok) showVibes(Number((await r.json()).credit)); }).catch(() => {});++ // Vibes only from the Network: no site's CSP allows billing.openvibe.network (no direct fallback since 4.172.0).++ if (both && both.vibes != null) showVibes(Number(both.vibes));}/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */@@@ -49,7 +49,10 @@stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',};-- const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat' };++ const SERVICE_LABELS = {++ live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat',++ actor: 'Agents', watch: 'Watch', connect: 'Connect', link: 'Link', community: 'Community', billing: 'Wallet', blog: 'Blog', design: 'Design', ai: 'AI', events: 'Events',++ };// Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.const GLYPH = {@@@ -70,7 +73,12 @@bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',++ agent: '<rect x="5" y="8.2" width="14" height="10.8" rx="3.6"/><path d="M12 8.2V5.4M9.4 13.4h.01M14.6 13.4h.01M3 12.6v2.6M21 12.6v2.6"/><circle cx="12" cy="4.4" r="1"/>',++ eye: '<path d="M2.8 12s3.4-6.2 9.2-6.2 9.2 6.2 9.2 6.2-3.4 6.2-9.2 6.2S2.8 12 2.8 12z"/><circle cx="12" cy="12" r="2.8"/>',++ screen: '<rect x="3.5" y="4.5" width="17" height="11.5" rx="2.2"/><path d="M9 20h6M12 16v4"/>',};++ const SERVICE_GLYPH = { actor: 'agent', watch: 'eye', connect: 'screen', link: 'screen', billing: 'coin' };++ const TYPE_GLYPH = { WELCOME: 'spark', AGENT_TASK_DONE: 'agent', AGENT_TASK_FAILED: 'agent', AGENT_NEEDS_YOU: 'agent' };const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };const TONE = {live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',@@@ -408,17 +416,54 @@label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',};}++ // JSON in a message (a watch sends a feed entry, cut short): its title and summary, never code.++ function readableMessage(raw) {++ const s = String(raw || '').trim();++ if (!/^[{[]\s*"/.test(s)) return s;++ const field = (name) => {++ const m = new RegExp(`"${name}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(s);++ if (!m) return '';++ try { return JSON.parse(`"${m[1].replace(/\\$/, '')}"`); } catch { return m[1]; }++ };++ const title = field('title') || field('name');++ const summary = field('summary') || field('description');++ return [title, summary].filter(Boolean).join(' — ').replace(/\s+/g, ' ').trim();++ }++ const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);++ // What kind of step it is, as a label (a noun, so it never reads as a button).++ const ASK_CHIP = { approve: 'Approval', confirm: 'Approval', answer: 'Question', choose: 'Choice', pick: 'Choice', review: 'Review', 'sign in': 'Sign-in', 'log in': 'Sign-in' };++ // An agent's notice: the task, not its wording ("Done: x" → x; "approve: step (task)" → step + chip).++ function agentText(n, rich) {++ const t = String(n.type || '');++ let msg = String(n.message || '').trim();++ const chips = [];++ if (t === 'AGENT_TASK_DONE') msg = rich.task_title || msg.replace(/^Done:\s*/i, '');++ else if (t === 'AGENT_TASK_FAILED') msg = rich.task_title ? `${rich.task_title}${rich.detail ? ` — ${rich.detail}` : ''}` : msg.replace(/^Not finished:\s*/i, '');++ else if (t === 'AGENT_NEEDS_YOU') {++ if (rich.step) msg = String(rich.step);++ else if (/\)$/.test(msg) && msg.lastIndexOf(' (') > 0) msg = msg.slice(0, msg.lastIndexOf(' ('));++ const m = /^(approve|answer|confirm|choose|pick|review|sign in|log in)\s*:\s*/i.exec(msg);++ const k = (m ? m[1] : String(rich.kind || '')).toLowerCase();++ if (m) msg = msg.slice(m[0].length);++ if (ASK_CHIP[k]) chips.push(ASK_CHIP[k]);++ }++ return { message: cap(msg.trim()), chips };++ }function present(n) {const rich = richOf(n);if (isOpsAlert(n, rich)) return presentOps(n, rich);const cat = n.category || 'system';const t = String(n.type || '');-- const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key' : (CATEGORY_GLYPH[cat] || 'bell');-- const tone = n.priority === 'critical' ? 'danger' : (CATEGORY_TONE[cat] || 'accent');-- const chips = Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string').slice(0, 4) : [];++ const agent = /^AGENT_/.test(t);++ const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key'++ : (TYPE_GLYPH[t] || SERVICE_GLYPH[n.service] || CATEGORY_GLYPH[cat] || 'bell');++ const tone = n.priority === 'critical' ? 'danger'++ : t === 'AGENT_TASK_DONE' ? 'ok' : t === 'AGENT_TASK_FAILED' ? 'warn' : (CATEGORY_TONE[cat] || 'accent');++ const said = agent ? agentText(n, rich) : { message: readableMessage(n.message), chips: [] };++ const chips = said.chips.concat(Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string') : []).slice(0, 4);return {-- kind: 'plain', key: [t, n.title || '', n.service || '', cat].join('\u0001'), tone, glyph,-- title: String(n.title || ''), message: String(n.message || ''), chips, severity: null, command: null,++ kind: 'plain', key: [t, n.title || '', n.service || '', cat, rich.task_id || ''].join('\u0001'), tone, glyph,++ title: String(n.title || ''), message: said.message, chips, severity: null, command: null,label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',url: urlOf(n), actionLabel: null,};@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.171.0",++ "version": "4.172.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.171.0",++ "version": "4.172.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.171.0",++ "version": "4.172.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -1,7 +1,7 @@'use strict';--// The account menu's balances: OpenCoins from Network and Vibes from OpenVibe.Billing (the owner: "make vibes cross site--// too like opencoins"), one request each when the menu first opens. Billing answers 409 until it is the money ledger:--// then there is no Vibes chip at all, never a shadow number.++// The account menu's balances: OpenCoins and Vibes from the Network in one request (it asks Billing; the owner: "make++// vibes cross site too like opencoins"), once when the menu first opens. A Network that gives no Vibes means no Vibes++// chip, never a shadow number, and Billing is never asked directly (no site's CSP allows it; the fallback went in 4.172.0).const assert = require('assert');const path = require('path');@@@ -25,13 +25,12 @@ function dropdown() {return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };};++ answer.coins = { status: 200, body: { balance: 1234, vibes: 5600 } };configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });let d = dropdown();await loadWallet(d);-- // The Network first, for both (4.25.0); an older one answers the coins only, so Billing itself after it.-- assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes', 'https://billing.openvibe.network/api/me/vibes']);-- assert.strictEqual(calls[1].init.headers.Authorization, 'Bearer tok');-- assert.strictEqual(calls[1].init.credentials, 'omit', 'no cookies to Billing');++ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);++ assert.strictEqual(calls[0].init.headers.Authorization, 'Bearer tok');assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);@@@ -40,15 +39,19 @@ function dropdown() {assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);await loadWallet(d);-- assert.strictEqual(calls.length, 2, 'once per page');++ assert.strictEqual(calls.length, 1, 'once per page');-- // Before the cutover Billing says 409: OpenCoins show, Vibes do not.-- answer.vibes = { status: 409, body: { code: 'billing.not_authoritative' } };-- configure({ token: 'tok' });-- d = dropdown();-- await loadWallet(d);-- assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);-- assert.strictEqual(d.els['#openvibe-vibes'].hidden, true, 'no shadow balance');++ // No Vibes from the Network (null, or an answer without them): OpenCoins show, Vibes do not, Billing is not asked.++ for (const body of [{ balance: 1234, vibes: null }, { balance: 1234 }]) {++ answer.coins = { status: 200, body };++ configure({ token: 'tok' });++ calls.length = 0;++ d = dropdown();++ await loadWallet(d);++ assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);++ assert.strictEqual(d.els['#openvibe-vibes'].hidden, true, 'no shadow balance');++ assert.ok(!calls.some((c) => /billing\./.test(c.url)), 'Billing is never asked directly');++ }// A site with its own header chips (Live), a bar that shows the balances already, and a signed-out page ask nothing.calls.length = 0;@@@ -63,7 +66,7 @@ function dropdown() {await loadWallet(dropdown());assert.strictEqual(calls.length, 0);-- // A Network that answers both: one request, Billing never asked.++ // A Network that answers both: one request.configure({ token: 'tok', menu: null, wallet: false, chips: [] });answer.coins = { status: 200, body: { balance: 1234, vibes: 77 } };calls.length = 0;@@@ -71,12 +74,5 @@ function dropdown() {await loadWallet(d);assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);assert.match(d.els['#openvibe-vibes'].innerHTML, /77 <small>Vibes<\/small>/);-- answer.coins = { status: 200, body: { balance: 1234 } };---- // billingBase points elsewhere (a test cell).-- configure({ token: 'tok', billingBase: 'https://billing.example.test' });-- calls.length = 0;-- await loadWallet(dropdown());-- assert.ok(calls.some((c) => c.url === 'https://billing.example.test/api/me/vibes'));console.log('navbar-wallet: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// How a service's notice reads in the bell and the Dock's Activity tab (notification-ui present()):++// - an agent's notices (Actor) show the task, not the words it was given: "Done: <prompt>" is the prompt, a step's++// "approve: … (<task>)" is the step with an "Approval" chip; rich_content's task_title and step win when given;++// two finished tasks are two rows (their task ids keep them apart);++// - a value's JSON in a message (a watch on a feed sends the entry, cut short at 300 characters) reads as its++// title and summary, and JSON with neither is left out, never shown as code;++// - Actor, Watch and Connect each have a face of their own (not the Services grid) and a label.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++const vm = require('node:vm');++const { parseHTML } = require('linkedom');++++// The script in a page (it wires the document as it loads), never polling: no token, same origin.++const { window: w, document } = parseHTML('<!doctype html><html><head></head><body></body></html>');++const ctx = { document, navigator: { userAgent: 'test' }, location: new URL('https://openvibe.network/'), console,++ setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {},++ addEventListener: () => {}, removeEventListener: () => {}, fetch: async () => ({ ok: false, status: 404, json: async () => ({}) }),++ HTMLElement: w.HTMLElement, Event: w.Event, CustomEvent: w.CustomEvent };++ctx.window = ctx; ctx.globalThis = ctx;++vm.createContext(ctx);++vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'notification-ui.js'), 'utf8'), ctx, { filename: 'notification-ui.js' });++const N = ctx.OpenVibeNotifications;++++const done = N.present({ type: 'AGENT_TASK_DONE', service: 'actor', category: 'service', title: 'Your task is done',++ message: 'Done: Research this and write a short report for a team, with the links of your sources: moss',++ rich_content: JSON.stringify({ task_id: 'tsk_1', state: 'succeeded' }) });++assert.equal(done.message, 'Research this and write a short report for a team, with the links of your sources: moss');++assert.equal(done.glyph, 'agent');++assert.equal(done.tone, 'ok');++assert.equal(done.label, 'Agents');++const done2 = N.present({ type: 'AGENT_TASK_DONE', service: 'actor', category: 'service', title: 'Your task is done', message: 'Done: x', rich_content: { task_id: 'tsk_2' } });++assert.notEqual(done.key, done2.key, 'two tasks never fold into one row');++assert.equal(N.present({ type: 'AGENT_TASK_DONE', service: 'actor', message: 'Done: old words', rich_content: { task_id: 'tsk_3', task_title: 'Plan the trip' } }).message, 'Plan the trip');++++const failed = N.present({ type: 'AGENT_TASK_FAILED', service: 'actor', category: 'service', message: 'Not finished: Book it — the site refused' });++assert.equal(failed.message, 'Book it — the site refused');++assert.equal(failed.tone, 'warn');++++const needs = N.present({ type: 'AGENT_NEEDS_YOU', service: 'actor', category: 'service', title: 'Your agent needs you',++ message: 'approve: Run "rm -rf /tmp/actor-gate-proof" on your computer (Call the computer_run tool right away)' });++assert.equal(needs.message, 'Run "rm -rf /tmp/actor-gate-proof" on your computer');++assert.deepEqual([...needs.chips], ['Approval']);++assert.equal(needs.glyph, 'agent');++assert.equal(N.present({ type: 'AGENT_NEEDS_YOU', service: 'actor', message: 'Look at the example page and tell me when you are done.' }).message,++ 'Look at the example page and tell me when you are done.');++const given = N.present({ type: 'AGENT_NEEDS_YOU', service: 'actor', message: 'approve: x (y)', rich_content: { step: 'Sign in to the bank', kind: 'sign in' } });++assert.equal(given.message, 'Sign in to the bank');++assert.deepEqual([...given.chips], ['Sign-in']);++++const cut = '{"identity":"https://openvibe.work/jobs/job_0wzn","kind":"entry","title":"(Senior) Product Engineer at sustaind GmbH","link":"https://openvibe.work/jobs/job_0wzn","summary":"Bielefeld · Remote · Unbefristeter arbeitsvertrag — Mit Sustaind \\"bauen\\" wir…';++const watch = N.present({ type: 'WATCH_TRIGGERED', service: 'watch', category: 'service', title: '“Jobs” fired', message: cut });++assert.equal(watch.message, '(Senior) Product Engineer at sustaind GmbH — Bielefeld · Remote · Unbefristeter arbeitsvertrag — Mit Sustaind "bauen" wir…');++assert.equal(watch.glyph, 'eye');++assert.equal(watch.label, 'Watch');++assert.equal(N.present({ type: 'WATCH_TRIGGERED', service: 'watch', message: '{"price":12,"stock":3}' }).message, '');++assert.equal(N.present({ type: 'WATCH_TRIGGERED', service: 'watch', message: '42 (was 40) · from https://x.example/' }).message, '42 (was 40) · from https://x.example/');++++const connect = N.present({ type: 'GENERIC', service: 'connect', category: 'service', title: 'a is back', message: 'Your computer is connected again.' });++assert.equal(connect.glyph, 'screen');++assert.equal(connect.label, 'Connect');++assert.equal(N.present({ type: 'WELCOME', service: 'network', category: 'service', title: 'Welcome to OpenVibe', message: 'Hi' }).glyph, 'spark');++// Unchanged: a stream notice keeps its category's face; deals keep the tag.++assert.equal(N.present({ type: 'STREAM_LIVE', service: 'live', category: 'stream', title: 'x is live' }).glyph, 'live');++assert.equal(N.present({ type: 'DEAL_FOUND', service: 'deals', category: 'economy', title: 'Deal' }).glyph, 'tag');++++console.log('notification-faces: all checks passed');@@@ -488,7 +488,8 @@ const created = (subject, over = {}) => ({// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or// clear, the phone sheet; quiet hours); measured 22.56 KB.// 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).-- assert.ok(size('notification-ui.js') <= 24.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 24.5 KB`);++ // 24.5 → 25.5 KB on 2026-11-09 (4.172.0): service faces and readable agent and watch notices (25.24 KB measured).++ assert.ok(size('notification-ui.js') <= 25.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.5 KB`);console.log('notification live: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,5 +1,19 @@
@@ -1,5 +1,19 @@
# Changelog
# Changelog
+## 4.172.0 — 2026-11-09
+
+- **Notices read as what happened** (`notification-ui.js`, the bell, its toasts and the Dock's Activity tab): an
+ agent's notice shows its task, not the words it was given ("Done: <prompt>" is the prompt; "approve: <step>
+ (<task>)" is the step with an "Approval" label), and rich_content's `task_title` and `step` win when Actor sends
+ them; two finished tasks are two rows. A message holding a value's JSON (a watch on a feed sends the entry, cut at
+ 300 characters) reads as its title and summary, and JSON with neither is left out instead of shown as code.
+- **Faces per service**: Actor's notices wear an agent, Watch's an eye, Connect's and Link's a computer, Billing's a
+ coin and the welcome a spark, instead of every service sharing the grid; their rows say Agents, Watch, Connect,
+ Link, Community, Wallet, Blog, Design, AI or Events. The bell script's budget is 25.5 KB brotli (25.24 measured).
+- **The account menu asks only the Network for Vibes** (`navbar.js`): the direct `billing.openvibe.network/api/me/vibes`
+ fallback (for a Network older than 10-28) and the `billingBase` option are gone. No site's CSP allowed that host, so
+ it could only fail. A Network answer without Vibes shows no Vibes chip.
+
## 4.171.0 — 2026-11-09
## 4.171.0 — 2026-11-09
- **The page head's icon and actions sit on the title's first line** (`kit.pageHead`): a title that wraps to three
- **The page head's icon and actions sit on the title's first line** (`kit.pageHead`): a title that wraps to three
@@ -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.171.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.172.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.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.171.0",
+ "version": "4.172.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)",
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
'use strict';
'use strict';
let _config = {
let _config = {
- service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', billingBase: null, frameBase: null,
+ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', frameBase: null,
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
links: null, menu: null, recent: true,
@@ -572,7 +572,7 @@
@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'fddb428eb75a', 'nav-slots.js': 'a5af310d62e0' };
+ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'fddb428eb75a', 'nav-slots.js': 'a5af310d62e0' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1293,10 +1293,8 @@
@@ -1293,10 +1293,8 @@
const bal = Number(both.balance ?? (both.wallet && both.wallet.balance));
const bal = Number(both.balance ?? (both.wallet && both.wallet.balance));
if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()} <small>OpenCoins</small></a>`);
if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()} <small>OpenCoins</small></a>`);
}
}
- if (both && 'vibes' in both) { if (both.vibes != null) showVibes(Number(both.vibes)); return; }
- // An older Network: Billing itself (409 until it is the ledger: no chip then).
- await fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })
- .then(async (r) => { if (r.ok) showVibes(Number((await r.json()).credit)); }).catch(() => {});
+ // Vibes only from the Network: no site's CSP allows billing.openvibe.network (no direct fallback since 4.172.0).
+ if (both && both.vibes != null) showVibes(Number(both.vibes));
}
}
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
@@ -49,7 +49,10 @@
@@ -49,7 +49,10 @@
stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',
stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',
achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',
achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',
};
};
- const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat' };
+ const SERVICE_LABELS = {
+ live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat',
+ actor: 'Agents', watch: 'Watch', connect: 'Connect', link: 'Link', community: 'Community', billing: 'Wallet', blog: 'Blog', design: 'Design', ai: 'AI', events: 'Events',
+ };
// Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.
// Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.
const GLYPH = {
const GLYPH = {
@@ -70,7 +73,12 @@
@@ -70,7 +73,12 @@
bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',
bellSolid: '<path d="M5.8 16.2V11a6.2 6.2 0 0 1 12.4 0v5.2l1.7 2.3H4.1ZM9.9 19.6h4.2a2.1 2.1 0 0 1-4.2 0Z"/>',
key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
+ agent: '<rect x="5" y="8.2" width="14" height="10.8" rx="3.6"/><path d="M12 8.2V5.4M9.4 13.4h.01M14.6 13.4h.01M3 12.6v2.6M21 12.6v2.6"/><circle cx="12" cy="4.4" r="1"/>',
+ eye: '<path d="M2.8 12s3.4-6.2 9.2-6.2 9.2 6.2 9.2 6.2-3.4 6.2-9.2 6.2S2.8 12 2.8 12z"/><circle cx="12" cy="12" r="2.8"/>',
+ screen: '<rect x="3.5" y="4.5" width="17" height="11.5" rx="2.2"/><path d="M9 20h6M12 16v4"/>',
};
};
+ const SERVICE_GLYPH = { actor: 'agent', watch: 'eye', connect: 'screen', link: 'screen', billing: 'coin' };
+ const TYPE_GLYPH = { WELCOME: 'spark', AGENT_TASK_DONE: 'agent', AGENT_TASK_FAILED: 'agent', AGENT_NEEDS_YOU: 'agent' };
const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
const TONE = {
const TONE = {
live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',
live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',
@@ -408,17 +416,54 @@
@@ -408,17 +416,54 @@
label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',
label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',
};
};
}
}
+ // JSON in a message (a watch sends a feed entry, cut short): its title and summary, never code.
+ function readableMessage(raw) {
+ const s = String(raw || '').trim();
+ if (!/^[{[]\s*"/.test(s)) return s;
+ const field = (name) => {
+ const m = new RegExp(`"${name}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(s);
+ if (!m) return '';
+ try { return JSON.parse(`"${m[1].replace(/\\$/, '')}"`); } catch { return m[1]; }
+ };
+ const title = field('title') || field('name');
+ const summary = field('summary') || field('description');
+ return [title, summary].filter(Boolean).join(' — ').replace(/\s+/g, ' ').trim();
+ }
+ const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
+ // What kind of step it is, as a label (a noun, so it never reads as a button).
+ const ASK_CHIP = { approve: 'Approval', confirm: 'Approval', answer: 'Question', choose: 'Choice', pick: 'Choice', review: 'Review', 'sign in': 'Sign-in', 'log in': 'Sign-in' };
+ // An agent's notice: the task, not its wording ("Done: x" → x; "approve: step (task)" → step + chip).
+ function agentText(n, rich) {
+ const t = String(n.type || '');
+ let msg = String(n.message || '').trim();
+ const chips = [];
+ if (t === 'AGENT_TASK_DONE') msg = rich.task_title || msg.replace(/^Done:\s*/i, '');
+ else if (t === 'AGENT_TASK_FAILED') msg = rich.task_title ? `${rich.task_title}${rich.detail ? ` — ${rich.detail}` : ''}` : msg.replace(/^Not finished:\s*/i, '');
+ else if (t === 'AGENT_NEEDS_YOU') {
+ if (rich.step) msg = String(rich.step);
+ else if (/\)$/.test(msg) && msg.lastIndexOf(' (') > 0) msg = msg.slice(0, msg.lastIndexOf(' ('));
+ const m = /^(approve|answer|confirm|choose|pick|review|sign in|log in)\s*:\s*/i.exec(msg);
+ const k = (m ? m[1] : String(rich.kind || '')).toLowerCase();
+ if (m) msg = msg.slice(m[0].length);
+ if (ASK_CHIP[k]) chips.push(ASK_CHIP[k]);
+ }
+ return { message: cap(msg.trim()), chips };
+ }
function present(n) {
function present(n) {
const rich = richOf(n);
const rich = richOf(n);
if (isOpsAlert(n, rich)) return presentOps(n, rich);
if (isOpsAlert(n, rich)) return presentOps(n, rich);
const cat = n.category || 'system';
const cat = n.category || 'system';
const t = String(n.type || '');
const t = String(n.type || '');
- const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key' : (CATEGORY_GLYPH[cat] || 'bell');
- const tone = n.priority === 'critical' ? 'danger' : (CATEGORY_TONE[cat] || 'accent');
- const chips = Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string').slice(0, 4) : [];
+ const agent = /^AGENT_/.test(t);
+ const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key'
+ : (TYPE_GLYPH[t] || SERVICE_GLYPH[n.service] || CATEGORY_GLYPH[cat] || 'bell');
+ const tone = n.priority === 'critical' ? 'danger'
+ : t === 'AGENT_TASK_DONE' ? 'ok' : t === 'AGENT_TASK_FAILED' ? 'warn' : (CATEGORY_TONE[cat] || 'accent');
+ const said = agent ? agentText(n, rich) : { message: readableMessage(n.message), chips: [] };
+ const chips = said.chips.concat(Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string') : []).slice(0, 4);
return {
return {
- kind: 'plain', key: [t, n.title || '', n.service || '', cat].join('\u0001'), tone, glyph,
- title: String(n.title || ''), message: String(n.message || ''), chips, severity: null, command: null,
+ kind: 'plain', key: [t, n.title || '', n.service || '', cat, rich.task_id || ''].join('\u0001'), tone, glyph,
+ title: String(n.title || ''), message: said.message, chips, severity: null, command: null,
label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',
label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',
url: urlOf(n), actionLabel: null,
url: urlOf(n), actionLabel: null,
};
};
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.171.0",
+ "version": "4.172.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.171.0",
+ "version": "4.172.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.171.0",
+ "version": "4.172.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": {
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
-// The account menu's balances: OpenCoins from Network and Vibes from OpenVibe.Billing (the owner: "make vibes cross site
-// too like opencoins"), one request each when the menu first opens. Billing answers 409 until it is the money ledger:
-// then there is no Vibes chip at all, never a shadow number.
+// The account menu's balances: OpenCoins and Vibes from the Network in one request (it asks Billing; the owner: "make
+// vibes cross site too like opencoins"), once when the menu first opens. A Network that gives no Vibes means no Vibes
+// chip, never a shadow number, and Billing is never asked directly (no site's CSP allows it; the fallback went in 4.172.0).
const assert = require('assert');
const assert = require('assert');
const path = require('path');
const path = require('path');
@@ -25,13 +25,12 @@ function dropdown() {
@@ -25,13 +25,12 @@ function dropdown() {
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
};
};
+ answer.coins = { status: 200, body: { balance: 1234, vibes: 5600 } };
configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });
configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });
let d = dropdown();
let d = dropdown();
await loadWallet(d);
await loadWallet(d);
- // The Network first, for both (4.25.0); an older one answers the coins only, so Billing itself after it.
- assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes', 'https://billing.openvibe.network/api/me/vibes']);
- assert.strictEqual(calls[1].init.headers.Authorization, 'Bearer tok');
- assert.strictEqual(calls[1].init.credentials, 'omit', 'no cookies to Billing');
+ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);
+ assert.strictEqual(calls[0].init.headers.Authorization, 'Bearer tok');
assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);
assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);
assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);
assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);
@@ -40,15 +39,19 @@ function dropdown() {
@@ -40,15 +39,19 @@ function dropdown() {
assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);
assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);
await loadWallet(d);
await loadWallet(d);
- assert.strictEqual(calls.length, 2, 'once per page');
+ assert.strictEqual(calls.length, 1, 'once per page');
- // Before the cutover Billing says 409: OpenCoins show, Vibes do not.
- answer.vibes = { status: 409, body: { code: 'billing.not_authoritative' } };
- configure({ token: 'tok' });
- d = dropdown();
- await loadWallet(d);
- assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
- assert.strictEqual(d.els['#openvibe-vibes'].hidden, true, 'no shadow balance');
+ // No Vibes from the Network (null, or an answer without them): OpenCoins show, Vibes do not, Billing is not asked.
+ for (const body of [{ balance: 1234, vibes: null }, { balance: 1234 }]) {
+ answer.coins = { status: 200, body };
+ configure({ token: 'tok' });
+ calls.length = 0;
+ d = dropdown();
+ await loadWallet(d);
+ assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
+ assert.strictEqual(d.els['#openvibe-vibes'].hidden, true, 'no shadow balance');
+ assert.ok(!calls.some((c) => /billing\./.test(c.url)), 'Billing is never asked directly');
+ }
// A site with its own header chips (Live), a bar that shows the balances already, and a signed-out page ask nothing.
// A site with its own header chips (Live), a bar that shows the balances already, and a signed-out page ask nothing.
calls.length = 0;
calls.length = 0;
@@ -63,7 +66,7 @@ function dropdown() {
@@ -63,7 +66,7 @@ function dropdown() {
await loadWallet(dropdown());
await loadWallet(dropdown());
assert.strictEqual(calls.length, 0);
assert.strictEqual(calls.length, 0);
- // A Network that answers both: one request, Billing never asked.
+ // A Network that answers both: one request.
configure({ token: 'tok', menu: null, wallet: false, chips: [] });
configure({ token: 'tok', menu: null, wallet: false, chips: [] });
answer.coins = { status: 200, body: { balance: 1234, vibes: 77 } };
answer.coins = { status: 200, body: { balance: 1234, vibes: 77 } };
calls.length = 0;
calls.length = 0;
@@ -71,12 +74,5 @@ function dropdown() {
@@ -71,12 +74,5 @@ function dropdown() {
await loadWallet(d);
await loadWallet(d);
assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);
assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);
assert.match(d.els['#openvibe-vibes'].innerHTML, /77 <small>Vibes<\/small>/);
assert.match(d.els['#openvibe-vibes'].innerHTML, /77 <small>Vibes<\/small>/);
- answer.coins = { status: 200, body: { balance: 1234 } };
-
- // billingBase points elsewhere (a test cell).
- configure({ token: 'tok', billingBase: 'https://billing.example.test' });
- calls.length = 0;
- await loadWallet(dropdown());
- assert.ok(calls.some((c) => c.url === 'https://billing.example.test/api/me/vibes'));
console.log('navbar-wallet: all checks passed');
console.log('navbar-wallet: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// How a service's notice reads in the bell and the Dock's Activity tab (notification-ui present()):
+// - an agent's notices (Actor) show the task, not the words it was given: "Done: <prompt>" is the prompt, a step's
+// "approve: … (<task>)" is the step with an "Approval" chip; rich_content's task_title and step win when given;
+// two finished tasks are two rows (their task ids keep them apart);
+// - a value's JSON in a message (a watch on a feed sends the entry, cut short at 300 characters) reads as its
+// title and summary, and JSON with neither is left out, never shown as code;
+// - Actor, Watch and Connect each have a face of their own (not the Services grid) and a label.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const { parseHTML } = require('linkedom');
+
+// The script in a page (it wires the document as it loads), never polling: no token, same origin.
+const { window: w, document } = parseHTML('<!doctype html><html><head></head><body></body></html>');
+const ctx = { document, navigator: { userAgent: 'test' }, location: new URL('https://openvibe.network/'), console,
+ setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {},
+ addEventListener: () => {}, removeEventListener: () => {}, fetch: async () => ({ ok: false, status: 404, json: async () => ({}) }),
+ HTMLElement: w.HTMLElement, Event: w.Event, CustomEvent: w.CustomEvent };
+ctx.window = ctx; ctx.globalThis = ctx;
+vm.createContext(ctx);
+vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'notification-ui.js'), 'utf8'), ctx, { filename: 'notification-ui.js' });
+const N = ctx.OpenVibeNotifications;
+
+const done = N.present({ type: 'AGENT_TASK_DONE', service: 'actor', category: 'service', title: 'Your task is done',
+ message: 'Done: Research this and write a short report for a team, with the links of your sources: moss',
+ rich_content: JSON.stringify({ task_id: 'tsk_1', state: 'succeeded' }) });
+assert.equal(done.message, 'Research this and write a short report for a team, with the links of your sources: moss');
+assert.equal(done.glyph, 'agent');
+assert.equal(done.tone, 'ok');
+assert.equal(done.label, 'Agents');
+const done2 = N.present({ type: 'AGENT_TASK_DONE', service: 'actor', category: 'service', title: 'Your task is done', message: 'Done: x', rich_content: { task_id: 'tsk_2' } });
+assert.notEqual(done.key, done2.key, 'two tasks never fold into one row');
+assert.equal(N.present({ type: 'AGENT_TASK_DONE', service: 'actor', message: 'Done: old words', rich_content: { task_id: 'tsk_3', task_title: 'Plan the trip' } }).message, 'Plan the trip');
+
+const failed = N.present({ type: 'AGENT_TASK_FAILED', service: 'actor', category: 'service', message: 'Not finished: Book it — the site refused' });
+assert.equal(failed.message, 'Book it — the site refused');
+assert.equal(failed.tone, 'warn');
+
+const needs = N.present({ type: 'AGENT_NEEDS_YOU', service: 'actor', category: 'service', title: 'Your agent needs you',
+ message: 'approve: Run "rm -rf /tmp/actor-gate-proof" on your computer (Call the computer_run tool right away)' });
+assert.equal(needs.message, 'Run "rm -rf /tmp/actor-gate-proof" on your computer');
+assert.deepEqual([...needs.chips], ['Approval']);
+assert.equal(needs.glyph, 'agent');
+assert.equal(N.present({ type: 'AGENT_NEEDS_YOU', service: 'actor', message: 'Look at the example page and tell me when you are done.' }).message,
+ 'Look at the example page and tell me when you are done.');
+const given = N.present({ type: 'AGENT_NEEDS_YOU', service: 'actor', message: 'approve: x (y)', rich_content: { step: 'Sign in to the bank', kind: 'sign in' } });
+assert.equal(given.message, 'Sign in to the bank');
+assert.deepEqual([...given.chips], ['Sign-in']);
+
+const cut = '{"identity":"https://openvibe.work/jobs/job_0wzn","kind":"entry","title":"(Senior) Product Engineer at sustaind GmbH","link":"https://openvibe.work/jobs/job_0wzn","summary":"Bielefeld · Remote · Unbefristeter arbeitsvertrag — Mit Sustaind \\"bauen\\" wir…';
+const watch = N.present({ type: 'WATCH_TRIGGERED', service: 'watch', category: 'service', title: '“Jobs” fired', message: cut });
+assert.equal(watch.message, '(Senior) Product Engineer at sustaind GmbH — Bielefeld · Remote · Unbefristeter arbeitsvertrag — Mit Sustaind "bauen" wir…');
+assert.equal(watch.glyph, 'eye');
+assert.equal(watch.label, 'Watch');
+assert.equal(N.present({ type: 'WATCH_TRIGGERED', service: 'watch', message: '{"price":12,"stock":3}' }).message, '');
+assert.equal(N.present({ type: 'WATCH_TRIGGERED', service: 'watch', message: '42 (was 40) · from https://x.example/' }).message, '42 (was 40) · from https://x.example/');
+
+const connect = N.present({ type: 'GENERIC', service: 'connect', category: 'service', title: 'a is back', message: 'Your computer is connected again.' });
+assert.equal(connect.glyph, 'screen');
+assert.equal(connect.label, 'Connect');
+assert.equal(N.present({ type: 'WELCOME', service: 'network', category: 'service', title: 'Welcome to OpenVibe', message: 'Hi' }).glyph, 'spark');
+// Unchanged: a stream notice keeps its category's face; deals keep the tag.
+assert.equal(N.present({ type: 'STREAM_LIVE', service: 'live', category: 'stream', title: 'x is live' }).glyph, 'live');
+assert.equal(N.present({ type: 'DEAL_FOUND', service: 'deals', category: 'economy', title: 'Deal' }).glyph, 'tag');
+
+console.log('notification-faces: all checks passed');
@@ -488,7 +488,8 @@ const created = (subject, over = {}) => ({
@@ -488,7 +488,8 @@ const created = (subject, over = {}) => ({
// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or
// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or
// clear, the phone sheet; quiet hours); measured 22.56 KB.
// clear, the phone sheet; quiet hours); measured 22.56 KB.
// 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).
// 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).
- assert.ok(size('notification-ui.js') <= 24.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 24.5 KB`);
+ // 24.5 → 25.5 KB on 2026-11-09 (4.172.0): service faces and readable agent and watch notices (25.24 KB measured).
+ assert.ok(size('notification-ui.js') <= 25.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.5 KB`);
console.log('notification live: all checks passed');
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });