openvibers/openvibe.shared Commits

4.242.0: one agent you can watch work, cards that act, a + that blooms (#526)

OP OpenVibers committed 21 min ago · c4d3743d5c · 19 files, +358 −33

* 4.240.0: one agent in the menu's Vibe, and a menu button drawn from the menu

The owner, 11-13: "all this separation of tasks and codes and chats … It's just like filters … your actor is just
your agent … it can orchestrate to openvibe.codes, or timer, task, desktop, browser"; "I tried typing test in and
just like nothing happens"; "regenerate this icon … based on what the menu actually looks like … glow".

- ov-run.js: your agent heads the list (face, state, today's spend, Pause or Resume); filters are chips over one list;
  one box starts everything and the thread opens at once with your words and your agent typing; a handed-on run says
  where it went; a failed start returns the words with why; Now shows on every filter; words carry into the shared
  composer.
- search.js: the menu's Vibe no longer lends the Dock's Chat, Agents and Code panels or its Actor card (only Now and
  the live agent view, dock.js part 'live'); Ask your agent fills the one box.
- navbar.js: the menu button is the menu, small (ask bar with your face, words and spark over the place tabs, one
  lit), with a walking light, glow, twinkle and the fold into the close mark.
- Tests: run-thread-chrome, dock-chrome (both halves).

* 4.240.0: Calm slows the menu button's light and spark (only Off stills them); navbar budget 52.6 KB; the slot shows on every filter

* WIP 4.241.0: the composer's + as tiles and rows

* 4.241.0: the feed as posts by people (who, level, name effect, Comment and Share, deal listings), chips with glyphs; version and changelog

* 4.241.0: the composer's new hash in its loaders

* wallet-chrome: measure the buttons after the opening animation settles (it failed about one run in four)

* Merge branch 'ui-4240' into ui-4241

* ov-run: quiet steps fold into one line, words said while it works wait as queued, the list counts what is working (4.242.0 work)

* The menu's stack and You's Actor card read through the Frame; your agents at work show with their faces (4.242.0 work)

* Sibling hashes for the run thread, the menu and You

* Merge main (4.240.0) into 4.242 work; a face that fails to load shows the initial (chat list, feed)

* User card: reads with the viewer's token and draws Network's actions for them (plan Y.1 A2); OpenVibeNavbar.viewerToken()

* Kit: an empty table is the designed empty card (plan Y.1 A10, rule 141)

* Kit: the empty table's wrapper keeps only its spacing; the card draws itself

* Composer: the + as its own mark (arms reach, petals peek, a spring into x, the disc lights and rings once)

* Merge remote-tracking branch 'origin/main' into ui-4242

# Conflicts:
#	navbar.js
#	ov-composer.js
#	ov-home.js

* Composer +: Calm keeps the burst (slower, a third as far, no overshoot); petals take the disc's white; a ring that fits the bar; sibling hashes

* 4.242.0: version and changelog

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 479eb91..b6e83af 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,32 @@
# Changelog
++## 4.242.0 — 2026-11-13
++
++**One agent you can watch work, cards that act, and a + that blooms** (the owner, 11-13: "it needs to use a universal
++agent system … your actor is just your agent"; "this doesn't work … typing test … nothing happens … not seamless and
++sleek"; "why don't the avatars show up properly"; "I don't really like the plus … reanalyze and make it a better one …
++way more animated, better effects").
++
++- **The run thread (ov-run.js).** Quiet tool steps fold into one line ("Ran 6 steps", open to see each). Words you
++ send while it works wait as queued messages (Send now, or they go when the turn ends). The list counts what is
++ working now.
++- **Your agents in the menu (search.js, dock.js).** The stack and You's agent card read through Network's Frame
++ (`/api/frame/actor`, `/api/frame/actor/faces`). Your agents at work show with their faces among the active rows; a tap
++ opens their tasks.
++- **Faces that fail to load show the initial** (the feed, the chat list, the Dock), never an empty circle.
++- **The user card acts (ov-user.js).** It reads with the viewer's token (`options.token`, else the bar's new
++ `OpenVibeNavbar.viewerToken()`) and draws the actions Network computed for that viewer: Message, Follow, Add friend,
++ Tip, Watch together, Mute, Block, Report and staff tools. An action calls Network, then the card reads itself again
++ (plan §Y.1 A2).
++- **An empty table is the designed empty card** (kit.js `table(…, { empty })`, rule 141; plan §Y.1 A10).
++- **The composer's + is its own mark (ov-composer.js).**
++ - At rest: a bold rounded plus in a softly lit disc (nothing moves).
++ - Under the pointer, or focused: the arms reach and four petals peek from the diagonals (the tiles it holds).
++ - Pressed: it sinks.
++ - Open: it springs a turn and a half of a quarter into an ×; the disc fills with the accent, rings once, and the petals
++ burst outward in the disc's white.
++ - Calm keeps all of it, slower, a third as far, with no overshoot. Off stills it.
++
## 4.241.0 — 2026-11-13
**The composer's + is what you can add, and the feed reads like posts by people** (the owner, 11-13: "the plus button
ddiff --git a/README.md b/README.md
iindex 13703da..e84f159 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.241.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.242.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 b462598..0cdf31f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.241.0",
++ "version": "4.242.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)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 20d8d4e..3b7163f 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -114,7 +114,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': '8a4f9f30bb39' };
++ const VIBE_REV = { 'ov-composer.js': 'ce2ed7a5d349' };
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 0d7b7a5..48e074c 100644
---- a/dock.js
++++ b/dock.js
@@@ -84,7 +84,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': 'ca316ecbc9c3', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
++ const DOCK_REV = { 'dock-vibe.js': 'd8d4ea13d12a', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
@@@ -361,8 +361,10 @@
function avatar(p, group) {
const a = h('span', 'ovdk-av');
const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
-- if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; a.append(img); }
-- else a.textContent = group ? '#' : String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
++ const first = () => String((p && (p.display_name || p.username)) || '?').trim().charAt(0).toUpperCase() || '?';
++ // A picture that does not load (a moved or deleted file) shows the initial, never an empty circle.
++ if (url && !group) { const img = h('img'); img.src = url; img.alt = ''; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; img.onerror = () => { img.remove(); a.textContent = first(); }; a.append(img); }
++ else a.textContent = group ? '#' : first();
return a;
}
function groupAvatar(c) {
ddiff --git a/kit-data.css b/kit-data.css
iindex 7991133..cce6de0 100644
---- a/kit-data.css
++++ b/kit-data.css
@@@ -49,7 +49,7 @@
.ovk-table a { color: var(--ovk-accent); text-decoration: none; }
.ovk-table a:hover { text-decoration: underline; }
.ovk-table tbody tr:hover > * { background: color-mix(in srgb, var(--ovk-panel-2) 55%, transparent); }
--.ovk-table-empty { margin: 0 0 14px; padding: 18px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }
++.ovk-table-empty { margin: 0; } /* 4.242.0: it holds the designed empty card (.ovk-empty), which draws itself */
@media (max-width: 599px) {
.ovk-table--stack { overflow: visible; }
.ovk-table--stack .ovk-table, .ovk-table--stack tbody, .ovk-table--stack tr, .ovk-table--stack td, .ovk-table--stack tbody th { display: block; }
ddiff --git a/kit.js b/kit.js
iindex 2a055a2..d38ae03 100644
---- a/kit.js
++++ b/kit.js
@@@ -807,7 +807,9 @@
*/
function table({ columns = [], rows = [], caption = '', label = '', empty: none = '', stack = true, id = null } = {}) {
const cols = (columns || []).filter((c) => c && c.key != null);
-- if (!rows || !rows.length) return none ? `<p class="ovk-table-empty">${esc(none)}</p>` : '';
++ // 4.242.0 (plan §Y.1 A10, rule 141 empty-says-what-comes): an empty table is the kit's designed empty card, not a
++ // muted line; `empty` is its words, or { title, text, action, icon }.
++ if (!rows || !rows.length) { if (!none) return ''; const spec = typeof none === 'object' ? none : { text: String(none), icon: 'inbox' }; return `<div class="ovk-table-empty">${empty(spec)}</div>`; }
const primary = cols.find((c) => c.primary) || cols[0];
const cell = (c, v) => {
if (c.html) return v == null ? '' : String(v);
ddiff --git a/navbar.js b/navbar.js
iindex 874454d..cce0207 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -624,7 +624,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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '73c7387a6ec5', 'dock.js': '9cf40600859c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '39decf9a5ce5', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '34f19495bc12', 'ov-composer.js': '8a4f9f30bb39' };
++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f14df235d61d', 'dock.js': '24539743248d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0f6074850291', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'a9008daa5af5', 'ov-composer.js': 'ce2ed7a5d349' };
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.
@@@ -2905,6 +2905,8 @@
pop: popFrom,
unpop,
askAgent(text) { askAgent(text); },
++ /** 4.242.0: the signed-in viewer's Network token for the page's own shared parts (the user card's actions), or ''. */
++ viewerToken() { const u = _config.user; return u && !u.is_anon && _config.token ? String(_config.token) : ''; },
/** Get the bell mount point for OpenVibeNotifications. */
getBellMount() {
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 39dd147..1694e1f 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -331,8 +331,38 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
/* 4.223.0: the + sheet, the mic first, the examples' fade, the round send. */
.ovc-v2, form.ovc[data-ovc-contain] { container: ovc / inline-size; }
.ovc-more[aria-expanded="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 18%, transparent); }
--.ovc-more svg { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
--.ovc-more[aria-expanded="true"] svg { transform: rotate(45deg); }
++/* 4.242.0, the + (plan §Z.1.6): a bold rounded plus in a lit disc; arms reach and petals peek under the pointer; it
++ sinks when pressed; open, it springs into a x, the disc fills and rings once, and the petals burst. */
++.ovc-more { position: relative; isolation: isolate; overflow: visible; }
++.ovc-more::before { content: ""; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: radial-gradient(120% 120% at 30% 25%, color-mix(in srgb, #fff 14%, transparent), transparent 60%); opacity: .7; pointer-events: none; transition: opacity .25s; }
++.ovc-more::after { content: ""; position: absolute; inset: -2px; border-radius: 50%; pointer-events: none; box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 55%, transparent); opacity: 0; }
++.ovc-plus { display: block; overflow: visible; }
++.ovc-plus-x path { fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; transform-box: view-box; transform-origin: 12px 12px; transition: transform .28s cubic-bezier(.3, 1.5, .4, 1); }
++.ovc-plus-x { transform-box: view-box; transform-origin: 12px 12px; transition: transform .45s cubic-bezier(.3, 1.6, .4, 1); }
++.ovc-plus-p circle { fill: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa))); transform-box: fill-box; transform-origin: center; transform: scale(0); opacity: 0; transition: transform .3s cubic-bezier(.3, 1.6, .4, 1), opacity .2s; }
++@media (hover: hover) and (pointer: fine) {
++ .ovc-more:not([aria-expanded="true"]):hover .ovc-plus-h { transform: scaleX(1.12); }
++ .ovc-more:not([aria-expanded="true"]):hover .ovc-plus-v { transform: scaleY(1.12); }
++ .ovc-more:not([aria-expanded="true"]):hover .ovc-plus-p circle { transform: scale(.75); opacity: .9; }
++ .ovc-more:hover::before { opacity: 1; }
++}
++.ovc-more:not([aria-expanded="true"]):focus-visible .ovc-plus-p circle { transform: scale(.75); opacity: .9; }
++.ovc-more:active .ovc-plus-x { transform: scale(.82); }
++.ovc-more[aria-expanded="true"] { color: var(--on-accent, #fff); background: linear-gradient(150deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 92%, #fff), var(--ovt-accent, var(--accent-strong, var(--accent, #2563eb)))) !important; box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 75%, transparent) !important; }
++.ovc-more[aria-expanded="true"] .ovc-plus-x { transform: rotate(135deg); }
++.ovc-more[aria-expanded="true"]::after { animation: ovc-plus-ring .6s ease-out; }
++.ovc-more[aria-expanded="true"] .ovc-plus-p circle { fill: currentColor; animation: ovc-plus-burst .55s cubic-bezier(.2, .8, .3, 1) both; }
++.ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(1) { --dx: -7px; --dy: -7px; } .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(2) { --dx: 7px; --dy: -7px; }
++.ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(3) { --dx: -7px; --dy: 7px; } .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(4) { --dx: 7px; --dy: 7px; }
++@keyframes ovc-plus-burst { 0% { transform: scale(1.15); opacity: 1; } 55% { opacity: .95; } 100% { transform: translate(var(--dx, 0), var(--dy, 0)) scale(.25); opacity: 0; } }
++@keyframes ovc-plus-ring { 0% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 55%, transparent); } 100% { opacity: 0; box-shadow: 0 0 0 5px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 0%, transparent); } }
++/* Calm: the petals still burst, a third as far and slower (Calm reduces; only Off removes). */
++html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle { animation-duration: .9s; }
++html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(1) { --dx: -2px; --dy: -2px; } html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(2) { --dx: 2px; --dy: -2px; }
++html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(3) { --dx: -2px; --dy: 2px; } html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(4) { --dx: 2px; --dy: 2px; }
++html[data-ov-motion=reduced] :is(.ovc-plus-x, .ovc-plus-x path) { transition-timing-function: ease-out; }
++html[data-ov-motion=off] :is(.ovc-plus-x, .ovc-plus-x path, .ovc-plus-p circle) { transition: none; animation: none !important; }
++html[data-ov-motion=off] .ovc-more::after { animation: none; }
/* 4.237.0 (the owner, 11-13: the row looked "offset"): every tool in the row is the same quiet disc, so the first one's
edge lines up with the words above it and an open + is that disc tinted, not the only solid shape in the row; Send
is the same size at the other end. */
@@@ -404,7 +434,7 @@ html[data-ov-motion=off] :is(.ovc-add-tile, .ovc-add-row, dialog.ovc-add, dialog
@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
@keyframes ovc-fade-calm { from { opacity: 0; } }
--html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { animation: none; transition: none; }
++html[data-ov-motion=off] .ovc-tray { animation: none; transition: none; }
.ovc textarea::placeholder, .ovt-composer textarea::placeholder { transition: opacity .25s ease; }
.ovc textarea.ovc-ph-swap::placeholder, .ovt-composer textarea.ovc-ph-swap::placeholder { opacity: 0; }
.ovc-v2 .ovt-send--round { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff);
@@@ -494,6 +524,8 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
video: '<rect x="3.4" y="6.4" width="12.3" height="11.3" rx="1.9"/><path d="M15.6 10.2l4.7-2.8v9.4l-4.7-2.8"/>',
chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>',
};
++ // 4.242.0: the + as its own mark (plan §Z.1.6): two arms, four petals that peek and burst, all drawn on the 24 grid.
++ const PLUS_MARK = '<svg class="ovc-plus" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><g class="ovc-plus-p"><circle cx="6.2" cy="6.2" r="1.35"/><circle cx="17.8" cy="6.2" r="1.35"/><circle cx="6.2" cy="17.8" r="1.35"/><circle cx="17.8" cy="17.8" r="1.35"/></g><g class="ovc-plus-x"><path class="ovc-plus-h" d="M5.5 12h13"/><path class="ovc-plus-v" d="M12 5.5v13"/></g></svg>';
const gsvg = (inner) => `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
const PLUS_DEFAULT = { tiles: ['camera', 'photo', 'file'], rows: ['paste', 'screenshot', 'camapp', 'share', 'timer', 'github'], only: false };
const CONFIG = { plus: null };
@@@ -1094,7 +1126,7 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
if (ev && ev.detail === 0) { const first = sheet.querySelector('.ovc-add-tile, .ovc-add-row'); if (first) first.focus(); }
}
if (tools && !form.hasAttribute('data-ovc-no-more')) {
-- more = el('button', 'ovt-btn ovc-more', svg(IC.plus));
++ more = el('button', 'ovt-btn ovc-more', PLUS_MARK);
more.type = 'button'; more.setAttribute('aria-label', 'Add: camera, photos, files and more'); more.title = 'Add';
more.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');
more.addEventListener('click', openSheet);
ddiff --git a/ov-home.js b/ov-home.js
iindex 1f64184..da5bdd6 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -1392,10 +1392,12 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
return st === 'working' ? 'spark' : 'clock';
}
async function actor(box, o) {
-- const base = String(o.actorBase || ACTOR).replace(/\/+$/, '');
++ // 4.242.0: through the Frame unless the page names Actor itself (every site's connect-src allows openvibe.network
++ // and most refuse openvibe.actor, so a direct read failed there and the card said "Set it up" to people who had one).
++ const url = o.actorBase ? `${String(o.actorBase).replace(/\/+$/, '')}/api/v1/me/actor` : o.frameBase ? `${String(o.frameBase).replace(/\/+$/, '')}/actor` : `${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/frame/actor`;
let a = null;
try {
-- const r = await fetch(`${base}/api/v1/me/actor`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${o.token}` } });
++ const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${o.token}` } });
if (r.ok) a = await r.json();
} catch { a = null; }
if (!box.isConnected) return;
@@@ -1707,7 +1709,7 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
function faceOf(c, cls) {
const face = h('span', cls || 'ovfd-face');
const av = c.actor && safe(c.actor.avatar);
-- if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
++ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; img.onerror = () => { img.remove(); face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase(); }; face.append(img); }
else if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
return face;
@@@ -1846,7 +1848,7 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
const face = h('span', 'ovfd-face');
const av = personOf(c) && safe(c.actor.avatar);
// No person behind it (the network's trending pictures, a report): the site's icon, not a "?".
-- if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
++ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; img.onerror = () => { img.remove(); face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase(); }; face.append(img); }
else if (!personOf(c)) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovfd-who');
ddiff --git a/ov-run.js b/ov-run.js
iindex 15d6bb7..cbe4562 100644
---- a/ov-run.js
++++ b/ov-run.js
@@@ -33,6 +33,9 @@
* OpenVibeRun.card(type, (data, step, ctx) => Node) a rich card for a step's `ui.type` (a site or a mod adds its
* own); an unknown type shows the step's words.
*
++ * 4.242.0: quiet steps in a row fold into "Ran 6 steps · the last one", opening to each; words said while the run works
++ * wait in the thread as queued and go at its next turn; the list says how many are working now.
++ *
* 4.240.0, one agent (the owner, 11-13: "all this separation of tasks and codes and chats … we shouldn't even have
* that. It's just like filters … your actor is just your agent … it can orchestrate to openvibe.codes, or timer, task,
* desktop, browser"):
@@@ -491,6 +494,9 @@
state.runs = state.runs.concat(extra).sort((x, y) => needs(y) - needs(x) || String(y.updated_at || '').localeCompare(String(x.updated_at || '')));
}
rows.textContent = ''; rows.removeAttribute('aria-busy');
++ // 4.242.0: how many are working right now (a coding agent's "2 running tasks"), above the rows.
++ { const busy = state.runs.filter((r) => r.status === 'running' || r.status === 'planning').length; let pill = box.querySelector('.ovr-busy');
++ if (busy) { if (!pill) { pill = el('p', 'ovr-busy'); rows.before(pill); } pill.textContent = ''; pill.append(el('i', 'ovr-pulse'), el('span', '', `${busy} working now`)); } else if (pill) pill.remove(); }
if (!state.runs.length) foot.append(empty('Nothing here yet', state.filter === 'code' ? 'Say what to build below: a page, a fix, controls for your stream. Your agent hands it to Codes.' : o.agent ? `Ask ${agentName()} anything below. It answers, sets timers and reminders, and hands coding and computer work on.` : 'Ask anything below. Chats, tasks and code you start show up here.'));
state.runs.forEach((r, i) => { const li = el('li'); li.append(row(r, i)); const acts = answerRow(r); if (acts) { li.classList.add('has-acts'); li.append(acts); } rows.append(li); });
if (Array.isArray(out.partial) && out.partial.length) foot.append(el('p', 'ovr-note', `Some of your runs did not load (${out.partial.join(', ')}).`));
@@@ -572,19 +578,69 @@
if (how.said && known && WENT[known.kind]) { const w = el('p', 'ovr-went'); w.append(svg(WENT[known.kind][0]), el('span', '', WENT[known.kind][1])); thread.append(w); }
let wait = null;
if (how.said) { const me = el('div', 'ovr-step is-you is-said'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(how.said)); me.append(b); thread.append(me); wait = typing(); thread.append(wait); }
-- composer(box, { placeholder: 'Say the next thing', onSend: async (v) => { const step = await api(`/${encodeURIComponent(ref)}/messages`, { method: 'POST', body: JSON.stringify({ text: v.text }) }); add(step); } });
++ // 4.242.0: say the next thing while it works. While the run is busy your words wait in the thread as queued
++ // and go at its next turn (a coding agent's "Queue a message"); Stop stays beside them.
++ const queue = [];
++ const sendNow = async (text, node) => {
++ const step = await api(`/${encodeURIComponent(ref)}/messages`, { method: 'POST', body: JSON.stringify({ text }) });
++ if (node) node.remove();
++ add(step);
++ };
++ const flushQueue = () => {
++ if (!queue.length || !state.run || ['running', 'planning'].includes(state.run.status)) return;
++ const next = queue.shift();
++ sendNow(next.text, next.node).catch((err) => { next.node.classList.add('is-failed'); next.node.querySelector('.ovr-queued').textContent = (err && err.message) || 'That did not send'; });
++ };
++ composer(box, { placeholder: 'Say the next thing', onSend: async (v) => {
++ if (state.run && ['running', 'planning'].includes(state.run.status)) {
++ const q = el('div', 'ovr-step is-you is-queued'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(v.text)); q.append(b, el('small', 'ovr-queued', 'Queued: it reads this after the step it is on'));
++ thread.append(q); queue.push({ text: v.text, node: q });
++ requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; });
++ return;
++ }
++ await sendNow(v.text);
++ } });
++ // 4.242.0: quiet steps in a row fold into one line ("Ran 6 steps · the last one") that opens to each step, the
++ // plain words first and the command one tap deeper.
++ const isToolRow = (x) => !!x && x.classList && x.classList.contains('is-row') && x.classList.contains('is-tool');
++ const paintGroup = (g) => {
++ if (!g) return;
++ const rows = [...g.querySelectorAll(':scope > .ovr-tools-list > .ovr-step')];
++ const running = rows.find((x) => x.classList.contains('is-running'));
++ const failed = rows.filter((x) => x.classList.contains('is-failed')).length;
++ const last = running || rows[rows.length - 1];
++ const word = (x) => ((x && x.querySelector('.ovr-row-step > span')) || {}).textContent || '';
++ g.classList.toggle('is-running', !!running);
++ g.querySelector('.ovr-tools-n').textContent = running ? `Working, step ${rows.indexOf(running) + 1} of ${rows.length}` : `Ran ${rows.length} steps`;
++ g.querySelector('.ovr-tools-last').textContent = [word(last), failed ? `${failed} did not work` : ''].filter(Boolean).join(' · ');
++ };
++ const groupWith = (n) => {
++ const prev = n.previousElementSibling;
++ if (!prev) return;
++ if (prev.classList.contains('ovr-tools')) { prev.querySelector('.ovr-tools-list').append(n); paintGroup(prev); return; }
++ if (!isToolRow(prev)) return;
++ const g = el('details', 'ovr-step ovr-tools');
++ const sm = el('summary', 'ovr-tools-h'); sm.append(svg('tool'), el('b', 'ovr-tools-n'), el('span', 'ovr-tools-last'), el('i', 'ovr-pulse'));
++ const list = el('div', 'ovr-tools-list');
++ g.append(sm, list);
++ prev.replaceWith(g); list.append(prev, n);
++ paintGroup(g);
++ };
const add = (s) => {
if (!s || !Number.isInteger(s.seq)) return;
// Your words came back as a step: the stand-in goes. Your agent's first words: the typing goes.
if (s.kind === 'message' && (s.from === 'person') && how.said) { const said = thread.querySelector(':scope > .is-said'); if (said) said.remove(); }
if (wait && !(s.kind === 'message' && s.from === 'person')) { wait.remove(); wait = null; }
-- if (state.steps.has(s.seq)) { const old = state.steps.get(s.seq); const n = stepNode(s); old.replaceWith(n); state.steps.set(s.seq, n); return; }
++ if (state.steps.has(s.seq)) { const old = state.steps.get(s.seq); const n = stepNode(s); const g = old.closest('.ovr-tools'); old.replaceWith(n); state.steps.set(s.seq, n); paintGroup(g); return; }
const wasNear = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 160;
const none = thread.querySelector(':scope > .ovr-none'); if (none) none.remove();
const n = stepNode(s);
state.steps.set(s.seq, n);
const after = [...state.steps.keys()].filter((k) => k > s.seq).sort((a, b) => a - b)[0];
-- if (after !== undefined && state.steps.get(after).parentNode === thread) thread.insertBefore(n, state.steps.get(after)); else thread.append(n);
++ if (after !== undefined && state.steps.get(after).parentNode === thread) thread.insertBefore(n, state.steps.get(after));
++ else { thread.append(n); if (isToolRow(n)) groupWith(n); }
++ // Queued words stay last, under what the agent is doing.
++ thread.querySelectorAll(':scope > .is-queued').forEach((q) => thread.append(q));
if (wasNear) requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; });
};
try {
@@@ -604,8 +660,8 @@
if (gen !== state.gen) return 'end';
if (event === 'step' && d && d.step) add(d.step);
else if (event === 'delta' && d && state.steps.has(d.seq)) { const w = state.steps.get(d.seq).querySelector('.ovr-words'); if (w) w.append(document.createTextNode(String(d.text || ''))); }
-- else if (event === 'run' && d && d.run) paintHead(d.run);
-- else if (event === 'end') { if (state.run) paintHead({ ...state.run, status: (d && d.status) || 'done' }); return 'end'; }
++ else if (event === 'run' && d && d.run) { paintHead(d.run); flushQueue(); }
++ else if (event === 'end') { if (state.run) paintHead({ ...state.run, status: (d && d.status) || 'done' }); flushQueue(); return 'end'; }
return null;
} });
}
@@@ -868,6 +924,25 @@ a.ovr-row { color: inherit; text-decoration: none; }
.ovr-went svg { flex: none; width: 15px; height: 15px; }
html[data-ov-motion=reduced] .ovr-typing i, html[data-ov-motion=reduced] .ovr-face.ovr-me.is-working { animation-duration: 3s; }
html[data-ov-motion=off] :is(.ovr-agent, .ovr-typing, .ovr-typing i, .ovr-went, .ovr-step.is-said, .ovr-face.ovr-me, .ovr-face.ovr-me::after) { animation: none !important; }
++/* 4.242.0: quiet steps fold into one line; words said while it works wait as queued; what is working now, counted. */
++.ovr-tools { align-self: stretch; max-width: 100%; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); }
++.ovr-tools-h { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 4px 12px; border-radius: 14px; cursor: pointer; list-style: none; font-size: 13.5px; color: var(--text-secondary, #a8b3c4); }
++.ovr-tools-h::-webkit-details-marker { display: none; }
++.ovr-tools-h > svg { flex: none; width: 16px; height: 16px; color: var(--text-muted, #93a0b4); }
++.ovr-tools-n { flex: none; font-weight: 750; color: var(--text-primary, #e6edf7); }
++.ovr-tools-last { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
++.ovr-tools-h > .ovr-pulse { margin-left: auto; }
++.ovr-tools:not(.is-running) .ovr-tools-h > .ovr-pulse { display: none; }
++.ovr-tools-h::after { content: ""; flex: none; width: 8px; height: 8px; margin-left: auto; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; transition: transform .2s; }
++.ovr-tools.is-running .ovr-tools-h::after { margin-left: 6px; }
++.ovr-tools[open] > .ovr-tools-h::after { transform: rotate(225deg); }
++.ovr-tools-list { display: grid; gap: 2px; padding: 0 6px 8px 22px; border-left: 2px solid color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); margin-left: 18px; animation: ovr-rise .2s ease both; }
++.ovr-tools-h:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovr-step.is-queued { opacity: .75; }
++.ovr-step.is-queued .ovr-bubble { border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: transparent; }
++.ovr-queued { display: block; margin-top: 4px; text-align: right; font-size: 12px; color: var(--text-muted, #93a0b4); }
++.ovr-step.is-queued.is-failed .ovr-queued { color: #f87171; }
++.ovr-busy { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; width: auto; margin: 2px 0 0; padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 700; color: var(--accent-light, #93c5fd); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
@keyframes ovr-rise { from { opacity: 0; transform: translateY(6px); } }
@keyframes ovr-shimmer { 50% { opacity: .55; } }
@keyframes ovr-breathe { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); } }
ddiff --git a/ov-thread.js b/ov-thread.js
iindex a6213ed..31432c4 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': '8a4f9f30bb39', 'ov-combo.js': '8001a3b19c2f' };
++ const THREAD_REV = { 'ov-composer.js': 'ce2ed7a5d349', 'ov-combo.js': '8001a3b19c2f' };
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/ov-user.js b/ov-user.js
iindex 34063d1..4c464f5 100644
---- a/ov-user.js
++++ b/ov-user.js
@@@ -40,6 +40,13 @@
.ovu-sites a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13px; font-weight: 600; }
.ovu-sites a:hover { border-color: var(--accent, #3b82f6); }
.ovu-acts { display: flex; gap: 8px; margin: 2px 16px 0; }
++.ovu-more { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 16px; }
++.ovu-more a, .ovu-more button { all: unset; box-sizing: border-box; display: inline-grid; place-items: center; min-height: 40px; padding: 0 14px; border-radius: 999px; cursor: pointer; font-size: 13.5px; font-weight: 650; color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
++.ovu-more .is-main { color: var(--on-accent, #fff); background: var(--accent, #3b82f6); }
++.ovu-more .is-quiet { color: var(--text-secondary, #a8b3c4); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 14%, transparent); }
++.ovu-more .is-danger { color: #fca5a5; }
++.ovu-more button:disabled { opacity: .55; cursor: default; }
++.ovu-more :is(a, button):focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.ovu-acts a { flex: 1; display: grid; place-items: center; min-height: 42px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: inherit; font-weight: 650; text-decoration: none; }
.ovu-acts a:first-child { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
.ovu-acts a:hover { filter: brightness(1.1); }
@@@ -137,6 +144,23 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i
if (a) { a.className = ''; acts.appendChild(a); }
}
if (acts.childNodes.length) section.appendChild(acts);
++ // 4.242.0 (plan §Y.1 A2): what this viewer can do with this person, as Network computed it for their token:
++ // Message, Follow, Add friend, Tip, Watch together, Mute, Block, Report, and staff tools by capability. A link
++ // opens; an action calls Network with the viewer's token, then the card reads itself again for the new state.
++ const more = Array.isArray(person.actions) ? person.actions.filter((x) => x && x.id && x.label && !['profile', 'channel'].includes(x.id)).slice(0, 14) : [];
++ if (!more.length || !section.__ovuAct) return;
++ const row = element(doc, 'div', undefined, 'ovu-more'); row.setAttribute('role', 'group'); row.setAttribute('aria-label', 'Actions');
++ const MAIN = new Set(['follow', 'add-friend']), QUIET = new Set(['unfollow', 'unmute', 'unblock', 'unfriend', 'cancel-request', 'mute']), DANGER = new Set(['block', 'report']);
++ for (const x of more) {
++ const cls = MAIN.has(x.id) ? 'is-main' : DANGER.has(x.id) ? 'is-quiet is-danger' : QUIET.has(x.id) ? 'is-quiet' : '';
++ if (x.href) { const a = link(doc, String(x.label).slice(0, 40), x.href); if (a) { a.className = cls; row.appendChild(a); } continue; }
++ if (!/^(PUT|POST|DELETE)$/.test(String(x.method || '')) || !/^\/api\/v1\/[A-Za-z0-9_\/%.-]{1,200}$/.test(String(x.path || ''))) continue;
++ const b = element(doc, 'button', String(x.label).slice(0, 40), cls); b.type = 'button';
++ if (x.id === 'report' && x.state && x.state.reported) { b.textContent = 'Reported'; b.disabled = true; }
++ b.addEventListener('click', () => section.__ovuAct(x, b));
++ row.appendChild(b);
++ }
++ if (row.childNodes.length) section.appendChild(row);
}
function card(host, options = {}) {
const username = usernameOf(options.username);
@@@ -163,9 +187,21 @@ html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i
section.style.setProperty('--ovu-h', String(hueOf(username)));
section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');
const fetcher = options.fetchImpl || ((...args) => root.fetch(...args));
-- const url = `${String(options.networkUrl || NETWORK).replace(/\/+$/, '')}/api/v1/people/${encodeURIComponent(username)}/card`;
++ const base = String(options.networkUrl || NETWORK).replace(/\/+$/, '');
++ const url = `${base}/api/v1/people/${encodeURIComponent(username)}/card`;
++ // 4.242.0: with the viewer's token (the page's, or the bar's), so Network can say what they may do.
++ const tok = (() => { try { const t = typeof options.token === 'function' ? options.token() : options.token; if (t) return String(t); const N = root.OpenVibeNavbar; return N && typeof N.viewerToken === 'function' ? N.viewerToken() : ''; } catch { return ''; } })();
++ const auth = tok ? { Authorization: `Bearer ${tok}` } : {};
++ section.__ovuAct = tok ? async (x, button) => {
++ if (button) button.disabled = true;
++ try {
++ const r = await fetcher(`${base}${x.path}`, { method: x.method, credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, x.body ? { 'Content-Type': 'application/json' } : {}, auth), body: x.body ? JSON.stringify(x.body) : undefined });
++ if (!r.ok) throw new Error(String(r.status));
++ api.refresh();
++ } catch { if (button) { button.disabled = false; button.textContent = 'Try again'; } }
++ } : null;
api.ready = Promise.resolve().then(() => fetcher(url, { credentials: 'omit', cache: 'no-store',
-- headers: { Accept: 'application/json' }, signal: request.signal }))
++ headers: Object.assign({ Accept: 'application/json' }, auth), signal: request.signal }))
.then(async response => {
if (!response.ok) throw new Error('Profile unavailable');
const person = await response.json();
ddiff --git a/package.json b/package.json
iindex 7a9c6ce..2677407 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.241.0",
++ "version": "4.242.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 314e4d3..317a78d 100644
---- a/search.js
++++ b/search.js
@@@ -1414,7 +1414,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (!signedInTop()) return null;
if (actorAt && Date.now() - actorAt < 60000) return actorInfo;
try {
-- const r = await fetch(`${String(ctx.actorBase || ACTOR).replace(/\/+$/, '')}/api/v1/me/actor`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${ctx.token}` } });
++ // 4.242.0: through the Frame (every site's connect-src allows openvibe.network, most refuse openvibe.actor).
++ const url = ctx.actorBase ? `${String(ctx.actorBase).replace(/\/+$/, '')}/api/v1/me/actor` : `${ctx.runsBase || ctx.apiBase || 'https://openvibe.network'}/api/frame/actor`;
++ const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${ctx.token}` } });
actorInfo = r.ok ? await r.json() : null;
} catch { actorInfo = null; }
actorAt = Date.now();
@@@ -1436,13 +1438,25 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (!signedInTop()) return [];
if (activeAt && Date.now() - activeAt < 30000) return activeList;
const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
-- const [a, runs] = await Promise.all([
++ const [a, runs, faces] = await Promise.all([
loadActor(),
fetch(`${net}/api/frame/runs?limit=20`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${ctx.token}` } })
.then((r) => (r.ok ? r.json() : { runs: [] })).then((d) => (Array.isArray(d.runs) ? d.runs : [])).catch(() => []),
++ // 4.242.0: your agents (Experts) with their faces, while they work or ask you something.
++ fetch(`${net}/api/frame/actor/faces`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${ctx.token}` } })
++ .then((r) => (r.ok ? r.json() : { agents: [] })).then((d) => (Array.isArray(d.agents) ? d.agents : [])).catch(() => []),
]);
const out = [];
-- if (a && a.id && a.enabled !== false) {
++ for (const ag of faces.slice(0, 12)) {
++ if (!ag || !ag.id || !['working', 'asks'].includes(ag.state)) continue;
++ out.push({ key: `agent:${ag.id}`, kind: 'agent', name: String(ag.name || 'Your agent').slice(0, 40), state: ag.state,
++ pic: /^https:\/\//.test(String(ag.avatar_url || '')) ? ag.avatar_url : '', hue: Number.isInteger(ag.hue) ? ag.hue : faceHue(ag.name || ag.id),
++ glyph: KIND_SVG[ag.glyph] ? ag.glyph : 'spark',
++ line: ag.state === 'asks' ? (ag.asks > 1 ? `${ag.asks} things for you` : 'It asks you something') : String(ag.step || 'Working').slice(0, 120) });
++ }
++ // Your Actor's own agent is already among the faces: once, with its face.
++ const shownAgent = (x) => out.some((f) => f.kind === 'agent' && ((x.agent_id && f.key === `agent:${x.agent_id}`) || f.name === String(x.name || '')));
++ if (a && a.id && a.enabled !== false && !shownAgent(a)) {
const asks = Array.isArray(a.asks) ? a.asks.length : 0;
if (asks || a.status === 'working') out.push({ key: `actor:${a.id}`, kind: 'actor', name: a.name || 'Your Actor', state: asks ? 'asks' : 'working',
pic: a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '', hue: faceHue(a.name || a.id), glyph: 'task',
@@@ -1510,7 +1524,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
/** The rows of what is running, in your sheet: a tap opens that run in Vibe (your Actor has its own card above). */
function activeRows(panel) {
const old = panel.querySelector('.ovs-active'); if (old) old.remove();
-- const runs = activeList.filter((x) => x.kind === 'run');
++ // 4.242.0: your agents at work (with their faces) and the runs that go or wait for you.
++ const runs = activeList.filter((x) => x.kind === 'run' || x.kind === 'agent');
if (!runs.length) return;
const box = document.createElement('div'); box.className = 'ovs-active'; box.setAttribute('role', 'group'); box.setAttribute('aria-label', 'Running for you');
const h = document.createElement('p'); h.className = 'ovs-active-h'; h.textContent = 'Running for you'; box.appendChild(h);
@@@ -1526,6 +1541,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
b.addEventListener('click', () => {
toggleYou(false);
if (!canDoor()) { location.href = 'https://openvibe.chat/vibe'; return; }
++ // An agent opens its work (Tasks); a run opens its thread.
++ if (it.kind === 'agent') { runFilter = 'task'; openDoor('vibe'); return; }
openDoor('vibe');
const go = (k = 0) => { if (runView && typeof runView.open === 'function') { try { runView.open(it.ref); } catch { /* the list stands */ } return; } if (k < 80) setTimeout(() => go(k + 1), 50); };
go();
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex e9b7815..739bbba 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -160,7 +160,9 @@ const kit = require('../kit');
assert.ok(t.includes('<a href="https://hetzner.com">hetzner.com</a>') && !t.includes('javascript:'), 'links are checked');
assert.ok(t.includes('class="is-hide-sm"') && t.includes('<code>x</code>'), 'html columns pass trusted markup');
assert.ok(t.includes('data-label="Per month">–</td>'), 'a missing number is a dash');
-- assert.strictEqual(kit.table({ columns: [{ key: 'a', label: 'A' }], rows: [], empty: 'Nothing yet' }), '<p class="ovk-table-empty">Nothing yet</p>');
++ // 4.242.0 (plan Y.1 A10): an empty table is the designed empty card (rule 141), with its words as text.
++ { const e = kit.table({ columns: [{ key: 'a', label: 'A' }], rows: [], empty: 'Nothing yet' });
++ assert.ok(e.startsWith('<div class="ovk-table-empty"><div class="ovk-empty">') && e.includes('<p>Nothing yet</p>') && e.includes('<svg'), 'an empty table is the empty card'); }
}
// ── Kit 2: charts ──
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex e483a4b..a9d5268 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -64,7 +64,9 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
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: [] });
++ // 4.242.0: your Actor and your agents' faces come through the Frame (every site's connect-src allows openvibe.network).
++ if (s === 'https://openvibe.network/api/frame/actor/faces') return J({ agents: [{ id: 'agt_jun', name: 'Juniper', avatar_url: null, hue: 258, glyph: 'spark', state: 'working', step: 'Reading job posts', asks: 0 }] });
++ if (s.startsWith('https://openvibe.network/api/frame/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
if (s === 'https://openvibe.network/api/frame/runs?limit=20') return J({ runs: [
{ id: 'run_1', ref: 'run_1', kind: 'code', title: 'Fix the navbar', status: 'running', doing: { text: 'Running the tests' }, agent: { id: 'agt_code', name: 'Codes' } },
{ id: 'run_2', ref: 'run_2', kind: 'task', title: 'Book the venue', status: 'waiting', waiting: { for: 'approval' }, agent: { id: 'agt_plan', name: 'Planner' } },
@@@ -244,7 +246,7 @@ const server = http.createServer((req, res) => {
await s.evaluate(`document.querySelector('.ovs-lead').click(), true`);
await eventually(async () => assert.match(await s.evaluate(`(document.querySelector('.ovs-youpanel:not([hidden]) .ovh-actor') || {}).textContent || ''`), /^Actor.*Working.*Juniper · Reading job posts/, 'your Actor\'s card is in your sheet'));
assert.strictEqual(await s.evaluate(`document.querySelector('.ovs-lead').getAttribute('aria-expanded')`), 'true');
-- await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovs-youpanel .ovs-active-row')].map((r) => r.querySelector('b').textContent + ': ' + r.querySelector('.ovs-active-st').textContent)`), ['Book the venue: Your turn', 'Fix the navbar: Working'], 'your sheet lists what is running'));
++ await eventually(async () => assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovs-youpanel .ovs-active-row')].map((r) => r.querySelector('b').textContent + ': ' + r.querySelector('.ovs-active-st').textContent)`), ['Book the venue: Your turn', 'Juniper: Working', 'Fix the navbar: Working'], 'your sheet lists what is running'));
// 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … move the notifications into a
// tab … put the apps more front facing … the first tab"; "waste of space"; "compact"): your row holds every round
// button (this site's own first, as icons), one AI notice, then one row of tabs with All and Edit at its end.
ddiff --git a/test/ov-user.test.js b/test/ov-user.test.js
iindex fed8a46..b09ef8f 100644
---- a/test/ov-user.test.js
++++ b/test/ov-user.test.js
@@@ -63,6 +63,37 @@ const event = (type, extras = {}) => { const e = new window.Event(type, { bubble
mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4, 'rebinding does not duplicate listeners');
unbind(); mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4);
++ // 4.242.0 (plan §Y.1 A2): with the viewer's token the card reads with it and draws Network's actions for them; an
++ // action calls Network with the token and the card reads itself again.
++ {
++ const sent = [];
++ body = Object.assign({}, person, { actions: [
++ { id: 'profile', label: 'Profile', href: person.profile_url },
++ { id: 'message', label: 'Message', href: 'https://openvibe.chat/messages' },
++ { id: 'follow', label: 'Follow', method: 'PUT', path: '/api/v1/me/follows/channel/Example', state: { following: false } },
++ { id: 'block', label: 'Block', method: 'PUT', path: '/api/v1/me/blocks/Example', state: { blocked: false } },
++ { id: 'evil', label: 'Evil', method: 'PUT', path: 'https://evil.example/x' },
++ ] });
++ const withToken = async (url, options) => { sent.push({ url, options }); return { ok: true, json: async () => body }; };
++ const mine = OVUser.card(host, { username: 'Example', fetchImpl: withToken, token: () => 'tok_viewer' });
++ await mine.ready;
++ assert.strictEqual(sent[0].options.headers.Authorization, 'Bearer tok_viewer', 'the card reads with the viewer token');
++ const labels = [...host.querySelectorAll('.ovu-more a, .ovu-more button')].map((b) => b.textContent);
++ assert.deepStrictEqual(labels, ['Message', 'Follow', 'Block'], 'Network\'s actions, not a foreign address');
++ assert.ok(host.querySelector('.ovu-more button').className.includes('is-main'), 'Follow leads');
++ host.querySelector('.ovu-more button').dispatchEvent(event('click'));
++ await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r));
++ const put = sent.find((c) => c.options && c.options.method === 'PUT');
++ assert.ok(put && put.url === 'https://openvibe.network/api/v1/me/follows/channel/Example' && put.options.headers.Authorization === 'Bearer tok_viewer', 'the action goes to Network with the token');
++ assert.ok(sent.filter((c) => /\/card$/.test(c.url)).length >= 2, 'then the card reads itself again');
++ mine.destroy();
++ const anonymous = OVUser.card(host, { username: 'Example', fetchImpl });
++ await anonymous.ready;
++ assert.strictEqual(host.querySelector('.ovu-more'), null, 'no token, no actions');
++ anonymous.destroy();
++ body = person;
++ }
++
// Native dialog opening/closing and request disposal through the default hook.
const create = document.createElement.bind(document);
document.createElement = name => { const el = create(name); if (name === 'dialog') el.showModal = () => el.setAttribute('open', ''); return el; };
ddiff --git a/test/run-steps-queue-chrome.test.js b/test/run-steps-queue-chrome.test.js
nnew file mode 100644
iindex 0000000..a281999
---- /dev/null
++++ b/test/run-steps-queue-chrome.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++// The run thread like a coding agent's (ov-run.js 4.242.0) in a real headless Chrome, against a stand-in for the Frame's
++// Run API: quiet steps in a row fold into one line ("Working, step 3 of 3" while one runs; "Ran 3 steps" after) that
++// opens to each step; words said while the run works wait as queued and go when it is the person's turn; the list says
++// how many are working now. The owner, 11-13, showing Claude Code's phone view: "Ran 6 commands", "Queue a message…",
++// "2 running tasks": "our system should be able to do this as well, but even more comprehensively".
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('run-steps-queue-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const T = '2026-11-13T09:00:00Z';
++const RUN = { id: 'k_1', ref: 'codes:k_1', kind: 'code', title: 'Fix the overlay', status: 'running', doing: { text: 'Running the tests', glyph: 'code' }, agent: { id: 'codes', name: 'Codes', glyph: 'code' }, updated_at: T };
++const STEPS = [
++ { seq: 1, kind: 'message', from: 'person', text: 'Fix the overlay' },
++ { seq: 2, kind: 'tool', title: 'Read the overlay code', status: 'done', text: 'cat overlay.js' },
++ { seq: 3, kind: 'tool', title: 'Changed two lines', status: 'done', text: 'apply_patch' },
++ { seq: 4, kind: 'tool', title: 'Running the tests', status: 'running' },
++];
++const posts = [];
++let streamTurn = 0;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/ov-run.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-run.js'))); }
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div id="host" style="height:100vh"></div><script src="/ov-run.js"></script>'); }
++ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
++ const rest = decodeURIComponent(u.pathname.slice('/api/frame/runs'.length));
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ if (rest === '' && req.method === 'GET') return json(200, { runs: [RUN, { ...RUN, id: 'k_2', ref: 'codes:k_2', title: 'Add a timer' }, { ...RUN, id: 'c_9', ref: 'chat:c_9', kind: 'chat', title: 'Ideas', status: 'waiting', waiting: { for: 'message' } }], next: null, partial: [] });
++ if (rest === '/codes:k_1') return json(200, RUN);
++ if (rest === '/codes:k_1/steps') return json(200, { steps: STEPS, next: null });
++ if (rest === '/codes:k_1/messages' && req.method === 'POST') { posts.push(JSON.parse(body).text); return json(201, { seq: 9, kind: 'message', from: 'person', text: JSON.parse(body).text }); }
++ if (rest === '/codes:k_1/events') {
++ streamTurn += 1;
++ res.writeHead(200, { 'content-type': 'text/event-stream' });
++ if (streamTurn === 1) {
++ // The tests finish; a moment later the run waits for the person.
++ setTimeout(() => res.write(`id: 4\nevent: step\ndata: ${JSON.stringify({ type: 'step', step: { ...STEPS[3], status: 'done', title: 'Ran the tests: 12 passed' } })}\n\n`), 900);
++ setTimeout(() => res.write(`id: 5\nevent: step\ndata: ${JSON.stringify({ type: 'step', step: { seq: 5, kind: 'message', from: 'agent', text: 'Fixed, and the tests pass.' } })}\n\n`), 1000);
++ setTimeout(() => { res.write(`id: 5\nevent: run\ndata: ${JSON.stringify({ type: 'run', run: { ...RUN, status: 'waiting', waiting: { for: 'message' } } })}\n\n`); res.end(); }, 1100);
++ return;
++ }
++ return setTimeout(() => res.end(), 2000);
++ }
++ json(404, { error: 'No such run' });
++ });
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-run-steps-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 820 });
++ await page.goto(`${base}/`, { maxMs: 6000 });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };
++ await page.evaluate(`window.view = OpenVibeRun.mount(document.getElementById('host'), { base: '${base}/api/frame', token: () => 'tok_1', me: { name: 'Sam' } })`);
++
++ // The list says how many are working now.
++ assert.equal(await until(`(document.querySelector('.ovr-busy') || {}).textContent`), '2 working now', 'the list counts what is working');
++
++ // Three quiet steps in a row are one line while one runs.
++ await page.evaluate(`view.open('codes:k_1')`);
++ const g = await until(`(() => { const g = document.querySelector('.ovr-tools'); return g ? { n: g.querySelector('.ovr-tools-n').textContent, last: g.querySelector('.ovr-tools-last').textContent, rows: g.querySelectorAll('.ovr-tools-list > .ovr-step').length, running: g.classList.contains('is-running'), loose: document.querySelectorAll('.ovr-thread > .ovr-step.is-row.is-tool').length } : null; })()`);
++ assert.deepEqual(g, { n: 'Working, step 3 of 3', last: 'Running the tests', rows: 3, running: true, loose: 0 }, 'one line for the three steps, saying which runs');
++ assert.equal(await page.evaluate(`document.querySelector('.ovr-tools').open`), false, 'folded until opened');
++
++ // Words said while it works wait as queued.
++ await page.evaluate(`(() => { const ta = document.querySelector('.ovr-compose textarea'); ta.value = 'Also add a test'; document.querySelector('.ovr-compose form').requestSubmit(); })()`);
++ assert.ok(await until(`!!document.querySelector('.ovr-step.is-queued')`), 'the words wait as queued');
++ assert.deepEqual(posts, [], 'nothing is sent while it works');
++ assert.match(await page.evaluate(`document.querySelector('.ovr-queued').textContent`), /Queued/);
++
++ // The step finishes, the run waits for the person: the line says Ran 3 steps, and the queued words go.
++ assert.ok(await until(`(document.querySelector('.ovr-tools-n') || {}).textContent === 'Ran 3 steps'`), 'Ran 3 steps once the last one is done');
++ assert.ok(await until(`window.__sent || (document.querySelectorAll('.ovr-step.is-queued').length === 0 && [...document.querySelectorAll('.ovr-step.is-you')].some((s) => s.textContent === 'Also add a test'))`), 'the queued words were sent and joined the thread');
++ assert.deepEqual(posts, ['Also add a test'], 'sent once, at its turn');
++
++ // The line opens to each step, plain words first.
++ await page.evaluate(`document.querySelector('.ovr-tools-h').click()`);
++ assert.deepEqual(await page.evaluate(`[...document.querySelectorAll('.ovr-tools-list .ovr-row-step > span')].map((s) => s.textContent)`), ['Read the overlay code', 'Changed two lines', 'Ran the tests: 12 passed']);
++ assert.equal(await page.evaluate('document.documentElement.scrollWidth'), 390, 'nothing wider than the phone');
++ console.log('run-steps-queue-chrome: quiet steps fold into one line, queued words wait for the turn, what is working is counted');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });