openvibers/openvibe.shared Commits
4.135.0: Ask your agent from any page; every page preconnects to openvibe.network; your feeds follow you (#323)
Changes
@@@ -1,5 +1,24 @@# Changelog++## 4.135.0 — 2026-11-07++++- **Ask your agent, from any page** (plan Z3; the owner, 11-07: "have your agent with you wherever you go"):++ - `kit.askAgent({ text })` draws a chip ("✦ Ask your agent"). Any control with `data-ov-ask="…"` works the same way.++ - A tap opens the person's Vibe in the Dock with the question waiting in its box, this page's context included. It is++ never sent for them: they read it, change it and press Send.++ - Signed out, the navbar sends them to sign in.++ - New calls: `OpenVibeNavbar.askAgent(text)` and `OpenVibeDock.ask({ text })`.++- **Your feeds follow you** (the menu's "Choose your feeds", 4.132.0):++ - signed in, the choice is saved to Network (`PUT /api/v1/me/menu-feeds`, network.menu-feeds@1);++ - it is read back on a new device the first time the menu opens there;++ - a first choice made before signing in is sent up then;++ - this device's copy paints at once and stands whenever Network does not answer, and the route is new, so until++ Network ships it nothing changes.++- **Every page opens its connection to openvibe.network early** (`seo.headTags()` adds++ `<link rel="preconnect" href="https://openvibe.network" crossorigin>`, except on the Network itself;++ `preconnect: false` turns it off). The phone-speed report of 11-07 found 102 of 108 pages asking openvibe.network for++ the bar, sign-in and the menu without one.++## 4.134.0 — 2026-11-07- **Fixed: the Dock's Agent panel could not read the timeline.** The navbar hands the Dock the person's token as a function (it may change when they sign in again); the panel used it as a string, so every call went out as `Bearer function…` and the panel said the agent was not reachable. The token is now resolved when a call is made.@@@ -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.134.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.135.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.134.0",++ "version": "4.135.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)",@@@ -22,6 +22,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)",++ "Ask your agent: kit.askAgent and any [data-ov-ask] control open your Vibe in the Dock with the question waiting, unsent; and every page preconnects to openvibe.network (4.134.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)",@@@ -140,6 +140,8 @@form.append(box, go);panel.append(head, scroll, err, form);const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };++ vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };++ if (vibe.pendingAsk) { vibe.fill(vibe.pendingAsk); vibe.pendingAsk = null; }box.addEventListener('input', fit);box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });@@@ -282,6 +284,8 @@refresh: () => { vibe.panel = panel; return openVibe(); },pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },talk: () => { if (vibe.talk) vibe.talk(); },++ // A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.++ ask: (text) => { if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },};}root.OpenVibeDockVibe = { mount };@@@ -827,6 +827,8 @@}// ── Open and close ───────────────────────────────────────++ // Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.++ function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }function isOpen() { return !!(el && !el.hidden); }function open(opts = {}) {o = { ...o, ...opts };@@@ -877,5 +879,5 @@if (isOpen()) layout();}-- root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };++ root.OpenVibeDock = { mount, open, close, isOpen, update, ask, _state: () => ({ doc, tab, pending }) };})(typeof window !== 'undefined' ? window : globalThis);@@@ -429,3 +429,8 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }++++/* "Ask your agent" (4.134.0): the chip that hands this page's question to your Vibe in the Dock. */++.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }++.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }++.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }@@@ -26,7 +26,7 @@* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })-- * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)++ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,@@@ -171,6 +171,14 @@attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });}++ /**++ * "Ask your agent" (4.135.0, plan Z3): one chip any page can carry. A tap opens the person's Vibe in the Dock with the++ * question waiting in its box, this page's context included, for them to read and send; it never sends for them.++ * Signed out, the navbar sends them to sign in. text: the prefilled question ("About this stream: …").++ */++ function askAgent({ text = '', label = 'Ask your agent', small = true } = {}) {++ return button({ type: 'button', label, icon: 'spark', kind: 'quiet', small, attrs: { 'data-ov-ask': String(text || '').slice(0, 2000), class: null } }).replace('class="ovk-btn', 'class="ovk-ask ovk-btn');++ }/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;@@@ -892,7 +900,7 @@return {skeleton,-- esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, iconButton, action, badge, dot, card, rail, CARD_RATIO,++ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,@@@ -1235,11 +1235,14 @@if (document.getElementById('ov-presence-loader')) return;const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = sibling('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);}-- /**-- * Open the Dock on a tab: 'agent' | 'activity'. opts may name the step to point at (opts.task): the-- * Agent panel marks that entry and brings it into view, so a site can open the Dock at its own task.-- */++ // The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }++ // Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.++ function askAgent(text) {++ if (!_config.token || _config.dock === false) { const u = resolveLoginHref(currentHost(), location.href); if (u) location.href = u; return; }++ loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); });++ }++ if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('click', (e) => { const b = e.target && e.target.closest && e.target.closest('[data-ov-ask]'); if (!b) return; e.preventDefault(); askAgent(b.getAttribute('data-ov-ask') || ''); });/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */function sectionsHTML(sections) {@@@ -2399,12 +2402,9 @@* settles; a page that draws such a box later may call this). Returns how many it marked. */focusScrollers() { try { return focusScrollers(); } catch { return 0; } },-- /**-- * Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only.-- * opts may name the step to point at (opts.task): the Agent panel marks that entry and brings it-- * into view, so a site can open the Dock at its own task.-- */++ /** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },++ askAgent(text) { askAgent(text); },/** Get the bell mount point for OpenVibeNotifications. */getBellMount() {@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.134.0",++ "version": "4.135.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -699,7 +699,32 @@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 */ } };++ const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };++ // The choice follows the person (4.135.0): Network keeps it (GET/PUT /api/v1/me/menu-feeds, network.menu-feeds@1);++ // this device's copy paints at once and stands whenever Network does not answer.++ let feedsSynced = false, feedsPut = 0;++ const feedsUrl = () => `${ctx.apiBase || 'https://openvibe.network'}/api/v1/me/menu-feeds`;++ const signedInCtx = () => !!(ctx.token && ctx.user && !ctx.user.is_anon);++ function pushFeeds(on) {++ if (!signedInCtx() || typeof fetch !== 'function') return;++ clearTimeout(feedsPut);++ feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_MAX) }) }).catch(() => {}); }, 800);++ }++ const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };++ async function syncFeeds() {++ if (feedsSynced || !signedInCtx() || typeof fetch !== 'function') return;++ feedsSynced = true;++ try {++ const r = await fetch(feedsUrl(), { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });++ if (!r.ok) return;++ const j = await r.json();++ const local = feedPref();++ if (Array.isArray(j.on)) {++ const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);++ if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }++ } else if (local) pushFeeds(local);++ } catch { /* the device copy stands */ }++ }/** 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 { /* */ }@@@ -1063,6 +1088,7 @@// From the burger ('here'): this site's pages lead, all of them, the way the drawer had them.allOpen = tab === 'sites'; hereOpen = tab === 'here'; leadHere = tab === 'here'; discoverPick = null;drainCards();++ syncFeeds();el.classList.add('is-open');// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one// history entry; Back pops it and only closes the menu.@@@ -62,6 +62,8 @@ function headTags(o = {}) {if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);++ // Every page's bar asks openvibe.network (the Frame, sign-in, the menu): open that connection early (4.135.0).++ if (o.preconnect !== false && !/^https:\/\/openvibe\.network(\/|$)/.test(canonical || '')) out.push('<link rel="preconnect" href="https://openvibe.network" crossorigin>');for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);@@@ -608,7 +608,23 @@ async function drag(p, from, to) {assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');await p.close();}-- console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');++ // ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──++ {++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: false } };++ const before = asked.length;++ const p = await fresh(1280, 860);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');++ await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");++ await until(p, CARD, 'the Dock opens');++ const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');++ assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab');++ await wait(400);++ assert.strictEqual(asked.length, before, 'nothing is sent for the person');++ await p.close();++ }++ console.log('dock-chrome: the pill only when it matters, Vibe, Ask your agent, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');} finally {await browser.close();server.close();@@@ -19,7 +19,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.// 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 = 15872; // 15.50 KB (15435 after 4.120.0 + 437 bytes of headroom)++const DOCK_BROTLI_BUDGET = 16077; // 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'];// ── the budget ──────────────────────────────────────────────@@@ -20,6 +20,18 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {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_IN = (saved) => `<!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.__puts = [];++ window.fetch = (u, o) => { u = String(u);++ if (u.startsWith('https://openvibe.network/api/frame?')) return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) });++ if (u === 'https://openvibe.network/api/v1/me/menu-feeds') { if (o && o.method === 'PUT') { window.__puts.push(JSON.parse(o.body)); return Promise.resolve({ ok: true, json: async () => ({}) }); } return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(saved)}) }); }++ if (u.startsWith('https://')) return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });++ return 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', auth: 'external', token: 'tok', user: { id: 7, username: 'maya' }, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false, dock: false, wallet: false, messages: false, presence: false }); });</script>++</body></html>`;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;@@@ -31,6 +43,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta nconst 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); }++ if (u.pathname === '/in') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE_IN({ on: ['news:top', 'today:today'] })); }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();@@@ -88,7 +101,21 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neassert.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');++ // Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.++ const ctx2 = await browser.newContext();++ const q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });++ await q.goto(`http://127.0.0.1:${server.address().port}/in`, { maxMs: 5000 });++ await until(q, `!!localStorage.getItem('ov_frame_v2')`);++ await q.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);++ assert.ok(await until(q, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened (signed in)');++ assert.ok(await until(q, `JSON.stringify(${SHOWN}) === JSON.stringify(['Top stories', 'Today'])`), 'the feeds saved on Network show on a new device');++ await q.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);++ await until(q, `!!document.querySelector('.ovs-feeds')`);++ await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);++ assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');++ assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });++ await q.close();++ console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');} finally {await browser.close();server.close();nnew file mode 100644@@@ -0,0 +1,19 @@++'use strict';++// 4.135.0: every page opens its connection to openvibe.network early (the bar, sign-in and the menu all ask it), except++// the Network's own pages and a page that says preconnect: false. And kit.askAgent draws the "Ask your agent" chip.++const assert = require('assert');++const seo = require('../seo');++const kit = require('../kit');++++const tag = '<link rel="preconnect" href="https://openvibe.network" crossorigin>';++assert.ok(seo.headTags({ title: 'Live', canonical: 'https://openvibe.live/' }).includes(tag), 'a product page preconnects');++assert.ok(!seo.headTags({ title: 'Network', canonical: 'https://openvibe.network/my' }).includes(tag), 'not on the Network itself');++assert.ok(!seo.headTags({ title: 'Kiosk', canonical: 'https://openvibe.live/tv', preconnect: false }).includes(tag), 'and not when a page says no');++++const chip = kit.askAgent({ text: 'About this stream: <b>night walk</b>' });++assert.match(chip, /^<button class="ovk-ask ovk-btn/, 'a button, styled as the ask chip');++assert.match(chip, /type="button"/);++assert.match(chip, /data-ov-ask="About this stream: <b>night walk<\/b>"/, 'the question, escaped');++assert.match(chip, />Ask your agent</);++assert.strictEqual((kit.askAgent({ text: 'x'.repeat(3000) }).match(/data-ov-ask="(x+)"/) || [])[1].length, 2000, 'capped at 2,000 characters');++console.log('seo-preconnect: preconnect on product pages only; kit.askAgent draws an escaped, capped ask chip');
@@ -1,5 +1,24 @@
@@ -1,5 +1,24 @@
# Changelog
# Changelog
+## 4.135.0 — 2026-11-07
+
+- **Ask your agent, from any page** (plan Z3; the owner, 11-07: "have your agent with you wherever you go"):
+ - `kit.askAgent({ text })` draws a chip ("✦ Ask your agent"). Any control with `data-ov-ask="…"` works the same way.
+ - A tap opens the person's Vibe in the Dock with the question waiting in its box, this page's context included. It is
+ never sent for them: they read it, change it and press Send.
+ - Signed out, the navbar sends them to sign in.
+ - New calls: `OpenVibeNavbar.askAgent(text)` and `OpenVibeDock.ask({ text })`.
+- **Your feeds follow you** (the menu's "Choose your feeds", 4.132.0):
+ - signed in, the choice is saved to Network (`PUT /api/v1/me/menu-feeds`, network.menu-feeds@1);
+ - it is read back on a new device the first time the menu opens there;
+ - a first choice made before signing in is sent up then;
+ - this device's copy paints at once and stands whenever Network does not answer, and the route is new, so until
+ Network ships it nothing changes.
+- **Every page opens its connection to openvibe.network early** (`seo.headTags()` adds
+ `<link rel="preconnect" href="https://openvibe.network" crossorigin>`, except on the Network itself;
+ `preconnect: false` turns it off). The phone-speed report of 11-07 found 102 of 108 pages asking openvibe.network for
+ the bar, sign-in and the menu without one.
+
## 4.134.0 — 2026-11-07
## 4.134.0 — 2026-11-07
- **Fixed: the Dock's Agent panel could not read the timeline.** The navbar hands the Dock the person's token as a function (it may change when they sign in again); the panel used it as a string, so every call went out as `Bearer function…` and the panel said the agent was not reachable. The token is now resolved when a call is made.
- **Fixed: the Dock's Agent panel could not read the timeline.** The navbar hands the Dock the person's token as a function (it may change when they sign in again); the panel used it as a string, so every call went out as `Bearer function…` and the panel said the agent was not reachable. The token is now resolved when a call is made.
@@ -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.134.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.135.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.134.0",
+ "version": "4.135.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)",
@@ -22,6 +22,7 @@
@@ -22,6 +22,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)",
+ "Ask your agent: kit.askAgent and any [data-ov-ask] control open your Vibe in the Dock with the question waiting, unsent; and every page preconnects to openvibe.network (4.134.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)",
"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 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)",
@@ -140,6 +140,8 @@
@@ -140,6 +140,8 @@
form.append(box, go);
form.append(box, go);
panel.append(head, scroll, err, form);
panel.append(head, scroll, err, form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
+ vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };
+ if (vibe.pendingAsk) { vibe.fill(vibe.pendingAsk); vibe.pendingAsk = null; }
box.addEventListener('input', fit);
box.addEventListener('input', fit);
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
@@ -282,6 +284,8 @@
@@ -282,6 +284,8 @@
refresh: () => { vibe.panel = panel; return openVibe(); },
refresh: () => { vibe.panel = panel; return openVibe(); },
pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
talk: () => { if (vibe.talk) vibe.talk(); },
talk: () => { if (vibe.talk) vibe.talk(); },
+ // A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.
+ ask: (text) => { if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
};
};
}
}
root.OpenVibeDockVibe = { mount };
root.OpenVibeDockVibe = { mount };
@@ -827,6 +827,8 @@
@@ -827,6 +827,8 @@
}
}
// ── Open and close ───────────────────────────────────────
// ── Open and close ───────────────────────────────────────
+ // Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
+ function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function isOpen() { return !!(el && !el.hidden); }
function isOpen() { return !!(el && !el.hidden); }
function open(opts = {}) {
function open(opts = {}) {
o = { ...o, ...opts };
o = { ...o, ...opts };
@@ -877,5 +879,5 @@
@@ -877,5 +879,5 @@
if (isOpen()) layout();
if (isOpen()) layout();
}
}
- root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };
+ root.OpenVibeDock = { mount, open, close, isOpen, update, ask, _state: () => ({ doc, tab, pending }) };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -429,3 +429,8 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
@@ -429,3 +429,8 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
+
+/* "Ask your agent" (4.134.0): the chip that hands this page's question to your Vibe in the Dock. */
+.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }
+.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
- * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
+ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
@@ -171,6 +171,14 @@
@@ -171,6 +171,14 @@
attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,
attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,
'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });
'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });
}
}
+ /**
+ * "Ask your agent" (4.135.0, plan Z3): one chip any page can carry. A tap opens the person's Vibe in the Dock with the
+ * question waiting in its box, this page's context included, for them to read and send; it never sends for them.
+ * Signed out, the navbar sends them to sign in. text: the prefilled question ("About this stream: …").
+ */
+ function askAgent({ text = '', label = 'Ask your agent', small = true } = {}) {
+ return button({ type: 'button', label, icon: 'spark', kind: 'quiet', small, attrs: { 'data-ov-ask': String(text || '').slice(0, 2000), class: null } }).replace('class="ovk-btn', 'class="ovk-ask ovk-btn');
+ }
/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
@@ -892,7 +900,7 @@
@@ -892,7 +900,7 @@
return {
return {
skeleton,
skeleton,
- esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, iconButton, action, badge, dot, card, rail, CARD_RATIO,
+ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
enhance, VERSION: 2,
@@ -1235,11 +1235,14 @@
@@ -1235,11 +1235,14 @@
if (document.getElementById('ov-presence-loader')) return;
if (document.getElementById('ov-presence-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = sibling('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = sibling('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
}
}
- /**
- * Open the Dock on a tab: 'agent' | 'activity'. opts may name the step to point at (opts.task): the
- * Agent panel marks that entry and brings it into view, so a site can open the Dock at its own task.
- */
+ // The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
+ // Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
+ function askAgent(text) {
+ if (!_config.token || _config.dock === false) { const u = resolveLoginHref(currentHost(), location.href); if (u) location.href = u; return; }
+ loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); });
+ }
+ if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('click', (e) => { const b = e.target && e.target.closest && e.target.closest('[data-ov-ask]'); if (!b) return; e.preventDefault(); askAgent(b.getAttribute('data-ov-ask') || ''); });
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
function sectionsHTML(sections) {
function sectionsHTML(sections) {
@@ -2399,12 +2402,9 @@
@@ -2399,12 +2402,9 @@
* settles; a page that draws such a box later may call this). Returns how many it marked. */
* settles; a page that draws such a box later may call this). Returns how many it marked. */
focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
- /**
- * Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only.
- * opts may name the step to point at (opts.task): the Agent panel marks that entry and brings it
- * into view, so a site can open the Dock at its own task.
- */
+ /** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
+ askAgent(text) { askAgent(text); },
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
getBellMount() {
getBellMount() {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.134.0",
+ "version": "4.135.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": {
@@ -699,7 +699,32 @@
@@ -699,7 +699,32 @@
const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;
const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;
const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;
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 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 */ } };
+ const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
+ // The choice follows the person (4.135.0): Network keeps it (GET/PUT /api/v1/me/menu-feeds, network.menu-feeds@1);
+ // this device's copy paints at once and stands whenever Network does not answer.
+ let feedsSynced = false, feedsPut = 0;
+ const feedsUrl = () => `${ctx.apiBase || 'https://openvibe.network'}/api/v1/me/menu-feeds`;
+ const signedInCtx = () => !!(ctx.token && ctx.user && !ctx.user.is_anon);
+ function pushFeeds(on) {
+ if (!signedInCtx() || typeof fetch !== 'function') return;
+ clearTimeout(feedsPut);
+ feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_MAX) }) }).catch(() => {}); }, 800);
+ }
+ const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };
+ async function syncFeeds() {
+ if (feedsSynced || !signedInCtx() || typeof fetch !== 'function') return;
+ feedsSynced = true;
+ try {
+ const r = await fetch(feedsUrl(), { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
+ if (!r.ok) return;
+ const j = await r.json();
+ const local = feedPref();
+ if (Array.isArray(j.on)) {
+ const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);
+ if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }
+ } else if (local) pushFeeds(local);
+ } catch { /* the device copy stands */ }
+ }
/** 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 { /* */ }
@@ -1063,6 +1088,7 @@
@@ -1063,6 +1088,7 @@
// From the burger ('here'): this site's pages lead, all of them, the way the drawer had them.
// From the burger ('here'): this site's pages lead, all of them, the way the drawer had them.
allOpen = tab === 'sites'; hereOpen = tab === 'here'; leadHere = tab === 'here'; discoverPick = null;
allOpen = tab === 'sites'; hereOpen = tab === 'here'; leadHere = tab === 'here'; discoverPick = null;
drainCards();
drainCards();
+ syncFeeds();
el.classList.add('is-open');
el.classList.add('is-open');
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
// history entry; Back pops it and only closes the menu.
// history entry; Back pops it and only closes the menu.
@@ -62,6 +62,8 @@ function headTags(o = {}) {
@@ -62,6 +62,8 @@ function headTags(o = {}) {
if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
+ // Every page's bar asks openvibe.network (the Frame, sign-in, the menu): open that connection early (4.135.0).
+ if (o.preconnect !== false && !/^https:\/\/openvibe\.network(\/|$)/.test(canonical || '')) out.push('<link rel="preconnect" href="https://openvibe.network" crossorigin>');
for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
@@ -608,7 +608,23 @@ async function drag(p, from, to) {
@@ -608,7 +608,23 @@ async function drag(p, from, to) {
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
await p.close();
await p.close();
}
}
- console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
+ // ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
+ {
+ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: false } };
+ const before = asked.length;
+ const p = await fresh(1280, 860);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');
+ await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");
+ await until(p, CARD, 'the Dock opens');
+ const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
+ assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab');
+ await wait(400);
+ assert.strictEqual(asked.length, before, 'nothing is sent for the person');
+ await p.close();
+ }
+ console.log('dock-chrome: the pill only when it matters, Vibe, Ask your agent, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -19,7 +19,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -19,7 +19,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
// 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 = 15872; // 15.50 KB (15435 after 4.120.0 + 437 bytes of headroom)
+const DOCK_BROTLI_BUDGET = 16077; // 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'];
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
@@ -20,6 +20,18 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
@@ -20,6 +20,18 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
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 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: {},
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)] };
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_IN = (saved) => `<!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.__puts = [];
+ window.fetch = (u, o) => { u = String(u);
+ if (u.startsWith('https://openvibe.network/api/frame?')) return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) });
+ if (u === 'https://openvibe.network/api/v1/me/menu-feeds') { if (o && o.method === 'PUT') { window.__puts.push(JSON.parse(o.body)); return Promise.resolve({ ok: true, json: async () => ({}) }); } return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(saved)}) }); }
+ if (u.startsWith('https://')) return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
+ return 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', auth: 'external', token: 'tok', user: { id: 7, username: 'maya' }, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false, dock: false, wallet: false, messages: false, presence: false }); });</script>
+</body></html>`;
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:,">
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>
<script>
const realFetch = window.fetch;
const realFetch = window.fetch;
@@ -31,6 +43,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta n
@@ -31,6 +43,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta n
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
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); }
if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ if (u.pathname === '/in') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE_IN({ on: ['news:top', 'today:today'] })); }
const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
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]))); }
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();
res.writeHead(404); return res.end();
@@ -88,7 +101,21 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -88,7 +101,21 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
await wait(150);
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
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');
+ // Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
+ const ctx2 = await browser.newContext();
+ const q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
+ await q.goto(`http://127.0.0.1:${server.address().port}/in`, { maxMs: 5000 });
+ await until(q, `!!localStorage.getItem('ov_frame_v2')`);
+ await q.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
+ assert.ok(await until(q, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened (signed in)');
+ assert.ok(await until(q, `JSON.stringify(${SHOWN}) === JSON.stringify(['Top stories', 'Today'])`), 'the feeds saved on Network show on a new device');
+ await q.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
+ await until(q, `!!document.querySelector('.ovs-feeds')`);
+ await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);
+ assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
+ assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
+ await q.close();
+ console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+'use strict';
+// 4.135.0: every page opens its connection to openvibe.network early (the bar, sign-in and the menu all ask it), except
+// the Network's own pages and a page that says preconnect: false. And kit.askAgent draws the "Ask your agent" chip.
+const assert = require('assert');
+const seo = require('../seo');
+const kit = require('../kit');
+
+const tag = '<link rel="preconnect" href="https://openvibe.network" crossorigin>';
+assert.ok(seo.headTags({ title: 'Live', canonical: 'https://openvibe.live/' }).includes(tag), 'a product page preconnects');
+assert.ok(!seo.headTags({ title: 'Network', canonical: 'https://openvibe.network/my' }).includes(tag), 'not on the Network itself');
+assert.ok(!seo.headTags({ title: 'Kiosk', canonical: 'https://openvibe.live/tv', preconnect: false }).includes(tag), 'and not when a page says no');
+
+const chip = kit.askAgent({ text: 'About this stream: <b>night walk</b>' });
+assert.match(chip, /^<button class="ovk-ask ovk-btn/, 'a button, styled as the ask chip');
+assert.match(chip, /type="button"/);
+assert.match(chip, /data-ov-ask="About this stream: <b>night walk<\/b>"/, 'the question, escaped');
+assert.match(chip, />Ask your agent</);
+assert.strictEqual((kit.askAgent({ text: 'x'.repeat(3000) }).match(/data-ov-ask="(x+)"/) || [])[1].length, 2000, 'capped at 2,000 characters');
+console.log('seo-preconnect: preconnect on product pages only; kit.askAgent draws an escaped, capped ask chip');