openvibers/openvibe.shared Commits

4.172.0: notices read as what happened, each service with its own face (#390)

OP OpenVibers committed 4 days ago · ea56b06c13 · 10 files, +163 −41

* 4.172.0: notices read as what happened, each service with its own face

* The account menu asks only the Network for Vibes; the bell's new content hash

The direct billing.openvibe.network fallback could only fail (no site's CSP allows it) and the
billingBase option goes with it. SIBLING_REV carries notification-ui.js's new hash.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b5c3066..ee2502c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 6fab8e7..2c79f3f 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 082a595..ca18a5e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex 620d91b..62bc3e8 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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). */
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 41bd3b4..572fc01 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -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,
};
ddiff --git a/package-lock.json b/package-lock.json
iindex 0748bb7..38007d4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex dff0289..007b8a5 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/navbar-wallet.test.js b/test/navbar-wallet.test.js
iindex 457e353..588f383 100644
---- a/test/navbar-wallet.test.js
++++ b/test/navbar-wallet.test.js
@@@ -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); });
ddiff --git a/test/notification-faces.test.js b/test/notification-faces.test.js
nnew file mode 100644
iindex 0000000..e9ea2ed
---- /dev/null
++++ b/test/notification-faces.test.js
@@@ -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');
ddiff --git a/test/notification-live.test.js b/test/notification-live.test.js
iindex 212e594..70e91da 100644
---- a/test/notification-live.test.js
++++ b/test/notification-live.test.js
@@@ -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); });