openvibers/openvibe.shared Commits

4.132.0: your feeds in the menu; the menu's Frame fields reach it again (#320)

OP OpenVibers committed 6 days ago · 1aee3fe814 · 8 files, +191 −9

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 03ff409..356025c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 0bb1aa3..7dd452e 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.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
([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 948d63f..2323941 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex c6312ea..34217a8 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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() {
ddiff --git a/package.json b/package.json
iindex 0198ddb..bc12763 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex b7b6a8a..b176f27 100644
---- a/search.js
++++ b/search.js
@@@ -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);
ddiff --git a/test/menu-feeds-chrome.test.js b/test/menu-feeds-chrome.test.js
nnew file mode 100644
iindex 0000000..b6eade0
---- /dev/null
++++ b/test/menu-feeds-chrome.test.js
@@@ -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); });
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex fb35799..37679d4 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{