openvibers/openvibe.shared Commits

Dock: a known OpenVibe link in a message shows its card (4.147.0) (#337)

OP OpenVibers committed 5 days ago · 6a8c623265 · 11 files, +102 −9

OpenVibe.Chat resolves a known OpenVibe link when a message is sent and the DM API carries it as card. The Dock
draws it under the message: a small picture when there is one, the title as a link, one line and the one move.
dock-card.js draws it and loads the first time a conversation shows a card, so dock.js grows by about 200 bytes;
its brotli budget moves from 16.25 to 16.40 KB. Every field is data: links only https, pictures only from
openvibe.media, text as text (dock-chrome checks a card that tries a script link, a foreign picture and a script
button).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a1158a2..d1cb41f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,15 @@
# Changelog
++## 4.147.0 — 2026-11-08
++
++- **A link in a Dock message shows its card.** OpenVibe.Chat now resolves a known OpenVibe link when a message is sent
++ (a video on OpenVibe.Video, a party invite, a shared voice or a skill on OpenVibe.Actor) and the DM API carries it on
++ the message as `card`. The Dock draws it under the message: a small picture when there is one, the title as a link,
++ one line, and the one move ("Watch together", "Join the party", "Borrow this agent", "Install"). The new sibling
++ `dock-card.js` draws it and is loaded the first time a conversation shows a card, so dock.js grows by about 200
++ bytes. Every field is data: text set as text, links only https, pictures only from openvibe.media (checked in
++ `test/dock-chrome.test.js` with a card that tries a script link, a picture from elsewhere and a script button).
++
## 4.146.0 — 2026-11-08
- **On a phone, a home's words and its main action come first.** `showcase.hero` with an orbit put the picture above
ddiff --git a/README.md b/README.md
iindex 1dcad02..bef2d22 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.146.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.147.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 32984b3..ffa25b5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.146.0",
++ "version": "4.147.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/dock-card.js b/dock-card.js
nnew file mode 100644
iindex 0000000..b208110
---- /dev/null
++++ b/dock-card.js
@@@ -0,0 +1,71 @@
++/**
++ * openvibe-shared/dock-card.js: a known OpenVibe link's card under a message in the Dock (4.147.0).
++ *
++ * OpenVibe.Chat resolves the card when a message is sent (its server/chat/link-cards.js) and the DM API carries it on
++ * the message as `card`: { kind, url, title, line, note?, image, action: { label, href } | null }, for a video on
++ * OpenVibe.Video ("Watch together"), a party invite ("Join the party"), a shared voice or a skill on OpenVibe.Actor.
++ * dock.js loads this file the first time an open conversation shows a message with a card, so the Dock that every
++ * signed-in page carries stays small.
++ *
++ * OpenVibeDockCard(card, h) → the card's node, or null when it is not a card this can draw
++ *
++ * Every field is data: set as text, cut to size, a link only when it is https, a picture only from openvibe.media.
++ * The card is compact (a 72 px picture beside the words) because the Dock is narrow; the message stays the thing said.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockCard) return;
++
++ const CSS = `
++.ovdk-card { display: flex; align-items: center; gap: 10px; box-sizing: border-box; max-width: min(100%, 300px); margin-top: 4px; padding: 8px 10px 8px 8px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-primary, #12141b); text-align: left; }
++.ovdk-card-pic { flex: none; width: 72px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
++.ovdk-card-body { min-width: 0; display: grid; justify-items: start; gap: 2px; }
++.ovdk-card-t { font-size: 13.5px; font-weight: 700; line-height: 1.3; color: var(--text-primary, #ececf1); text-decoration: none; overflow-wrap: anywhere; }
++a.ovdk-card-t:hover { text-decoration: underline; }
++.ovdk-card-l { font-size: 12.5px; line-height: 1.35; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
++.ovdk-card-go { display: inline-flex; align-items: center; min-height: 32px; margin-top: 4px; padding: 0 12px; border-radius: 999px; font-size: 13px; font-weight: 700; text-decoration: none; color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
++.ovdk-card-go:hover { filter: brightness(1.08); }
++@media (pointer: coarse) { .ovdk-card-go { min-height: 44px; padding: 0 16px; } }
++`;
++
++ const https = (u) => (typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null);
++ const cut = (v, n) => Array.from(String(v)).slice(0, n).join('');
++
++ function styles() {
++ if (document.getElementById('ovdk-card-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'ovdk-card-styles';
++ st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ function card(c, h) {
++ if (!c || typeof c !== 'object' || typeof c.title !== 'string' || !c.title || typeof h !== 'function') return null;
++ styles();
++ const url = https(c.url);
++ const box = h('div', 'ovdk-card');
++ if (c.kind) box.dataset.kind = cut(c.kind, 20);
++ const pic = typeof c.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(c.image) ? c.image : null;
++ if (pic) {
++ const img = h('img', 'ovdk-card-pic');
++ img.src = pic; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
++ box.append(img);
++ }
++ const body = h('div', 'ovdk-card-body');
++ const title = h(url ? 'a' : 'b', 'ovdk-card-t', cut(c.title, 120));
++ if (url) { title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow'; }
++ body.append(title);
++ if (typeof c.line === 'string' && c.line) body.append(h('span', 'ovdk-card-l', cut(c.line, 200)));
++ if (typeof c.note === 'string' && c.note) body.append(h('span', 'ovdk-card-l', cut(c.note, 160)));
++ const go = c.action && typeof c.action === 'object' && https(c.action.href) && typeof c.action.label === 'string' && c.action.label ? c.action : null;
++ if (go) {
++ const a = h('a', 'ovdk-card-go', cut(go.label, 40));
++ a.href = https(go.href); a.target = '_blank'; a.rel = 'noopener nofollow';
++ body.append(a);
++ }
++ box.append(body);
++ return box;
++ }
++
++ root.OpenVibeDockCard = card;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/dock.js b/dock.js
iindex f7a6070..add539f 100644
---- a/dock.js
++++ b/dock.js
@@@ -100,7 +100,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef' };
++ const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '49938c0551cc', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
@@@ -592,6 +592,8 @@
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));
li.append(h('span', 'ovdk-text', String(m.message || '')));
++ // A known OpenVibe link's card (Chat's link cards): dock-card.js draws it, loaded the first time one shows.
++ if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { const c = ok && root.OpenVibeDockCard(m.card, h); if (c) li.append(c); });
li.title = new Date(m.created_at).toLocaleString();
return li;
}
ddiff --git a/files.js b/files.js
iindex f9a2701..2b7a433 100644
---- a/files.js
++++ b/files.js
@@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
++ 'dock-card.js', // a known OpenVibe link's card under a Dock message (4.147.0); loaded the first time one shows
'ov-canvas.js', // an agent's live panel (actor.canvas@1, 4.145.0); a page that shows canvases loads it
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
ddiff --git a/navbar.js b/navbar.js
iindex 4ade83b..9196b82 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -513,7 +513,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // 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': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'fc4a350badec', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'b563663a49d6', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
// 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">.
ddiff --git a/package.json b/package.json
iindex d826466..a74d591 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.146.0",
++ "version": "4.147.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -51,6 +51,7 @@
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
++ "./dock-card": "./dock-card.js",
"./ov-canvas": "./ov-canvas.js",
"./dock-agent": "./dock-agent.js",
"./dock-timeline": "./dock-timeline.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex c9cd921..7de2355 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
--const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js'] },
++const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 7c90aa7..6355d3e 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -124,7 +124,11 @@ const server = http.createServer((req, res) => {
if (dm && req.method === 'POST') { said.push(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').message); return json(201, { message: { id: 900 + said.length } }); }
if (dm) {
const after = Number(u.searchParams.get('after')) || 0;
-- const all = [{ id: 101, username: 'kai', display_name: 'Kai Moreno', message: 'see the shots?', created_at: iso(240e3) }, { id: 102, username: 'ada', display_name: 'Ada', message: '<b>on it</b>', created_at: iso(120e3) }]
++ // A known OpenVibe link's card, as Chat's link cards put it on a message (4.147.0, dock-card.js); and one that
++ // tries a script link, a picture from elsewhere and a script button, which draws as plain text only.
++ const card = { kind: 'video', url: 'https://openvibe.video/v/42', title: 'The studio tour', line: '12:04 · Kai Moreno', image: 'https://openvibe.media/t/abc?w=320', action: { label: 'Watch together', href: 'https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42' } };
++ const bad = { kind: 'video', url: 'javascript:alert(1)', title: '<img src=x>', line: 'x', image: 'https://evil.example/p.png', action: { label: 'Go', href: 'javascript:alert(2)' } };
++ const all = [{ id: 101, username: 'kai', display_name: 'Kai Moreno', message: 'see the shots?', card, created_at: iso(240e3) }, { id: 102, username: 'ada', display_name: 'Ada', message: '<b>on it</b>', card: bad, created_at: iso(120e3) }]
.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));
return json(200, { messages: all.filter((m) => m.id > after) });
}
@@@ -278,6 +282,10 @@ async function drag(p, from, to) {
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
const thread = await until(p, "(() => { const m = [...document.querySelectorAll('.ovdk .ovdk-msg')]; return m.length === 2 ? m.map((x) => (x.classList.contains('is-mine') ? 'me: ' : 'them: ') + x.querySelector('.ovdk-text').textContent) : null; })()", 'the thread');
assert.deepStrictEqual(thread, ['them: see the shots?', 'me: <b>on it</b>'], 'theirs and mine, and what people wrote is text, never markup');
++ const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');
++ assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');
++ assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');
++ assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet');
await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 9737d5d..07d680b 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -21,8 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
--const DOCK_BROTLI_BUDGET = 16640; // 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js'];
++const DOCK_BROTLI_BUDGET = 16794; // 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
{