openvibers/openvibe.shared Commits
Dock: a known OpenVibe link in a message shows its card (4.147.0) (#337)
Changes
@@@ -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@@@ -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 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.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)",nnew file mode 100644@@@ -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);@@@ -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;}@@@ -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@@@ -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">.@@@ -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",@@@ -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(', ')} };`;@@@ -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');@@@ -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 ──────────────────────────────────────────────{
@@ -1,5 +1,15 @@
@@ -1,5 +1,15 @@
# Changelog
# 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
## 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
- **On a phone, a home's words and its main action come first.** `showcase.hero` with an orbit put the picture above
@@ -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.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
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.146.0",
+ "version": "4.147.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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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);
@@ -100,7 +100,7 @@
@@ -100,7 +100,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -592,6 +592,8 @@
@@ -592,6 +592,8 @@
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
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 || ''));
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 || '')));
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();
li.title = new Date(m.created_at).toLocaleString();
return li;
return li;
}
}
@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
@@ -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-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-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-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
'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-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
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
@@ -513,7 +513,7 @@
@@ -513,7 +513,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // 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': '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
// 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">.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -51,6 +51,7 @@
@@ -51,6 +51,7 @@
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
"./dock-share": "./dock-share.js",
+ "./dock-card": "./dock-card.js",
"./ov-canvas": "./ov-canvas.js",
"./ov-canvas": "./ov-canvas.js",
"./dock-agent": "./dock-agent.js",
"./dock-agent": "./dock-agent.js",
"./dock-timeline": "./dock-timeline.js",
"./dock-timeline": "./dock-timeline.js",
@@ -15,7 +15,7 @@ const ROOT = path.join(__dirname, '..');
@@ -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',
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'];
'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.
// 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'] },
'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'] } };
'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(', ')} };`;
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
@@ -124,7 +124,11 @@ const server = http.createServer((req, res) => {
@@ -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 && req.method === 'POST') { said.push(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').message); return json(201, { message: { id: 900 + said.length } }); }
if (dm) {
if (dm) {
const after = Number(u.searchParams.get('after')) || 0;
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) })));
.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) });
return json(200, { messages: all.filter((m) => m.id > after) });
}
}
@@ -278,6 +282,10 @@ async function drag(p, from, to) {
@@ -278,6 +282,10 @@ async function drag(p, from, to) {
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
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');
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');
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');
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 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');
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
@@ -21,8 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,8 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// 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
// 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.
// 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 ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
{
{