openvibers/openvibe.shared Commits

4.206.0: the menu keeps media playing, the Feed in the menu searchable with the one composer, a Code task in the Dock (#467)

OP OpenVibers committed 2 days ago · de9cf2e3e5 · 27 files, +473 −132

* Keep live media playing under mobile sheets and expose portable player state

* Refresh sibling asset revisions for player changes

* Merge remote-tracking branch 'origin/main' into ask360-shared-r2

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	navbar.js
#	package-lock.json
#	package.json

* Carry changed sound state on later site handoffs

* Add Vibe navigation, Feed search, and unified composer to menu

* Keep Dock within size budget

* Preserve site search placeholder outside Feed

* Merge Shared 4.205.0 into menu work

* Open Code tasks in shared AG-UI Dock card

* Keep Shared release docs at 4.206.0

* Merge remote-tracking branch 'origin/ask360-shared-r2' into release/4206

# Conflicts:
#	CHANGELOG.md
#	STATUS.json
#	navbar.js

* Merge remote-tracking branch 'origin/cx/360-menu-feed-chat' into release/4206

* Merge remote-tracking branch 'origin/feat/354-agent-view-dock' into release/4206

# Conflicts:
#	CHANGELOG.md
#	STATUS.json
#	dock.js
#	navbar.js

* 4.206.0: the menu keeps media playing, the Feed in the menu searchable with the one composer, a Code task in the Dock

Carries #461 (menu keeps media playing), #463 (Feed search and the one composer in the menu; Chat's Vibe row) and #465
(a Code task in the Dock's AG-UI card). Design fixes on #463: the composer's + is its own label.ovt-btn.ovt-attach
(the kit button drew bare in the menu), and the example searches are the feed's own chips. One CHANGELOG entry;
SIBLING_REV and DOCK_REV regenerated.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d9d2185..96d2faf 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,23 @@
# Changelog
++## 4.206.0 — 2026-11-11
++
++**The menu keeps a stream playing, the Feed in the menu is searchable with the one composer at its top, and a Code task
++opens in the Dock's step card.**
++
++- **The menu no longer pauses what is playing (#461, ask 360).** Opening the menu on a phone left live video and audio
++ paused under it; the sheet now leaves media alone and the page can read the player's state.
++- **The Feed in the menu (#463, ask 360).** Feed search uses Community's search and examples through Network, with
++ loaded-card filtering when search is unavailable; the example searches are chips that fill the field. The Feed's
++ post form is the one composer (OpenVibeComposer: files, paste, drop, commands, drafts, settings); each picture needs a
++ description and other files upload to Media. Its + and the example chips carry their own styles in the menu (they
++ drew as bare browser buttons in the PR's screenshots).
++- **Chat and Vibe (#463).** The Chat summary's Vibe row is an accessible Open Vibe button that selects the menu's Vibe
++ tab; on a desktop Chat and Vibe open the Dock after the menu closes, and the Dock opens from its pill only when that
++ pill is visible.
++- **A Code task in the Dock (#465, ask 354).** The Dock's Code card shows the task's live steps with the AG-UI step view
++ (agent-session.js card mode) and opens the whole run.
++
## 4.205.0 — 2026-11-11
**Agents show their work everywhere: the one step view reads AG-UI, compact like the portal, with the one composer to
ddiff --git a/README.md b/README.md
iindex 3cf72e1..5be1cb1 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.205.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.206.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 07842ed..4c160e7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.205.0",
++ "version": "4.206.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)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"features": [
++ "the menu Feed searches Community with examples and uses the one composer for pictures and files; the Chat summary opens the Vibe tab (4.206.0)",
"the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)",
"buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
ddiff --git a/dock-code.js b/dock-code.js
iindex b9c7ba3..6789fa7 100644
---- a/dock-code.js
++++ b/dock-code.js
@@@ -18,6 +18,7 @@
if (typeof document === 'undefined' || root.OpenVibeDockCode) return;
const CODES = 'https://openvibe.codes';
++ const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
const POLL_MS = 8000;
const MODES = [['create', 'Create', 'A new website, game or app'], ['openvibe', 'OpenVibe', 'A change to OpenVibe itself'], ['repo', 'My code', 'A repository of yours']];
const STATE = { queued: 'Waiting', running: 'Running', 'needs-you': 'Needs you', done: 'Done', failed: 'Failed', stopped: 'Stopped' };
@@@ -100,6 +101,72 @@
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
let gone = false, timer = 0, mode = 'create', tasks = null, reachable = true;
++ function loadScript(file, ready) {
++ if (ready()) return Promise.resolve(true);
++ return new Promise((resolve) => {
++ const id = `ovdc-${file}`;
++ let script = d.getElementById(id);
++ if (!script) { script = d.createElement('script'); script.id = id; script.src = SCRIPT_BASE + file; script.async = true; d.head.appendChild(script); }
++ script.addEventListener('load', () => resolve(!!ready()), { once: true });
++ script.addEventListener('error', () => resolve(false), { once: true });
++ });
++ }
++ function runUrl(id) {
++ return (direct ? direct + '/api/v1/runs/' : relay + '/codes-runs/') + encodeURIComponent(id);
++ }
++ async function openRun(task, from) {
++ const href = onCodes(task.url);
++ if (!href || !tok()) return;
++ const loaded = await Promise.all([
++ loadScript('ov-window.js', () => !!root.OVWindow),
++ loadScript('agent-session.js', () => !!root.OpenVibeAgentSession)
++ ]);
++ if (loaded.some((ok) => !ok)) { d.defaultView.location.assign(href); return; }
++ const body = h('div');
++ let stopped = false, controller = null, view = null;
++ const win = root.OVWindow.open({ id: `codes-run-${task.id}`, title: task.title || 'Coding task', body, kind: 'dialog', size: 'md', from,
++ onClose: () => { stopped = true; if (controller) controller.abort(); if (view) view.destroy(); } });
++ body.appendChild(h('p', 'ovdc-none', 'Loading the run…'));
++ const headers = () => ({ Authorization: `Bearer ${tok()}`, Accept: 'application/json' });
++ try {
++ const res = await fetch(runUrl(task.id), { headers: headers(), credentials: 'omit' });
++ if (!res.ok) throw new Error('The run could not be read.');
++ const run = await res.json();
++ if (stopped) return;
++ body.replaceChildren();
++ view = root.OpenVibeAgentSession.mount(body, { title: run.title || task.title || 'Coding task',
++ status: run.status || task.status || 'running', mode: 'card', href });
++ let last = 0;
++ while (!stopped) {
++ controller = new AbortController();
++ const events = await fetch(runUrl(task.id) + '/events', { credentials: 'omit',
++ headers: { ...headers(), Accept: 'text/event-stream', 'Last-Event-ID': String(last) }, signal: controller.signal });
++ if (!events.ok || !events.body) break;
++ const reader = events.body.getReader();
++ const decoder = new TextDecoder();
++ let buffer = '', terminal = false;
++ while (!stopped) {
++ const part = await reader.read();
++ if (part.done) break;
++ buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
++ let cut;
++ while ((cut = buffer.indexOf('\n\n')) !== -1) {
++ const frame = buffer.slice(0, cut); buffer = buffer.slice(cut + 2);
++ const id = /^id: *(\d+)$/m.exec(frame), data = frame.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
++ if (id) last = Number(id[1]);
++ if (!data) continue;
++ try { const event = JSON.parse(data); view.push(event); if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') terminal = true; } catch { /* skip one malformed event */ }
++ }
++ }
++ if (terminal || !/running|queued|placed/.test(run.status || '')) break;
++ await new Promise((resolve) => setTimeout(resolve, 1500));
++ }
++ } catch (err) {
++ if (!stopped) { body.replaceChildren(); const a = h('a', 'ovk-btn ovk-btn--quiet', 'Open on Codes'); a.href = href; body.appendChild(a); }
++ }
++ return win;
++ }
++
container.innerHTML = '';
container.classList.add('ovdc');
const box = h('form', 'ovdc-box');
@@@ -183,7 +250,10 @@
const li = h('li', 'ovdc-task');
const there = onCodes(t.url);
const a = h(there ? 'a' : 'div', 'ovdc-a');
-- if (there) a.href = there;
++ if (there) { a.href = there; a.addEventListener('click', (event) => {
++ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
++ event.preventDefault(); openRun(t, a);
++ }); }
a.appendChild(h('span', 'ovdc-t', t.title || 'A coding task'));
const m = h('span', 'ovdc-m');
const s = h('span', 'ovdc-s', STATE[t.status] || t.status || ''); s.dataset.s = t.status || '';
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 340473c..3c17283 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -58,7 +58,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
-- const VIBE_REV = { 'ov-composer.js': 'fbe0a5b0d179' };
++ const VIBE_REV = { 'ov-composer.js': '94fe9cbede93' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex e5b3d57..bd0cbeb 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '7f3c25bc076d', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
++ const DOCK_REV = { 'dock-vibe.js': '2c597ddf5604', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
@@@ -694,8 +694,11 @@
const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
-- b.append(av, body, meta); b.setAttribute('aria-label', `Vibe, ${c.count} chats${at ? `, ${answered(at)}` : ''}`);
-- b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });
++ b.append(av, body, meta); b.setAttribute('aria-label', 'Open Vibe');
++ b.addEventListener('click', () => {
++ if (!b.dispatchEvent(new CustomEvent('openvibe:open-vibe', { bubbles: true, cancelable: true }))) return;
++ vibeOnList = true; save({ vibe: null }); setTab('vibe', true);
++ });
li.append(b); ul.append(li); return;
}
const li = h('li'), b = h('button', 'ovdk-chat');
@@@ -1042,7 +1045,10 @@
fromOrb();
void el.offsetWidth; el.classList.add('is-in');
// On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.
-- if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) root.OpenVibeNavbar.pop(el, document.getElementById('ovdk-pill'));
++ if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) {
++ const pill = document.getElementById('ovdk-pill');
++ root.OpenVibeNavbar.pop(el, pill?.offsetWidth ? pill : null);
++ }
if (phone()) {
el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');
if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }
ddiff --git a/docs/screenshots/360-chat-1280.png b/docs/screenshots/360-chat-1280.png
nnew file mode 100644
iindex 0000000..9423384
BBinary files /dev/null and b/docs/screenshots/360-chat-1280.png differ
ddiff --git a/docs/screenshots/360-chat-390.png b/docs/screenshots/360-chat-390.png
nnew file mode 100644
iindex 0000000..cde4e29
BBinary files /dev/null and b/docs/screenshots/360-chat-390.png differ
ddiff --git a/docs/screenshots/360-feed-1280.png b/docs/screenshots/360-feed-1280.png
nnew file mode 100644
iindex 0000000..83eb04a
BBinary files /dev/null and b/docs/screenshots/360-feed-1280.png differ
ddiff --git a/docs/screenshots/360-feed-390.png b/docs/screenshots/360-feed-390.png
nnew file mode 100644
iindex 0000000..f1885fa
BBinary files /dev/null and b/docs/screenshots/360-feed-390.png differ
ddiff --git a/docs/screenshots/ask-354-1280.png b/docs/screenshots/ask-354-1280.png
nnew file mode 100644
iindex 0000000..a03da6d
BBinary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
ddiff --git a/docs/screenshots/ask-354-390.png b/docs/screenshots/ask-354-390.png
nnew file mode 100644
iindex 0000000..d98be4e
BBinary files /dev/null and b/docs/screenshots/ask-354-390.png differ
ddiff --git a/navbar.js b/navbar.js
iindex 2c1d4b0..e93bdd3 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '0748f4087385', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '1428fea2f91c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex f2621ba..714ceb7 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -30,7 +30,15 @@
// 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
// Vibe and Code boxes included. Injected once, before the first box is joined.
-- const CSS = `.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
++ const CSS = `.ovt-composer { position: relative; display: grid; gap: 6px; max-width: 780px; margin: 0 auto; padding: 10px 12px; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 18px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }
++.ovt-composer textarea { width: 100%; min-height: 48px; box-sizing: border-box; padding: 6px 2px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; }
++.ovt-c-row { display: flex; align-items: center; gap: 8px; }
++.ovt-c-tools { flex: 1; display: flex; align-items: center; gap: 6px; }
++.ovt-send { min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
++.ovt-send:disabled { opacity: .5; }
++.ovt-composer .ovc-file.ovc-image { height: auto; min-height: 100px; flex-wrap: wrap; overflow: visible; }
++.ovt-composer .ovc-image input { flex: 1 0 100%; min-width: 0; min-height: 36px; box-sizing: border-box; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 8px; background: var(--bg-primary, #0b1220); color: inherit; font: inherit; }
++.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
.ovc-files[hidden] { display: none; }
.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
@@@ -251,6 +259,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
files.splice(i, 1);
if (f.url) URL.revokeObjectURL(f.url);
if (f.hidden) f.hidden.remove();
++ form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));
f.chip.remove();
strip.hidden = !files.length;
syncInputs();
@@@ -266,6 +275,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
const f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };
files.push(f);
paintFile(f);
++ form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));
if (up) {
f.progress = 0; paintFile(f);
Promise.resolve(up(file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))
@@@ -505,7 +515,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
const api = {
form, ctx,
refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
-- add, files: () => files.map((f) => f.file),
++ add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },
uploads: () => files.map((f) => f.id).filter(Boolean),
clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
ddiff --git a/ov-home.js b/ov-home.js
iindex 717bce7..a6946d3 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -152,28 +152,6 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
--.ovh-compose { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px; align-items: start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
-- box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
--.ovh-compose textarea { flex: 1; min-width: 0; min-height: 44px; max-height: 160px; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
-- background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; line-height: 1.4; resize: none; overflow-y: auto; }
--.ovh-post { flex: none; min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; cursor: pointer; }
--.ovh-post:disabled { opacity: .5; cursor: default; }
--.ovh-compose-main { display: grid; gap: 8px; min-width: 0; }
--.ovh-compose-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
--.ovh-compose-bar .ovh-post { margin-left: auto; }
--.ovh-compose-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.ovh-pic-btn { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 12px; background: transparent; color: var(--accent-light, var(--accent, #60a5fa)); cursor: pointer; }
--.ovh-pic-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
--.ovh-pic-btn:disabled { opacity: .4; cursor: default; background: transparent; }
--.ovh-pic-btn svg, .ovh-pic-x svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
--.ovh-pics { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
--.ovh-pics[hidden] { display: none; }
--.ovh-pic { display: grid; grid-template-columns: 56px minmax(0, 1fr) 40px; gap: 8px; align-items: center; }
--.ovh-pic img { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
--.ovh-pic input { min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }
--.ovh-pic-x { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
--.ovh-pic-x svg { width: 18px; height: 18px; }
--.ovh-pic-x:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); }
.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }
.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
@@@ -359,6 +337,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const ICON = {
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
++ attach: [['path', { d: 'M12 5v14M5 12h14' }]],
plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],
out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],
grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],
@@@ -872,85 +851,83 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
return out;
}
function composer(box, o) {
-- const form = h('form', 'ovh-compose');
-- const face = h('span', 'ovh-face');
-- const pic = o.user && /^https:\/\//.test(String(o.user.avatar_url || o.user.avatar || '')) ? (o.user.avatar_url || o.user.avatar) : '';
-- if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String((o.user && (o.user.display_name || o.user.username)) || 'Y').charAt(0).toUpperCase();
++ const form = h('form', 'ovt-composer'); form.id = 'ovh-feed-composer'; form.setAttribute('data-ovc', ''); form.setAttribute('data-ovc-max-files', '10'); form.setAttribute('data-ovc-max-mb', '300');
++ const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 2000; ta.rows = 2; ta.required = true; ta.setAttribute('data-ovt-text', '');
const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
-- const main = h('div', 'ovh-compose-main');
-- const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 500; ta.rows = 1; ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
-- // Pictures (owner, 11-09: "you can make posts and put images"): up to four, each with a line saying what it shows,
-- // which Community requires (alt text, for people who cannot see it).
-- const pics = []; const list = h('ul', 'ovh-pics'); list.hidden = true;
-- const bar = h('div', 'ovh-compose-bar');
-- const add = h('button', 'ovh-pic-btn'); add.type = 'button'; add.setAttribute('aria-label', 'Add pictures'); add.title = 'Add pictures (up to 4)'; add.append(svgIcon('image'));
-- const file = h('input'); file.type = 'file'; file.accept = 'image/jpeg,image/png,image/webp,image/gif'; file.multiple = true; file.hidden = true;
-- const hint = h('small', 'ovh-compose-hint');
-- const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;
-- const ready = () => {
-- const missing = pics.some((p) => !p.alt.value.trim());
-- post.disabled = !ta.value.trim() || missing;
-- hint.textContent = missing ? 'Describe each picture' : '';
-- add.disabled = pics.length >= 4;
-- list.hidden = !pics.length;
-- };
-- ta.addEventListener('input', () => { ready(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });
-- add.addEventListener('click', () => file.click());
-- file.addEventListener('change', async () => {
-- const chosen = [...(file.files || [])].slice(0, 4 - pics.length); file.value = '';
-- for (const f of chosen) {
-- if (!/^image\//.test(f.type)) continue;
-- const pr = await preparePicture(f);
-- if (pr.file.size > 8 * 1024 * 1024) { note.hidden = false; note.textContent = `${f.name} is over 8 MB.`; continue; }
-- const li = h('li', 'ovh-pic');
-- const im = h('img'); im.alt = ''; if (pr.thumb) im.src = pr.thumb;
-- const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?'; alt.setAttribute('aria-label', `What ${f.name} shows`);
-- const x = h('button', 'ovh-pic-x'); x.type = 'button'; x.setAttribute('aria-label', 'Remove this picture'); x.title = 'Remove'; x.append(svgIcon('x'));
-- const entry = { file: pr.file, alt, li };
-- x.addEventListener('click', () => { pics.splice(pics.indexOf(entry), 1); li.remove(); ready(); });
-- alt.addEventListener('input', ready);
-- li.append(im, alt, x); list.append(li); pics.push(entry);
-- }
-- ready();
-- const empty = pics.find((p) => !p.alt.value.trim()); if (empty) empty.alt.focus();
++ ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
++ const row = h('div', 'ovt-c-row'), tools = h('div', 'ovt-c-tools');
++ // The composer's own + (its contract: label.ovt-btn.ovt-attach holding the file input), drawn by ov-composer.js's
++ // styles wherever the menu opens; a kit button had no styles here and drew bare ("Attach files").
++ const attach = h('label', 'ovt-btn ovt-attach'); attach.title = 'Attach files'; attach.setAttribute('aria-label', 'Attach files');
++ attach.append(svgIcon('attach'));
++ const file = h('input'); file.type = 'file'; file.multiple = true; file.hidden = true; file.setAttribute('data-ovt-files', ''); file.setAttribute('aria-label', 'Attach files');
++ attach.append(file); tools.append(attach);
++ const post = h('button', 'ovt-send', 'Post'); post.type = 'submit'; post.disabled = true; post.setAttribute('data-ovt-send', '');
++ const note = h('p', 'ovh-note'); note.hidden = true; note.setAttribute('data-ovt-note', '');
++ row.append(tools, post); form.append(ta, row); box.append(form, note);
++ const pics = new Map();
++ const ready = () => { post.disabled = !ta.value.trim() || [...pics.values()].some((alt) => !alt.value.trim()); };
++ form.addEventListener('input', ready);
++ form.addEventListener('ovc:file-added', (e) => {
++ const { file: f, chip } = e.detail;
++ if (!/^image\//.test(f.type)) return;
++ chip.classList.add('ovc-image');
++ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?';
++ alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows`); alt.required = true;
++ chip.append(alt); pics.set(f, alt); queueMicrotask(ready);
});
-- bar.append(add, file, hint, post);
-- main.append(ta, list, bar);
-- form.append(face, main);
-- const note = h('p', 'ovh-note'); note.hidden = true;
++ form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); queueMicrotask(ready); });
const auth = { Authorization: `Bearer ${o.token}` };
++ const upload = async (url, f) => {
++ const fd = new FormData(); fd.append('file', f, f.name || 'file');
++ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: auth, body: fd });
++ const j = await r.json().catch(() => ({}));
++ const id = (j.attachment && (j.attachment.media_id || j.attachment.id)) || j.id;
++ if (!r.ok || !id) throw new Error(j.detail || `${f.name || 'File'} did not upload.`);
++ return id;
++ };
form.addEventListener('submit', async (e) => {
e.preventDefault();
-- const text = ta.value.trim(); if (!text || post.disabled) return;
-- post.disabled = true; add.disabled = true; note.hidden = true;
++ const text = ta.value.trim(), files = form.__ovc ? form.__ovc.files() : [...file.files];
++ if (!text) { ta.focus(); return; }
++ const images = files.filter((f) => /^image\//.test(f.type));
++ if (images.length > 4) { note.hidden = false; note.textContent = 'Choose up to four pictures.'; return; }
++ const missing = images.find((f) => !pics.get(f) || !pics.get(f).value.trim());
++ if (missing) { note.hidden = false; note.textContent = 'Describe each picture.'; pics.get(missing)?.focus(); return; }
++ post.disabled = true; note.hidden = true;
const label = post.textContent;
try {
-- let r;
-- if (pics.length) {
-- const named = [];
-- for (let k = 0; k < pics.length; k++) {
-- post.textContent = pics.length > 1 ? `Picture ${k + 1} of ${pics.length}` : 'Sending the picture';
-- const fd = new FormData(); fd.append('file', pics[k].file, pics[k].file.name || 'picture.jpg');
-- const up = await fetch(`${o.frameBase || FRAME}/attachments`, { method: 'POST', credentials: 'omit', headers: auth, body: fd });
-- const uj = await up.json().catch(() => ({}));
-- const id = uj && uj.attachment && uj.attachment.media_id;
-- if (!up.ok || !id) throw new Error(uj.detail || 'A picture did not upload. Try again in a moment.');
-- named.push({ media_id: id, alt: pics[k].alt.value.trim() });
++ const pictures = [], attachments = [];
++ for (const f of files) {
++ post.textContent = `Uploading ${f.name || 'file'}`;
++ if (/^image\//.test(f.type)) {
++ const prepared = await preparePicture(f);
++ const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
++ pictures.push({ media_id, alt: pics.get(f).value.trim() });
++ } else {
++ const media_id = await upload('https://openvibe.media/api/v1/attachments', f);
++ attachments.push(media_id);
}
-- post.textContent = 'Posting';
-- r = await fetch(`${o.frameBase || FRAME}/posts`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text, pictures: named }) });
-- } else {
-- r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text }) });
}
++ post.textContent = 'Posting';
++ const url = files.length ? `${o.frameBase || FRAME}/posts` : `${o.frameBase || FRAME}/status`;
++ const body = files.length ? { text, pictures, attachments } : { text };
++ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
-- ta.value = ''; ta.style.height = ''; pics.splice(0).forEach((p) => p.li.remove());
++ ta.value = ''; ta.dispatchEvent(new Event('input', { bubbles: true })); form.__ovc?.clearFiles(); pics.clear();
note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';
if (o.onPosted) o.onPosted(j);
-- } catch (err) { note.hidden = false; note.textContent = String(err.message || err); }
++ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); ta.dispatchEvent(new Event('input', { bubbles: true })); }
post.textContent = label; ready();
});
-- box.append(form, note);
++ const join = () => { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); };
++ if (root.OpenVibeComposer) join();
++ else if (typeof o.sibling === 'function') {
++ let script = document.getElementById('ov-composer-script');
++ if (!script) { script = document.createElement('script'); script.id = 'ov-composer-script'; script.src = o.sibling('ov-composer.js'); script.onload = join; document.head.append(script); }
++ else script.addEventListener('load', join, { once: true });
++ }
}
function spark(series) {
const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);
@@@ -1259,11 +1236,64 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
el.classList.add('ovh', 'ovfd');
el.setAttribute('data-sec', 'wall');
el.textContent = '';
++ const examples = h('div', 'ovfd-examples'); examples.setAttribute('aria-label', 'Example feed searches');
++ const resultHead = h('div', 'ovfd-results'); resultHead.hidden = true;
const chips = h('div', 'ovfd-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');
const list = h('div', 'ovfd-list');
const sentinel = h('div', 'ovfd-more');
-- el.append(chips, list, sentinel);
++ el.append(examples, resultHead, chips, list, sentinel);
let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';
++ let query = '', searchSeq = 0;
++ const loaded = [];
++ const feedBase = `${o.frameBase || FRAME}/feed`;
++ const headers = () => Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {});
++ const fallbackExamples = ["Who's live", 'New photos', 'Clips from today', 'Videos this week'];
++ const showExamples = (values) => {
++ examples.textContent = '';
++ [...new Set([...values, ...fallbackExamples])].slice(0, 6).forEach((value) => {
++ const b = h('button', 'ovfd-ex', value); b.type = 'button';
++ b.addEventListener('click', () => { if (o.onSearch) o.onSearch(value); });
++ examples.append(b);
++ });
++ };
++ showExamples(fallbackExamples);
++ fetch(`${feedBase}/examples`, { credentials: 'omit', headers: headers() }).then((r) => r.ok ? r.json() : null).then((j) => {
++ const values = j && Array.isArray(j.examples) ? j.examples.map((x) => x && x.text).filter((x) => typeof x === 'string' && x.trim()) : [];
++ if (values.length) showExamples(values);
++ }).catch(() => {});
++ const matches = (x, q) => {
++ const words = q.toLowerCase().replace(/^(new |who's |from |this )/g, '').split(/\s+/).filter((w) => !['the', 'today', 'week'].includes(w));
++ const hay = [x.title, x.text, x.kind, x.site, x.site_name, x.actor && x.actor.name].join(' ').toLowerCase();
++ if (q.toLowerCase().includes('today') && String(x.at || '').slice(0, 10) !== new Date().toISOString().slice(0, 10)) return false;
++ if (q.toLowerCase().includes('this week') && Date.parse(x.at || '') < Date.now() - 7 * 864e5) return false;
++ return words.filter((w) => !['from', 'this', 'week'].includes(w)).every((w) => {
++ const word = w.replace(/s$/, '');
++ return hay.includes(word) || word === 'photo' && x.kind === 'pic';
++ });
++ };
++ async function search(q) {
++ query = String(q || '').trim(); const current = ++searchSeq;
++ resultHead.textContent = '';
++ resultHead.hidden = !query; chips.hidden = !!query; sentinel.hidden = !!query;
++ examples.hidden = !!query;
++ if (!query) { cursor = null; done = false; busy = false; more(true); return; }
++ list.textContent = '';
++ resultHead.append(h('span', '', 'Searching…'));
++ let found = null;
++ try {
++ const r = await fetch(`${feedBase}/search?q=${encodeURIComponent(query)}`, { credentials: 'omit', headers: headers() });
++ if (r.ok) { const j = await r.json(); if (Array.isArray(j.items)) found = j.items; }
++ } catch { /* the loaded cards remain searchable */ }
++ if (current !== searchSeq) return;
++ if (!found) found = loaded.filter((x) => matches(x, query));
++ resultHead.textContent = '';
++ resultHead.append(h('span', '', `${found.length} result${found.length === 1 ? '' : 's'}`));
++ const clear = h('button', 'ovk-btn ovk-btn--quiet', '×'); clear.type = 'button'; clear.setAttribute('aria-label', 'Clear feed search');
++ clear.addEventListener('click', () => { if (o.onSearch) o.onSearch(''); }); resultHead.append(clear);
++ list.textContent = '';
++ found.forEach((x) => { const card = feedCard(x); if (card) list.append(card); });
++ if (typeof o.mountIcons === 'function') o.mountIcons(list);
++ }
const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };
const clearSkels = () => list.querySelectorAll('.ovfd-skel').forEach((x) => x.remove());
async function more(first) {
@@@ -1272,12 +1302,15 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
if (first) { list.textContent = ''; skeletons(); } else skeletons();
const j = await loadFeed(o, kind, cursor);
if (my !== seq) return;
-- clearSkels(); busy = false;
++ clearSkels();
if (!j) {
++ busy = false;
if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall stands
done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
}
if (first && o.feedLive) o.feedLive();
++ if (!kind) { if (first) loaded.length = 0; loaded.push(...j.items); }
++ if (query) { busy = false; search(query); return; }
// Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).
const past = (x) => x && (x.kind === 'stream' || (x.kind === 'live' && !x.live_now)) && safe(x.url);
let run = [];
@@@ -1291,6 +1324,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
cursor = j.next_cursor || null;
done = !cursor;
++ busy = false;
sentinel.textContent = '';
if (done) sentinel.append(h('p', 'ovh-note ovfd-caught', list.querySelector('.ovfd-card,.ovfd-act,.ovfd-update') ? "You're all caught up."
: (kind === 'following' ? 'Nothing yet from the people you follow. Follow someone on their profile and what they share shows here.' : 'Nothing here yet.')));
@@@ -1311,7 +1345,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
io.observe(sentinel);
}
more(true);
-- return { update() {} };
++ return { update() {}, search, get query() { return query; } };
}
function feedStyles() {
if (document.getElementById('ovfd-styles')) return;
@@@ -1319,6 +1353,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
st.textContent = `
.ovfd-chips{position:sticky;top:0;z-index:2;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:8px 4px;margin:0 -4px;background:var(--bg-secondary,#141a24)}
.ovfd-chips::-webkit-scrollbar{display:none}
++.ovfd-examples{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:2px 4px 6px;margin:0 -4px}.ovfd-examples::-webkit-scrollbar{display:none}.ovfd-examples[hidden]{display:none}
++.ovfd-ex{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;border-radius:999px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:13px;font-weight:650;cursor:pointer;white-space:nowrap;transition:background .15s}
++.ovfd-ex::before{content:"";width:12px;height:12px;flex:none;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;opacity:.8}
++.ovfd-ex:hover,.ovfd-ex:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent)}
.ovfd-chip{flex:none;min-height:36px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}
.ovfd-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}
.ovfd-list{display:grid;gap:10px;padding:4px 0}
ddiff --git a/ov-thread.js b/ov-thread.js
iindex a0ad44b..2a35516 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': 'fbe0a5b0d179', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': '94fe9cbede93', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/overlay.js b/overlay.js
iindex 2ba03f0..249eaad 100644
---- a/overlay.js
++++ b/overlay.js
@@@ -37,6 +37,11 @@
const cards = new Map(); // id → card
const declared = new Map(); // id → descriptor (drawn by the page itself)
let styleEl = null, clicksWired = false;
++ const changed = (reason, card) => { try { root.dispatchEvent(new CustomEvent('openvibe:player', { detail: { reason, card: card || current() } })); } catch { /* */ } };
++ function current() {
++ const live = [...declared.values(), ...cards.values()].map((c) => c.d || c).find((c) => c.live);
++ return live ? { ...live } : null;
++ }
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
@@@ -59,7 +64,7 @@
function portableOf(d) {
const src = safeSrc(d && d.src);
if (!src) return null;
-- const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live };
++ const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live, sound: !!d.sound };
const href = safeSrc(d.href); if (href) out.href = href;
const side = d.side && safeSrc(d.side.src); if (side) out.side = { src: side, title: clean(d.side.title, 80) };
if (Number(d.aspect) > 0.3 && Number(d.aspect) < 4) out.aspect = Number(d.aspect);
@@@ -353,7 +358,11 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
if (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));
ui.append(top, row);
const grip = document.createElement('span'); grip.className = 'ovo-grip'; grip.innerHTML = ICON.grip; grip.setAttribute('aria-hidden', 'true');
-- el.append(frame(d.src, d.title), ui, grip);
++ const mediaFrame = frame(d.src, d.title);
++ if (d.sound && embed) mediaFrame.addEventListener('load', () => {
++ try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(d.src).origin); } catch { /* browser may require a new tap */ }
++ });
++ el.append(mediaFrame, ui, grip);
document.body.appendChild(el);
cards.set(c.id, c);
let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
@@@ -368,6 +377,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
wire(c);
if (!fine()) showUi(c);
remember();
++ changed('open', d);
return handle(c);
}
function handle(c) {
@@@ -380,12 +390,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
clearTimeout(c.idle);
try { c.el.remove(); } catch { /* */ }
remember();
++ changed('close');
}
function list() { return [...cards.values()].map((c) => ({ ...c.d })); }
function declare(id, desc) {
const key = clean(id, 40);
if (!desc) declared.delete(key); else { const d = portableOf({ ...desc, id: key }); if (d) declared.set(key, d); }
if (declared.size) wireClicks();
++ changed('declare');
}
// ── Within a site: the cards come back after a reload ──
@@@ -412,7 +424,9 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
if (u.origin === root.location.origin || u.protocol !== 'https:' || !FIRST_PARTY.test(u.hostname)) return href;
const data = b64(JSON.stringify(list));
if (data.length > 3000) return href;
-- u.hash = `ovo=${data}`;
++ const existing = u.hash.slice(1);
++ u.hash = existing ? `${existing}&ovo=${data}` : `ovo=${data}`;
++ changed('handoff');
return u.href;
} catch { return href; }
}
@@@ -433,7 +447,12 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
let list = null;
if (m) {
try { list = JSON.parse(unb64(m[1])); } catch { list = null; }
-- try { const u = new URL(root.location.href); u.hash = ''; root.history.replaceState(root.history.state, '', u.href.replace(/#$/, '')); } catch { /* */ }
++ try {
++ const u = new URL(root.location.href);
++ const keep = u.hash.slice(1).split('&').filter((part) => !/^ovo=/.test(part)).join('&');
++ u.hash = keep;
++ root.history.replaceState(root.history.state, '', u.href.replace(/#$/, ''));
++ } catch { /* */ }
} else {
try { list = JSON.parse(sessionStorage.getItem(KEY) || 'null'); } catch { list = null; }
}
@@@ -456,14 +475,18 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
const f = c.el.querySelector('.ovo-frame');
if (!f || e.source !== f.contentWindow || e.origin !== new URL(c.d.src).origin) continue;
c.muted = !!m.muted;
++ c.d.sound = !c.muted;
++ c.d.playing = !!m.playing;
const b = c.el.querySelector('[data-ovo="sound"]');
if (b) { b.innerHTML = c.muted ? ICON.muted : ICON.sound; b.setAttribute('aria-label', c.muted ? 'Turn the sound on' : 'Mute'); b.title = b.getAttribute('aria-label'); }
if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });
const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;
++ remember();
++ changed('state', { ...c.d });
}
});
root.addEventListener('resize', () => { for (const c of cards.values()) settle(c, c.corner, clampW(c.w)); });
-- root.OVOverlay = { open, close, list, declare, handoffUrl, restore, safeSrc, _travelling: travelling };
++ root.OVOverlay = { open, close, list, current, declare, handoffUrl, restore, safeSrc, _travelling: travelling };
// After the page's own start-up (its scripts may set OVOverlayHere on DOMContentLoaded).
const later = () => setTimeout(restore, 0);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', later, { once: true }); else later();
ddiff --git a/package-lock.json b/package-lock.json
iindex 8d28e15..d42c1b0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.205.0",
++ "version": "4.206.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.205.0",
++ "version": "4.206.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 9797c3c..3101259 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.205.0",
++ "version": "4.206.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 d560d95..a5e4baa 100644
---- a/search.js
++++ b/search.js
@@@ -529,6 +529,11 @@
timerButton.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* Dock optional */ } });
doorEl.append(doorSw, doorHost, timerButton);
listEl.after(doorEl);
++ doorHost.addEventListener('openvibe:open-vibe', (e) => {
++ if (door !== 'chats' || !inSheet()) return;
++ e.preventDefault();
++ openDoor('vibe');
++ });
tabsEl = el.querySelector('.ovs-tabs');
actsEl = el.querySelector('.ovs-acts');
rtabsEl = el.querySelector('.ovs-rtabs');
@@@ -543,7 +548,7 @@
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
leaveDoor();
-- tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
++ tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); input.value = ''; render(''); listEl.scrollTop = 0;
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
});
@@@ -614,6 +619,7 @@
if (e.key === 'Enter') {
e.preventDefault();
const q = input.value.trim();
++ if (tab === 'feed' && homeLib() && !door) { render(q); return; }
if (inSheet() && door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }
if (inSheet() && door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
const it = items[active >= 0 ? active : 0];
@@@ -1213,7 +1219,7 @@
if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
-- b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* the Dock is optional */ } });
++ b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } close(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });
tabsEl.appendChild(b);
continue;
}
@@@ -1320,6 +1326,7 @@
const { all, mineSite, byId, shown, take, ranked, f } = basis();
const groups = [];
const wall = homeLib();
++ if (wall && input && input.value.trim()) return [{ as: 'wall', items: [], section: 'wall' }];
if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });
const live = liveGroup(); if (live) groups.push(live);
if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });
@@@ -1475,7 +1482,8 @@
const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
-- token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
++ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
++ onSearch: (q) => { input.value = q; render(q); input.focus(); },
pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
@@@ -1490,6 +1498,15 @@
el.classList.toggle('ovs-typing', !!q);
if (door === 'chats' && inSheet()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
if (door === 'vibe' && inSheet()) return;
++ if (tab === 'feed' && homeLib()) {
++ tabsEl.hidden = false;
++ input.placeholder = 'Search the feed';
++ paintTabs(); renderTab();
++ const feed = homeViews.wall && homeViews.wall.api;
++ if (feed && feed.search && (q || feed.query)) feed.search(q);
++ return;
++ }
++ input.placeholder = `Search ${here()}, or ask Vibe`;
if (q) leaveDoor();
tabsEl.hidden = !!q;
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
ddiff --git a/surfaces.js b/surfaces.js
iindex 127556f..16be4df 100644
---- a/surfaces.js
++++ b/surfaces.js
@@@ -8,6 +8,11 @@
let saved = null;
let pop = null;
let opener = null;
++ let mediaLock = false;
++ const blockBackdropScroll = (e) => {
++ const top = [...sheets].at(-1);
++ if (top && top.el && !top.el.contains(e.target)) e.preventDefault();
++ };
function lock() {
if (saved) return;
@@@ -19,13 +24,21 @@
const gap = Math.max(0, root.innerWidth - html.clientWidth);
if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;
html.style.overflow = 'hidden';
-- body.style.position = 'fixed'; body.style.top = `-${y}px`;
-- body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
++ // Moving a playing video into a fixed-position body interrupts playback on some
++ // mobile WebKit builds. Keep the media layer in its existing layout instead.
++ mediaLock = [...document.querySelectorAll('video, audio')].some((media) => !media.paused && !media.ended);
++ if (mediaLock) document.addEventListener('touchmove', blockBackdropScroll, { passive: false, capture: true });
++ else {
++ body.style.position = 'fixed'; body.style.top = `-${y}px`;
++ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
++ }
}
function unlock() {
if (!saved || sheets.size) return;
const s = saved, html = document.documentElement, body = document.body;
saved = null;
++ if (mediaLock) document.removeEventListener('touchmove', blockBackdropScroll, true);
++ mediaLock = false;
html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;
body.style.position = s.bodyPosition; body.style.top = s.bodyTop;
body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;
@@@ -62,7 +75,7 @@
};
if (head) { head.addEventListener('pointerdown', onDown); head.addEventListener('pointermove', onMove); head.addEventListener('pointerup', onUp); head.addEventListener('pointercancel', onUp); }
if (el instanceof HTMLDialogElement) el.addEventListener('close', release);
-- const api = { open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,
++ const api = { el, open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,
destroy() { release(); scroll.style.overscrollBehavior = oldOverscroll; if (head) { head.style.touchAction = oldTouch; head.removeEventListener('pointerdown', onDown); head.removeEventListener('pointermove', onMove); head.removeEventListener('pointerup', onUp); head.removeEventListener('pointercancel', onUp); } if (el instanceof HTMLDialogElement) el.removeEventListener('close', release); } };
return api;
}
ddiff --git a/test/dock-code-chrome.test.js b/test/dock-code-chrome.test.js
iindex 7a04e9e..9e590d5 100644
---- a/test/dock-code-chrome.test.js
++++ b/test/dock-code-chrome.test.js
@@@ -24,6 +24,10 @@ let up = true;
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/dock-code.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'dock-code.js'))); }
++ if (u.pathname === '/ov-window.js' || u.pathname === '/agent-session.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.pathname.slice(1)))); }
++ if (u.pathname === '/agent-session.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(root, 'agent-session.css'))); }
++ if (u.pathname === '/api/frame/codes-runs/t1') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ id: 't1', title: 'A goal bar for my sub goal', status: 'running', plan: [] })); }
++ if (u.pathname === '/api/frame/codes-runs/t1/events') { res.writeHead(200, { 'content-type': 'text/event-stream' }); return res.end('id: 1\nevent: RUN_STARTED\ndata: {"type":"RUN_STARTED","runId":"t1","threadId":"t1"}\n\nid: 2\nevent: STEP_STARTED\ndata: {"type":"STEP_STARTED","stepName":"Read files"}\n\n'); }
if (u.pathname.startsWith('/api/frame/codes-tasks')) {
let body = '';
req.on('data', (c) => { body += c; });
@@@ -65,6 +69,12 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
assert.equal(seen[0].auth, 'Bearer tok_abc', 'the read carries the person\'s token');
assert.equal(seen[0].path, '/api/frame/codes-tasks?limit=8', 'through Network\'s relay');
++ // A task opens the shared card in the one window; the full Codes link stays available.
++ await page.evaluate(`document.querySelector('.ovdc-task a.ovdc-a').click()`);
++ assert.ok(await until(`!!document.querySelector('.ovw .ovas')`), 'the agent session mounted in OVWindow');
++ assert.equal(await page.evaluate(`document.querySelector('.ovw a[href="https://openvibe.codes/tasks/t1"]') !== null`), true);
++ await page.evaluate(`OVWindow.closeAll()`);
++
// My code wants a repository; Ctrl+Enter starts the task.
await page.evaluate(`document.querySelector('[data-mode="repo"]').click()`);
const box = () => page.evaluate(`({ repo: !document.querySelector('.ovdc-repo').hidden, go: document.querySelector('.ovdc-go').disabled, ph: document.querySelector('.ovdc-text').placeholder, checked: document.querySelector('.ovdc-mode[aria-checked="true"]').textContent })`);
ddiff --git a/test/fixtures/menu-playing.mp4 b/test/fixtures/menu-playing.mp4
nnew file mode 100644
iindex 0000000..e0dbe27
BBinary files /dev/null and b/test/fixtures/menu-playing.mp4 differ
ddiff --git a/test/menu-feed-search-chrome.test.js b/test/menu-feed-search-chrome.test.js
nnew file mode 100644
iindex 0000000..36993d9
---- /dev/null
++++ b/test/menu-feed-search-chrome.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-feed-search-chrome: skipped'); process.exit(0); }
++const item = (id, title, kind, person) => ({ id, title, text: title, kind, actor: { name: person }, site: 'community', site_name: 'Community', url: `https://openvibe.community/p/${id}`, at: new Date().toISOString(), counts: {} });
++const loaded = [item('1', 'New photo of a dog', 'pic', 'Ada'), item('2', 'Clip from today', 'clip', 'Bo')];
++const server = http.createServer((req, res) => {
++ if (req.url === '/ov-home.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'))); }
++ res.writeHead(200, { 'Content-Type': 'text/html' });
++ res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><div id="feed"></div><script>
++ window.__mode = 'api'; window.__queries = []; window.__search = '';
++ const loaded = ${JSON.stringify(loaded)};
++ const J = (data, ok = true) => Promise.resolve({ ok, json: async () => data });
++ window.fetch = (url) => { const u = String(url);
++ if (u.includes('/feed/examples')) return J({ examples: [{ text: 'New photos' }, { text: 'Ada' }] });
++ if (u.includes('/feed/search')) { window.__queries.push(u); return window.__mode === 'api' ? J({ items: [${JSON.stringify(item('3', 'A matching video', 'video', 'Ada'))}], next_cursor: null }) : J({}, false); }
++ if (u.includes('/feed?')) return J({ items: loaded, next_cursor: null });
++ return J({}, false);
++ };
++ </script><script src="/ov-home.js"></script><script>
++ const target = document.getElementById('feed');
++ window.api = OpenVibeHome.feed(target, { onSearch: (q) => { window.__search = q; api.search(q); } });
++ </script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feed-search-')) });
++ const context = await browser.newContext();
++ const page = await h.openPage(browser, context, { width: 390, height: 844 });
++ const wait = async (expr) => { for (let n = 0; n < 100; n++) { if (await page.evaluate(expr)) return true; await new Promise((ok) => setTimeout(ok, 50)); } return false; };
++ try {
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 6000 });
++ assert.ok(await wait(`document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`), 'loaded cards show');
++ assert.ok(await wait(`document.querySelectorAll('.ovfd-examples button').length >= 4`), 'API and fallback examples show');
++ await page.evaluate(`api.search('Ada')`);
++ assert.ok(await wait(`document.querySelector('.ovfd-results').textContent.includes('1 result')`), 'API results show a count');
++ assert.ok(await page.evaluate(`window.__queries[0].includes('q=Ada') && document.querySelector('.ovfd-list').textContent.includes('A matching video')`), 'search calls the API and replaces the list');
++ await page.evaluate(`window.__mode = 'fallback'; api.search('Bo')`);
++ assert.ok(await wait(`document.querySelector('.ovfd-list').textContent.includes('Clip from today') && !document.querySelector('.ovfd-list').textContent.includes('dog')`), 'unavailable API filters loaded cards by person');
++ await page.evaluate(`document.querySelector('[aria-label="Clear feed search"]').click()`);
++ assert.ok(await wait(`window.__search === '' && document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`), 'clear restores the list');
++ await page.evaluate(`document.querySelector('.ovfd-examples button').click()`);
++ assert.strictEqual(await page.evaluate(`window.__search`), 'New photos', 'example chip fills the query');
++ console.log('menu-feed-search-chrome: API results, examples, clear, and loaded-card fallback ok');
++ } finally { await page.close(); await browser.close(); server.close(); }
++})().catch((e) => { console.error(e); server.close(); process.exitCode = 1; });
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 0bdcb7b..0e7abc4 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -33,6 +33,10 @@ const PULSE = { items: [
{ title: 'A new clip: the jump', url: 'https://openvibe.video/c/1', occurred_at: new Date(Date.now() - 3600e3).toISOString(), ref: { service: 'video' } },
{ title: '<img src=x onerror="window.pwned=1"> a post', url: 'https://openvibe.community/p/1', occurred_at: new Date(Date.now() - 7200e3).toISOString(), ref: { service: 'community' } },
], next_cursor: 'c2' };
++const FEED_V2 = { items: [
++ { id: 'p:1', kind: 'pic', title: 'Ada shared a new photo', text: 'A sunny sky', actor: { name: 'Ada' }, site: 'community', site_name: 'Community', url: 'https://openvibe.community/p/1', at: new Date().toISOString(), counts: {} },
++ { id: 'p:2', kind: 'clip', title: 'Bo shared a clip', text: 'A new trick', actor: { name: 'Bo' }, site: 'video', site_name: 'Video', url: 'https://openvibe.video/c/1', at: new Date().toISOString(), counts: {} },
++], next_cursor: null };
const TODAY = { people: { today: 1204, series: [800, 900, 1000, 1204] }, streams: { today: 37, series: [20, 30, 37] }, posts: { today: 210 }, videos: { today: 12 } };
const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home</title><link rel="icon" href="data:,">
@@@ -52,7 +56,13 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
if (s === 'https://openvibe.network/api/frame/attachments') return J({ attachment: { media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ0' + (window.__calls.filter((c) => c.u === s).length) } }, true, 201);
++ if (s === 'https://openvibe.media/api/v1/attachments') return J({ id: 'att_01ABCDEFGHJKMNPQRSTVWXYZ01' }, true, 201);
if (s === 'https://openvibe.network/api/frame/posts') return J({ id: 7 }, true, 201);
++ if (s === 'https://openvibe.chat/api/dm/conversations') return J({ conversations: [] });
++ if (s === 'https://openvibe.chat/api/vibe/conversations') return J({ count: 4, conversations: [{ name: 'Read https://…', last_message_at: new Date().toISOString() }] });
++ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed/examples')) return J({ examples: [{ text: 'New photos' }, { text: 'Clips from today' }, { text: 'Ada' }] });
++ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed/search')) return J({ items: ${JSON.stringify(FEED_V2.items.slice(0, 1))}, next_cursor: null });
++ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed?')) return J(${JSON.stringify(FEED_V2)});
if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
if (s.startsWith('https://')) return J({}, false, 404);
@@@ -209,27 +219,33 @@ const server = http.createServer((req, res) => {
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
-- assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
-- await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?"));
-- // Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each
-- // uploads through Network and the post names them with that text.
++ assert.ok(await until(s, `!!document.querySelector('#ovh-feed-composer.__none, #ovh-feed-composer.ovc')`), 'the one composer joins the Feed form');
++ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('#ovh-feed-composer textarea').placeholder`), "What's new, Ada?"));
await s.evaluate(`(async () => {
-- const ta = document.querySelector('.ovh-compose textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input'));
++ const ta = document.querySelector('#ovh-feed-composer textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input', { bubbles: true }));
const c = document.createElement('canvas'); c.width = 40; c.height = 30; c.getContext('2d').fillRect(0, 0, 40, 30);
const blob = await new Promise((ok) => c.toBlob(ok, 'image/png'));
-- const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File([blob], 'b.png', { type: 'image/png' }));
-- const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));
++ const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File(['PDF'], 'notes.pdf', { type: 'application/pdf' }));
++ const input = document.querySelector('#ovh-feed-composer input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true }));
})(), true`);
-- assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');
-- await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows'));
-- await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);
-- await eventually(async () => assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on'));
-- await s.evaluate(`document.querySelector('.ovh-post').click(), true`);
-- assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');
-- const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
-- assert.deepStrictEqual(sent.map((c) => c.u), ['attachments', 'attachments', 'posts'], 'each picture, then the post');
-- assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'with the person\'s token');
-- assert.deepStrictEqual(JSON.parse(sent[2].body).pictures.map((x) => x.alt), ['A dark sky', 'The same sky']);
++ assert.ok(await until(s, `document.querySelectorAll('#ovh-feed-composer .ovc-file').length === 2`), 'the picture and PDF become chips');
++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('#ovh-feed-composer').checkValidity()`), 'the picture needs a description'));
++ await s.evaluate(`(() => { const a = document.querySelector('#ovh-feed-composer .ovc-image input'); a.value = 'A dark sky'; a.dispatchEvent(new Event('input', { bubbles: true })); })()`);
++ await s.evaluate(`document.querySelector('#ovh-feed-composer .ovt-send').click(), true`);
++ const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer + .ovh-note').textContent)`);
++ const postState = await s.evaluate(`({ note: document.querySelector('#ovh-feed-composer + .ovh-note').textContent, valid: document.querySelector('#ovh-feed-composer').checkValidity(), disabled: document.querySelector('#ovh-feed-composer .ovt-send').disabled, calls: window.__calls.filter((c) => /attachments|posts/.test(c.u)).map((c) => c.u) })`);
++ assert.ok(posted, `it posts: ${JSON.stringify(postState)}`);
++ const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/v1/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u, auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
++ assert.deepStrictEqual(sent.map((c) => c.u.replace(/^https:\/\/(?:openvibe.network\/api\/frame|openvibe.media\/api\/v1)\//, '')), ['attachments', 'attachments', 'posts'], 'picture and PDF upload before the post');
++ assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'uploads and post use the person\'s token');
++ const body = JSON.parse(sent[2].body);
++ assert.strictEqual(body.pictures[0].alt, 'A dark sky');
++ assert.deepStrictEqual(body.attachments, ['att_01ABCDEFGHJKMNPQRSTVWXYZ01']);
++ assert.deepStrictEqual(await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/attachments') && c.body instanceof FormData).map((c) => c.body.get('file').name)`), ['a.png', 'notes.pdf'], 'the picture and PDF bytes use the two upload routes');
++ await s.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);
++ assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]')`), 'the Chat summary is an Open Vibe button');
++ await s.evaluate(`document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]').click()`);
++ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true'`), 'the Vibe row selects the menu Vibe tab');
// ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,
// 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button
// that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes
@@@ -257,6 +273,22 @@ const server = http.createServer((req, res) => {
const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
await s.close();
++ if (process.env.OV_CAPTURE_MENU) {
++ for (const width of [390, 1280]) {
++ const shotPage = await h.openPage(browser, context, { width, height: width === 390 ? 844 : 900, block: blocked });
++ await shotPage.goto(`${base}/?signed=1&feedv2=1`, { maxMs: 6000 });
++ await openTab(shotPage, 'feed');
++ assert.ok(await until(shotPage, `document.querySelectorAll('.ovfd-examples button').length >= 4 && document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`));
++ const feedShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
++ fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-feed-${width}.png`), Buffer.from(feedShot.data, 'base64'));
++ await shotPage.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);
++ if (width === 1280) assert.ok(await until(shotPage, `!document.querySelector('.ovs').classList.contains('is-open')`), 'desktop Chat closes the menu so the Dock is visible');
++ assert.ok(await until(shotPage, `(() => { const b = document.querySelector('.ovdk-chat[aria-label="Open Vibe"]'), d = b && b.closest('.ovdk'), r = b && b.getBoundingClientRect(); return !!d && !d.hidden && d.getAnimations({ subtree: false }).every((a) => a.playState === 'finished') && r.width > 0 && r.left < innerWidth && r.top < innerHeight && b.contains(document.elementFromPoint((r.left+r.right)/2, (r.top+r.bottom)/2)); })()`), 'Chat is painted in the capture');
++ const chatShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
++ fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-chat-${width}.png`), Buffer.from(chatShot.data, 'base64'));
++ await shotPage.close();
++ }
++ }
console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');
} finally {
await browser.close();
ddiff --git a/test/menu-media-playwright.test.js b/test/menu-media-playwright.test.js
nnew file mode 100644
iindex 0000000..ab8bc90
---- /dev/null
++++ b/test/menu-media-playwright.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++// Real mobile engines: a person starts a stream with sound, opens the network menu,
++// and the same media element continues advancing. The fixture is a short loop.
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const http = require('node:http');
++const path = require('node:path');
++let playwright;
++try { playwright = require(process.env.OV_PLAYWRIGHT || 'playwright'); } catch {
++ console.log('menu-media-playwright: skipped (set OV_PLAYWRIGHT to a Playwright package)');
++ process.exit(0);
++}
++const root = path.join(__dirname, '..');
++const fixture = path.join(__dirname, 'fixtures/menu-playing.mp4');
++const html = `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
++<button id="start">Play with sound</button><button id="menu">Open menu</button>
++<video id="stream" playsinline loop src="/media.mp4"></video><div style="height:1200px"></div>
++<script src="/surfaces.js"></script><script src="/search.js"></script>
++<script>document.querySelector('#start').onclick=()=>{const v=document.querySelector('#stream');v.muted=false;v.play()};
++document.querySelector('#menu').onclick=()=>OpenVibeSearch.open({links:[],service:'Live'});</script>`;
++const server = http.createServer((req, res) => {
++ if (req.url === '/media.mp4') { res.writeHead(200, { 'content-type': 'video/mp4', 'content-length': fs.statSync(fixture).size, 'accept-ranges': 'bytes' }); return fs.createReadStream(fixture).pipe(res); }
++ if (req.url === '/surfaces.js' || req.url === '/search.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return fs.createReadStream(path.join(root, req.url.slice(1))).pipe(res); }
++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(html);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const url = `http://127.0.0.1:${server.address().port}/`;
++ try {
++ for (const name of ['chromium', 'webkit']) {
++ const browser = await playwright[name].launch({ headless: true,
++ ...(name === 'webkit' && process.env.OV_WEBKIT_BIN ? { executablePath: process.env.OV_WEBKIT_BIN } : {}) });
++ try {
++ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
++ userAgent: name === 'webkit' ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1' : 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/130.0 Mobile Safari/537.36' });
++ const page = await context.newPage();
++ await page.goto(url);
++ await page.locator('#start').tap();
++ await page.waitForFunction(() => document.querySelector('#stream').currentTime > .2);
++ const before = await page.locator('#stream').evaluate((v) => v.currentTime);
++ await page.locator('#menu').tap();
++ await page.waitForTimeout(650);
++ const state = await page.locator('#stream').evaluate((v) => ({ time: v.currentTime, paused: v.paused, muted: v.muted, body: document.body.style.position }));
++ assert.ok(!state.paused && !state.muted && state.time > before + .25, `${name}: unmuted stream advances with menu open (${JSON.stringify(state)})`);
++ assert.equal(state.body, '', `${name}: menu leaves the playing video's body layout intact`);
++ await context.close();
++ } finally { await browser.close(); }
++ }
++ console.log('menu-media-playwright: Chromium and WebKit keep an unmuted stream playing through the menu');
++ } finally { server.close(); }
++})().catch((error) => { console.error(error); process.exitCode = 1; server.close(); });
ddiff --git a/test/overlay-chrome.test.js b/test/overlay-chrome.test.js
iindex 3ff8595..ba68544 100644
---- a/test/overlay-chrome.test.js
++++ b/test/overlay-chrome.test.js
@@@ -73,6 +73,8 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
// A link to another OpenVibe site carries it; one on this site does not.
const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
++ assert.strictEqual(await p.evaluate(`OVOverlay.current().href`), `${base}/a`, 'the active live card exposes its way back');
++ assert.match(await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements#scores')`), /#scores&ovo=/, 'handoff keeps the destination section');
const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));