openvibers/openvibe.shared Commits

4.133.0: a picture on the menu's network cards (#321)

OP OpenVibers committed 6 days ago · aafe26e50c · 6 files, +39 −6

When a site's frame.menu-card@1 entry carries an image (Contracts 0.320.0), the menu draws a 64x36 rounded, lazy thumbnail before the title; its https src is checked again here. Cards without pictures render as before.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 356025c..e9472e4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,9 @@
# Changelog
++## 4.133.0 — 2026-11-07
++
++- The menu's network cards draw an entry's picture when the site sent one (`frame.menu-card@1`'s optional item `image`, openvibe-contracts 0.320.0): a 64×36 rounded thumbnail, `object-fit: cover`, `loading="lazy"`, `decoding="async"`, empty alt, before the title and after the site's icon. The `src` is checked again in the menu (https only); a card or entry without a picture renders exactly as before.
++
## 4.132.0 — 2026-11-07
- **Your feeds in the menu** (the owner, 11-07: "integrate OpenVibe.Today and OpenVibe.News … the user should be able to
ddiff --git a/README.md b/README.md
iindex 7dd452e..e53492b 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.132.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.133.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 2323941..f8c74be 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.132.0",
++ "version": "4.133.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)",
@@@ -21,6 +21,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"the shared video player (player.js, player.css): one look for every <video>",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
++ "a picture on the menu's network cards: each entry's 64×36 thumbnail before its title when the site sent one (frame.menu-card@1 images, Contracts 0.320.0), https only and lazy (4.133.0)",
"your feeds in the menu: choose which sites' cards show (Today, News, Trending, New worlds…) and their order; and the navbar keeps the Frame's cards, next and beacon, which it used to drop (4.132.0)",
"your online status on every site: a dot on your picture, a heartbeat once a minute across tabs, and a picker (Online, Do not disturb, Invisible, a status line) in the account menu (4.131.0)",
"your things on every site: Messages (opens your DMs in place, in the Dock) with Chat's unread count, and the OpenCoins and Vibes balances, on unless a site says no (4.130.0)",
ddiff --git a/package.json b/package.json
iindex bc12763..7f1bffd 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.132.0",
++ "version": "4.133.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex b176f27..48ba6bc 100644
---- a/search.js
++++ b/search.js
@@@ -197,6 +197,7 @@
.ovs-ic { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, #8ab4ff); font-size: 12px; font-weight: 800; }
.ovs-ic > svg { width: 16px; height: 16px; }
.ovs-ic.ovs-site { background: none; }
++.ovs-thumb { flex: none; width: 64px; height: 36px; border-radius: 9px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); }
.ovs .ov-icon:empty { border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
.ovs-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
.ovs-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; }
@@@ -526,7 +527,14 @@
if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; t.appendChild(tg); }
if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }
-- a.append(icon(it), main);
++ a.append(icon(it));
++ if (it.image) {
++ const im = document.createElement('img');
++ im.className = 'ovs-thumb'; im.src = it.image; im.alt = '';
++ im.width = 64; im.height = 36; im.loading = 'lazy'; im.decoding = 'async';
++ a.appendChild(im);
++ }
++ a.appendChild(main);
const q = input.value.trim();
if (q && view !== 'results' && (it.kind === 'results' || (items.length === 1 && exact(it, q)))) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }
li.appendChild(a); listEl.appendChild(li);
@@@ -795,13 +803,16 @@
}
// 1b. The network's cards: what sites publish for every menu (Network collects each site's
// /.well-known/openvibe-menu.json into the Frame's `cards`): Live's channels, Games' new worlds, Video's trending.
-- // Text and links only (never another site's code), checked here again; at most two, Network's order.
++ // Text and links only (never another site's code), checked here again, with an entry's https picture when it
++ // sent one; at most two, Network's order.
const feeds = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
const chosen = feedPref();
const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds.slice(0, 2);
netCards.forEach((c) => {
const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
-- const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url), site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
++ const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),
++ image: x.image && /^https:\/\//.test(String(x.image.src || '')) ? String(x.image.src) : null,
++ site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
if (rows.length) groups.push({ title: String(c.title).slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });
});
// 1c. Choose your feeds: every card a site offers, a tick to show it, arrows to order it (four at most).
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex 86b1edf..b10e108 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -155,6 +155,23 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu');
assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');
++ // ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──
++ await p.evaluate(`OpenVibeSearch.open({ service: 'chat', scope: { label: 'Chat', page: '/search' }, links: [], frame: { cards: [
++ { id: 'trending', site: 'media-hub', title: 'Trending', kind: 'rows', items: [
++ { title: 'A walk', meta: '9 views this week', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/1.jpg?w=640', w: 640, h: 360 } },
++ { title: 'No picture', url: 'https://openvibe.video/v/2', image: { src: 'javascript:alert(1)' } },
++ ], more: { label: 'Watch more', url: 'https://openvibe.video/' } },
++ ] } }); 1`);
++ await wait(300);
++ v = await p.evaluate(`(() => { const imgs = [...document.querySelectorAll('.ovs-thumb')]; const row = imgs[0] && imgs[0].closest('.ovs-item');
++ return { count: imgs.length, attrs: imgs[0] && ['src', 'width', 'height', 'loading', 'decoding', 'alt'].map((k) => imgs[0].getAttribute(k)),
++ href: row && row.querySelector('a').getAttribute('href'), title: row && row.querySelector('.ovs-title').textContent,
++ beforeTitle: !!(imgs[0] && imgs[0].nextElementSibling && imgs[0].nextElementSibling.classList.contains('ovs-main')) }; })()`);
++ assert.strictEqual(v.count, 1, 'only the https picture is drawn');
++ assert.deepStrictEqual(v.attrs, ['https://openvibe.media/t/1.jpg?w=640', '64', '36', 'lazy', 'async', ''], 'a 64x36 lazy thumbnail with empty alt');
++ assert.deepStrictEqual([v.href, v.title, v.beforeTitle], ['https://openvibe.video/v/1', 'A walk', true], 'the same row, picture before its title');
++ await p.evaluate('OpenVibeSearch.close(); 1');
++
// ── Loading ──
v = await p.evaluate(`new Promise((done) => {
const l = OpenVibeLoading.mount(document.getElementById('player'), { label: 'Connecting', sub: 'Getting your viewer ready', steps: 4,