openvibers/openvibe.shared Commits
4.132.0: your feeds in the menu; the menu's Frame fields reach it again (#320)
Changes
@@@ -1,5 +1,23 @@# Changelog++## 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++ configure which data feed streams are shown"):++ - every card a site publishes (`frame.menu-card@1`, collected by Network into the Frame's `cards`) is a feed you can++ choose;++ - "Choose your feeds (N)" under them lists each with a tick and two arrows: tick to show it, arrows to order it, four++ at most;++ - the choice is kept on this device (`ovs-feeds`). With none chosen, the Network's first two show, as before.++- **Fixed: the menu's Frame fields were dropped.** The navbar's Frame cache kept only `nav`, `groups`, `soon` and++ `footer`, so since 4.128.0 the menu never saw:++ - `cards`: the sites' cards (Live now, Trending, New worlds);++ - `next`: where people go next;++ - `discover_ids`: New to you;++ - `menu.beacon`: the click beacon.++++ It now keeps them, and search.js still checks every entry. The navbar budget is 43 KB brotli (42.8 KB).++## 4.131.0 — 2026-11-07- **Your online status, on every site** (the owner, 11-07: "we could have online status, especially in DMs … Discord@@@ -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.131.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.132.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.131.0",++ "version": "4.132.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)",++ "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)","one menu: the burger opens the OpenVibe menu, this site's pages first and Display at its foot; the old drawer only for sites that ask (4.129.0)",@@@ -886,7 +886,10 @@const f = d.footer || {}, lg = f.legal || {};return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),-- legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null },++ // The menu's fields (dropped here until 4.132.0); search.js checks each entry.++ cards: Array.isArray(d.cards) ? d.cards.slice(0, 12) : [], next: d.next && typeof d.next === 'object' ? d.next : null,++ discover_ids: Array.isArray(d.discover_ids) ? d.discover_ids.slice(0, 40) : [], menu: d.menu && https(d.menu.beacon) ? { beacon: d.menu.beacon } : null };}function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }function refresh() {@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.131.0",++ "version": "4.132.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -233,6 +233,17 @@.ovs-foldrow { padding: 6px 4px 2px; }.ovs-fold { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--border, rgba(255,255,255,.16)); background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; text-align: left; cursor: pointer; }.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }++.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }++.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }++.ovs-feed label { display: flex; align-items: center; gap: 12px; min-height: 44px; min-width: 0; cursor: pointer; }++.ovs-feed input { width: 18px; height: 18px; flex: none; accent-color: var(--accent, #3b82f6); }++.ovs-feed label span { display: grid; min-width: 0; }++.ovs-feed b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovs-feed small { font-size: 12px; color: var(--text-muted, #8b95a7); text-transform: capitalize; }++.ovs-feed button { width: 40px; height: 40px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 16px; cursor: pointer; }++.ovs-feed button:hover:not(:disabled) { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }++.ovs-feed button:disabled { opacity: .3; cursor: default; }++.ovs-feeds-note { margin: 2px 6px 0; font-size: 12px; color: var(--text-muted, #8b95a7); }.ovs-fold[aria-expanded="true"] svg { transform: rotate(90deg); }.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }@@@ -321,9 +332,29 @@if (fold) {e.preventDefault();const key = fold.getAttribute('data-ovs-fold');-- if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen;++ if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen; else if (key === 'feeds') feedsOpen = !feedsOpen;render(''); const back = listEl.querySelector(`[data-ovs-fold="${key}"]`); if (back) back.focus(); return;}++ const tick = e.target.closest && e.target.closest('[data-ovs-feed]');++ const move = e.target.closest && e.target.closest('[data-ovs-feed-move]');++ if (tick || move) {++ const shown = [...listEl.querySelectorAll('[data-ovs-feed]')];++ let on = shown.filter((x) => x.checked).map((x) => x.getAttribute('data-ovs-feed'));++ const cur = feedPref();++ if (cur) on = cur.filter((id) => on.includes(id)).concat(on.filter((id) => !cur.includes(id)));++ let focusSel = tick ? `[data-ovs-feed="${tick.getAttribute('data-ovs-feed')}"]` : null;++ if (move) {++ e.preventDefault();++ const [dir, id] = move.getAttribute('data-ovs-feed-move').split('|');++ const i = on.indexOf(id), j = dir === 'up' ? i - 1 : i + 1;++ if (i >= 0 && j >= 0 && j < on.length) { [on[i], on[j]] = [on[j], on[i]]; }++ focusSel = `[data-ovs-feed-move="${dir}|${id}"]`;++ }++ saveFeeds(on);++ render('');++ const back = focusSel && listEl.querySelector(focusSel); if (back) back.focus();++ return;++ }const a = e.target.closest && e.target.closest('a[data-i]');const it = a && items[Number(a.getAttribute('data-i'))];if (it && !input.value.trim()) beacon(it, a);@@@ -426,12 +457,31 @@function paint(groups, note) {items = [];listEl.textContent = '';-- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display'].includes(g.as)).forEach((g) => {++ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds'].includes(g.as)).forEach((g) => {if (g.as === 'route') {const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);return;}if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }++ if (g.as === 'feeds') {++ const li = document.createElement('li'); li.className = 'ovs-feeds'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'feeds');++ const onCount = g.items.filter((x) => x.on).length;++ g.items.forEach((f) => {++ const row = document.createElement('div'); row.className = 'ovs-feed';++ const lab = document.createElement('label');++ const box = document.createElement('input'); box.type = 'checkbox'; box.checked = f.on; box.setAttribute('data-ovs-feed', f.id);++ if (!f.on && onCount >= FEEDS_MAX) box.disabled = true;++ const t = document.createElement('span'); const b = document.createElement('b'); b.textContent = f.title; const m = document.createElement('small'); m.textContent = f.meta;++ t.append(b, m); lab.append(box, t); row.appendChild(lab);++ [['up', '↑', 'Show it earlier'], ['down', '↓', 'Show it later']].forEach(([dir, glyph, name]) => {++ const mv = document.createElement('button'); mv.type = 'button'; mv.textContent = glyph; mv.setAttribute('aria-label', `${name}: ${f.title}`); mv.setAttribute('data-ovs-feed-move', `${dir}|${f.id}`); mv.disabled = !f.on; row.appendChild(mv);++ });++ li.appendChild(row);++ });++ const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = `Up to ${FEEDS_MAX}, in your order. Kept on this device.`; li.appendChild(note);++ listEl.appendChild(li);++ return;++ }if (g.as === 'display') {const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'display');try { ctx.display(li); } catch { return; }@@@ -635,7 +685,13 @@live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',};-- let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null;++ let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null, feedsOpen = false;++ // Your feeds (11-07, the owner: "the user should be able to configure which data feed streams are shown"): the sites'++ // cards you chose and their order, on this device ({ on: ['media-hub:trending', …] }); none chosen = Network's first two.++ const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;++ const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;++ const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };++ const saveFeeds = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };/** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */function loadPulse() {try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }@@@ -740,12 +796,19 @@// 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.-- const netCards = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 2) : [];++ 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);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).++ if (feeds.length > 1 || chosen) {++ groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${feeds.length})` }, section: 'feeds' });++ if (feedsOpen) groups.push({ as: 'feeds', section: 'feeds', items: feeds.map((c) => ({ id: feedId(c), title: String(c.title).slice(0, 60), meta: String(c.site || '').replace('media-hub', 'Video'), on: netCards.includes(c) })) });++ }// 2. The places this person uses most (else the network's most used): a dock of big icons.const yours = mine && mine.services.some((x) => x.count > 0);const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++// Your feeds in the OpenVibe menu (4.132.0; the owner, 11-07: "integrate OpenVibe.Today and OpenVibe.News … the user++// should be able to configure which data feed streams are shown"). With the Frame's cards (each site's++// /.well-known/openvibe-menu.json): the first two show by default; "Choose your feeds (N)" lists every card with a tick++// and arrows; ticking one shows it, an arrow reorders, at most four, and the choice is kept on this device. A real++// headless Chrome; skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const ROOT = path.join(__dirname, '..');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('menu-feeds-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const card = (site, id, title, n) => ({ id, site, title, kind: 'rows', items: Array.from({ length: n }, (_, i) => ({ title: `${title} ${i + 1}`, url: `https://openvibe.${site === 'media-hub' ? 'video' : site}/x/${i}`, meta: `${i + 1} things` })) });++const FRAME = { nav: [{ id: 'live', name: 'Live', url: 'https://openvibe.live/', icon: 'live', group: 'watch' }], groups: [{ id: 'watch', name: 'Watch' }], soon: [], footer: {},++ cards: [card('media-hub', 'trending', 'Trending', 3), card('games', 'new-worlds', 'New worlds', 2), card('news', 'top', 'Top stories', 4), card('today', 'today', 'Today', 2), card('bad', 'empty', 'Nothing', 0)] };++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Feeds</title><link rel="icon" href="data:,">++<script>++ const realFetch = window.fetch;++ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://openvibe.network') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));++</script>++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script>++</body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }++ res.writeHead(404); return res.end();++});++const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => g.firstChild.textContent)`;++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feeds-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });++ await until(p, `!!localStorage.getItem('ov_frame_v2')`);++ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');++ await wait(150);++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');++ const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);++ assert.strictEqual(fold, 'Choose your feeds (4)', 'every card with entries can be chosen; the empty one is not offered');++++ await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');++ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);++ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, false, false]);++++ // Tick Today: it shows, after the two.++ await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);++ await wait(100);++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']);++ // Move Today up twice: first.++ await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);++ await wait(60);++ await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);++ await wait(100);++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them');++ assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow');++ // Untick Trending; tick Top stories: four at most.++ await p.evaluate(`document.querySelector('[data-ovs-feed="media-hub:trending"]').click(), true`);++ await wait(60);++ await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);++ await wait(100);++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']);++ assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device');++ const box = await p.evaluate(`document.documentElement.scrollWidth`);++ assert.ok(box <= 390, `nothing runs off a phone (${box})`);++++ // Reopened later: the choice holds.++ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);++ await wait(200);++ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));++ await wait(150);++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');++ console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 42.75 * 1024; // 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)++const NAVBAR_BROTLI_BUDGET = 43 * 1024; // 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{
@@ -1,5 +1,23 @@
@@ -1,5 +1,23 @@
# Changelog
# Changelog
+## 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
+ configure which data feed streams are shown"):
+ - every card a site publishes (`frame.menu-card@1`, collected by Network into the Frame's `cards`) is a feed you can
+ choose;
+ - "Choose your feeds (N)" under them lists each with a tick and two arrows: tick to show it, arrows to order it, four
+ at most;
+ - the choice is kept on this device (`ovs-feeds`). With none chosen, the Network's first two show, as before.
+- **Fixed: the menu's Frame fields were dropped.** The navbar's Frame cache kept only `nav`, `groups`, `soon` and
+ `footer`, so since 4.128.0 the menu never saw:
+ - `cards`: the sites' cards (Live now, Trending, New worlds);
+ - `next`: where people go next;
+ - `discover_ids`: New to you;
+ - `menu.beacon`: the click beacon.
+
+ It now keeps them, and search.js still checks every entry. The navbar budget is 43 KB brotli (42.8 KB).
+
## 4.131.0 — 2026-11-07
## 4.131.0 — 2026-11-07
- **Your online status, on every site** (the owner, 11-07: "we could have online status, especially in DMs … Discord
- **Your online status, on every site** (the owner, 11-07: "we could have online status, especially in DMs … Discord
@@ -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.131.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.132.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.131.0",
+ "version": "4.132.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)",
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"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 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)",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.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 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)",
"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)",
"one menu: the burger opens the OpenVibe menu, this site's pages first and Display at its foot; the old drawer only for sites that ask (4.129.0)",
"one menu: the burger opens the OpenVibe menu, this site's pages first and Display at its foot; the old drawer only for sites that ask (4.129.0)",
@@ -886,7 +886,10 @@
@@ -886,7 +886,10 @@
const f = d.footer || {}, lg = f.legal || {};
const f = d.footer || {}, lg = f.legal || {};
return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
- legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null },
+ // The menu's fields (dropped here until 4.132.0); search.js checks each entry.
+ cards: Array.isArray(d.cards) ? d.cards.slice(0, 12) : [], next: d.next && typeof d.next === 'object' ? d.next : null,
+ discover_ids: Array.isArray(d.discover_ids) ? d.discover_ids.slice(0, 40) : [], menu: d.menu && https(d.menu.beacon) ? { beacon: d.menu.beacon } : null };
}
}
function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
function refresh() {
function refresh() {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.131.0",
+ "version": "4.132.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": {
@@ -233,6 +233,17 @@
@@ -233,6 +233,17 @@
.ovs-foldrow { padding: 6px 4px 2px; }
.ovs-foldrow { padding: 6px 4px 2px; }
.ovs-fold { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--border, rgba(255,255,255,.16)); background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; text-align: left; cursor: pointer; }
.ovs-fold { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--border, rgba(255,255,255,.16)); background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; text-align: left; cursor: pointer; }
.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
+.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }
+.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
+.ovs-feed label { display: flex; align-items: center; gap: 12px; min-height: 44px; min-width: 0; cursor: pointer; }
+.ovs-feed input { width: 18px; height: 18px; flex: none; accent-color: var(--accent, #3b82f6); }
+.ovs-feed label span { display: grid; min-width: 0; }
+.ovs-feed b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovs-feed small { font-size: 12px; color: var(--text-muted, #8b95a7); text-transform: capitalize; }
+.ovs-feed button { width: 40px; height: 40px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 16px; cursor: pointer; }
+.ovs-feed button:hover:not(:disabled) { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
+.ovs-feed button:disabled { opacity: .3; cursor: default; }
+.ovs-feeds-note { margin: 2px 6px 0; font-size: 12px; color: var(--text-muted, #8b95a7); }
.ovs-fold[aria-expanded="true"] svg { transform: rotate(90deg); }
.ovs-fold[aria-expanded="true"] svg { transform: rotate(90deg); }
.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
@@ -321,9 +332,29 @@
@@ -321,9 +332,29 @@
if (fold) {
if (fold) {
e.preventDefault();
e.preventDefault();
const key = fold.getAttribute('data-ovs-fold');
const key = fold.getAttribute('data-ovs-fold');
- if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen;
+ if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen; else if (key === 'feeds') feedsOpen = !feedsOpen;
render(''); const back = listEl.querySelector(`[data-ovs-fold="${key}"]`); if (back) back.focus(); return;
render(''); const back = listEl.querySelector(`[data-ovs-fold="${key}"]`); if (back) back.focus(); return;
}
}
+ const tick = e.target.closest && e.target.closest('[data-ovs-feed]');
+ const move = e.target.closest && e.target.closest('[data-ovs-feed-move]');
+ if (tick || move) {
+ const shown = [...listEl.querySelectorAll('[data-ovs-feed]')];
+ let on = shown.filter((x) => x.checked).map((x) => x.getAttribute('data-ovs-feed'));
+ const cur = feedPref();
+ if (cur) on = cur.filter((id) => on.includes(id)).concat(on.filter((id) => !cur.includes(id)));
+ let focusSel = tick ? `[data-ovs-feed="${tick.getAttribute('data-ovs-feed')}"]` : null;
+ if (move) {
+ e.preventDefault();
+ const [dir, id] = move.getAttribute('data-ovs-feed-move').split('|');
+ const i = on.indexOf(id), j = dir === 'up' ? i - 1 : i + 1;
+ if (i >= 0 && j >= 0 && j < on.length) { [on[i], on[j]] = [on[j], on[i]]; }
+ focusSel = `[data-ovs-feed-move="${dir}|${id}"]`;
+ }
+ saveFeeds(on);
+ render('');
+ const back = focusSel && listEl.querySelector(focusSel); if (back) back.focus();
+ return;
+ }
const a = e.target.closest && e.target.closest('a[data-i]');
const a = e.target.closest && e.target.closest('a[data-i]');
const it = a && items[Number(a.getAttribute('data-i'))];
const it = a && items[Number(a.getAttribute('data-i'))];
if (it && !input.value.trim()) beacon(it, a);
if (it && !input.value.trim()) beacon(it, a);
@@ -426,12 +457,31 @@
@@ -426,12 +457,31 @@
function paint(groups, note) {
function paint(groups, note) {
items = [];
items = [];
listEl.textContent = '';
listEl.textContent = '';
- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display'].includes(g.as)).forEach((g) => {
+ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds'].includes(g.as)).forEach((g) => {
if (g.as === 'route') {
if (g.as === 'route') {
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
return;
return;
}
}
if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }
if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }
+ if (g.as === 'feeds') {
+ const li = document.createElement('li'); li.className = 'ovs-feeds'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'feeds');
+ const onCount = g.items.filter((x) => x.on).length;
+ g.items.forEach((f) => {
+ const row = document.createElement('div'); row.className = 'ovs-feed';
+ const lab = document.createElement('label');
+ const box = document.createElement('input'); box.type = 'checkbox'; box.checked = f.on; box.setAttribute('data-ovs-feed', f.id);
+ if (!f.on && onCount >= FEEDS_MAX) box.disabled = true;
+ const t = document.createElement('span'); const b = document.createElement('b'); b.textContent = f.title; const m = document.createElement('small'); m.textContent = f.meta;
+ t.append(b, m); lab.append(box, t); row.appendChild(lab);
+ [['up', '↑', 'Show it earlier'], ['down', '↓', 'Show it later']].forEach(([dir, glyph, name]) => {
+ const mv = document.createElement('button'); mv.type = 'button'; mv.textContent = glyph; mv.setAttribute('aria-label', `${name}: ${f.title}`); mv.setAttribute('data-ovs-feed-move', `${dir}|${f.id}`); mv.disabled = !f.on; row.appendChild(mv);
+ });
+ li.appendChild(row);
+ });
+ const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = `Up to ${FEEDS_MAX}, in your order. Kept on this device.`; li.appendChild(note);
+ listEl.appendChild(li);
+ return;
+ }
if (g.as === 'display') {
if (g.as === 'display') {
const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'display');
const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'display');
try { ctx.display(li); } catch { return; }
try { ctx.display(li); } catch { return; }
@@ -635,7 +685,13 @@
@@ -635,7 +685,13 @@
live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',
live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',
tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',
tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',
};
};
- let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null;
+ let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null, feedsOpen = false;
+ // Your feeds (11-07, the owner: "the user should be able to configure which data feed streams are shown"): the sites'
+ // cards you chose and their order, on this device ({ on: ['media-hub:trending', …] }); none chosen = Network's first two.
+ const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;
+ const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;
+ const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };
+ const saveFeeds = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
/** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */
/** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */
function loadPulse() {
function loadPulse() {
try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
@@ -740,12 +796,19 @@
@@ -740,12 +796,19 @@
// 1b. The network's cards: what sites publish for every menu (Network collects each site's
// 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.
// /.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; at most two, Network's order.
- const netCards = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 2) : [];
+ 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) => {
netCards.forEach((c) => {
const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
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), 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 });
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).
+ if (feeds.length > 1 || chosen) {
+ groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${feeds.length})` }, section: 'feeds' });
+ if (feedsOpen) groups.push({ as: 'feeds', section: 'feeds', items: feeds.map((c) => ({ id: feedId(c), title: String(c.title).slice(0, 60), meta: String(c.site || '').replace('media-hub', 'Video'), on: netCards.includes(c) })) });
+ }
// 2. The places this person uses most (else the network's most used): a dock of big icons.
// 2. The places this person uses most (else the network's most used): a dock of big icons.
const yours = mine && mine.services.some((x) => x.count > 0);
const yours = mine && mine.services.some((x) => x.count > 0);
const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);
const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+// Your feeds in the OpenVibe menu (4.132.0; the owner, 11-07: "integrate OpenVibe.Today and OpenVibe.News … the user
+// should be able to configure which data feed streams are shown"). With the Frame's cards (each site's
+// /.well-known/openvibe-menu.json): the first two show by default; "Choose your feeds (N)" lists every card with a tick
+// and arrows; ticking one shows it, an arrow reorders, at most four, and the choice is kept on this device. A real
+// headless Chrome; skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const ROOT = path.join(__dirname, '..');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('menu-feeds-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const card = (site, id, title, n) => ({ id, site, title, kind: 'rows', items: Array.from({ length: n }, (_, i) => ({ title: `${title} ${i + 1}`, url: `https://openvibe.${site === 'media-hub' ? 'video' : site}/x/${i}`, meta: `${i + 1} things` })) });
+const FRAME = { nav: [{ id: 'live', name: 'Live', url: 'https://openvibe.live/', icon: 'live', group: 'watch' }], groups: [{ id: 'watch', name: 'Watch' }], soon: [], footer: {},
+ cards: [card('media-hub', 'trending', 'Trending', 3), card('games', 'new-worlds', 'New worlds', 2), card('news', 'top', 'Top stories', 4), card('today', 'today', 'Today', 2), card('bad', 'empty', 'Nothing', 0)] };
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Feeds</title><link rel="icon" href="data:,">
+<script>
+ const realFetch = window.fetch;
+ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://openvibe.network') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));
+</script>
+<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
+<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script>
+</body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
+ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
+ res.writeHead(404); return res.end();
+});
+const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => g.firstChild.textContent)`;
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feeds-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
+ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
+ await until(p, `!!localStorage.getItem('ov_frame_v2')`);
+ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');
+ await wait(150);
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');
+ const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);
+ assert.strictEqual(fold, 'Choose your feeds (4)', 'every card with entries can be chosen; the empty one is not offered');
+
+ await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
+ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);
+ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, false, false]);
+
+ // Tick Today: it shows, after the two.
+ await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
+ await wait(100);
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Today']);
+ // Move Today up twice: first.
+ await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
+ await wait(60);
+ await p.evaluate(`document.querySelector('[data-ovs-feed-move="up|today:today"]').click(), true`);
+ await wait(100);
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'Trending', 'New worlds'], 'the arrows order them');
+ assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-ovs-feed-move')`), 'up|today:today', 'focus stays on the arrow');
+ // Untick Trending; tick Top stories: four at most.
+ await p.evaluate(`document.querySelector('[data-ovs-feed="media-hub:trending"]').click(), true`);
+ await wait(60);
+ await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
+ await wait(100);
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories']);
+ assert.deepStrictEqual(await p.evaluate(`JSON.parse(localStorage.getItem('ovs-feeds'))`), { on: ['today:today', 'games:new-worlds', 'news:top'] }, 'kept on this device');
+ const box = await p.evaluate(`document.documentElement.scrollWidth`);
+ assert.ok(box <= 390, `nothing runs off a phone (${box})`);
+
+ // Reopened later: the choice holds.
+ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);
+ await wait(200);
+ await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
+ await wait(150);
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
+ console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 42.75 * 1024; // 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
+const NAVBAR_BROTLI_BUDGET = 43 * 1024; // 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{