openvibers/openvibe.shared Commits
4.242.0: one agent you can watch work, cards that act, a + that blooms (#526)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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) {@@@ -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; }@@@ -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);@@@ -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() {@@@ -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'sedge lines up with the words above it and an open + is that disc tinted, not the only solid shape in the row; Sendis 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] .ovcvideo: '<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] .ovcif (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);@@@ -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');@@@ -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); } }@@@ -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() {@@@ -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 iif (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 isection.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();@@@ -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": {@@@ -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();@@@ -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 ──@@@ -64,7 +64,9 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utif (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.@@@ -63,6 +63,37 @@ const event = (type, extras = {}) => { const e = new window.Event(type, { bubblemention.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; };nnew file mode 100644@@@ -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); });
@@ -1,5 +1,32 @@
@@ -1,5 +1,32 @@
# Changelog
# 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
## 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
**The composer's + is what you can add, and the feed reads like posts by people** (the owner, 11-13: "the plus button
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.241.0",
+ "version": "4.242.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -114,7 +114,7 @@
@@ -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
// 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.
// 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 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) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -361,8 +361,10 @@
@@ -361,8 +361,10 @@
function avatar(p, group) {
function avatar(p, group) {
const a = h('span', 'ovdk-av');
const a = h('span', 'ovdk-av');
const url = p && /^https:\/\//.test(String(p.avatar_url || '')) ? p.avatar_url : '';
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;
return a;
}
}
function groupAvatar(c) {
function groupAvatar(c) {
@@ -49,7 +49,7 @@
@@ -49,7 +49,7 @@
.ovk-table a { color: var(--ovk-accent); text-decoration: none; }
.ovk-table a { color: var(--ovk-accent); text-decoration: none; }
.ovk-table a:hover { text-decoration: underline; }
.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 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) {
@media (max-width: 599px) {
.ovk-table--stack { overflow: visible; }
.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; }
.ovk-table--stack .ovk-table, .ovk-table--stack tbody, .ovk-table--stack tr, .ovk-table--stack td, .ovk-table--stack tbody th { display: block; }
@@ -807,7 +807,9 @@
@@ -807,7 +807,9 @@
*/
*/
function table({ columns = [], rows = [], caption = '', label = '', empty: none = '', stack = true, id = null } = {}) {
function table({ columns = [], rows = [], caption = '', label = '', empty: none = '', stack = true, id = null } = {}) {
const cols = (columns || []).filter((c) => c && c.key != 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 primary = cols.find((c) => c.primary) || cols[0];
const cell = (c, v) => {
const cell = (c, v) => {
if (c.html) return v == null ? '' : String(v);
if (c.html) return v == null ? '' : String(v);
@@ -624,7 +624,7 @@
@@ -624,7 +624,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* 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.
// 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]}` : ''}`;
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.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -2905,6 +2905,8 @@
@@ -2905,6 +2905,8 @@
pop: popFrom,
pop: popFrom,
unpop,
unpop,
askAgent(text) { askAgent(text); },
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. */
/** Get the bell mount point for OpenVibeNotifications. */
getBellMount() {
getBellMount() {
@@ -331,8 +331,38 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -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. */
/* 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-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[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
/* 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
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. */
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
@@ -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); } }
@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
@keyframes ovc-fade-calm { from { opacity: 0; } }
@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::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 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);
.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
@@ -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"/>',
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"/>',
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 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 PLUS_DEFAULT = { tiles: ['camera', 'photo', 'file'], rows: ['paste', 'screenshot', 'camapp', 'share', 'timer', 'github'], only: false };
const CONFIG = { plus: null };
const CONFIG = { plus: null };
@@ -1094,7 +1126,7 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -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 (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')) {
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.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.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');
more.addEventListener('click', openSheet);
more.addEventListener('click', openSheet);
@@ -1392,10 +1392,12 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1392,10 +1392,12 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
return st === 'working' ? 'spark' : 'clock';
return st === 'working' ? 'spark' : 'clock';
}
}
async function actor(box, o) {
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;
let a = null;
try {
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();
if (r.ok) a = await r.json();
} catch { a = null; }
} catch { a = null; }
if (!box.isConnected) return;
if (!box.isConnected) return;
@@ -1707,7 +1709,7 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -1707,7 +1709,7 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
function faceOf(c, cls) {
function faceOf(c, cls) {
const face = h('span', cls || 'ovfd-face');
const face = h('span', cls || 'ovfd-face');
const av = c.actor && safe(c.actor.avatar);
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 if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
return face;
return face;
@@ -1846,7 +1848,7 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -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 face = h('span', 'ovfd-face');
const av = personOf(c) && safe(c.actor.avatar);
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 "?".
// 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 if (!personOf(c)) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovfd-who');
const who = h('span', 'ovfd-who');
@@ -33,6 +33,9 @@
@@ -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
* 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.
* 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
* 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,
* that. It's just like filters … your actor is just your agent … it can orchestrate to openvibe.codes, or timer, task,
* desktop, browser"):
* desktop, browser"):
@@ -491,6 +494,9 @@
@@ -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 || '')));
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');
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.'));
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); });
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(', ')}).`));
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 @@
@@ -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); }
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;
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); }
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) => {
const add = (s) => {
if (!s || !Number.isInteger(s.seq)) return;
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.
// 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 (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 (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 wasNear = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 160;
const none = thread.querySelector(':scope > .ovr-none'); if (none) none.remove();
const none = thread.querySelector(':scope > .ovr-none'); if (none) none.remove();
const n = stepNode(s);
const n = stepNode(s);
state.steps.set(s.seq, n);
state.steps.set(s.seq, n);
const after = [...state.steps.keys()].filter((k) => k > s.seq).sort((a, b) => a - b)[0];
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; });
if (wasNear) requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; });
};
};
try {
try {
@@ -604,8 +660,8 @@
@@ -604,8 +660,8 @@
if (gen !== state.gen) return 'end';
if (gen !== state.gen) return 'end';
if (event === 'step' && d && d.step) add(d.step);
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 === '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;
return null;
} });
} });
}
}
@@ -868,6 +924,25 @@ a.ovr-row { color: inherit; text-decoration: none; }
@@ -868,6 +924,25 @@ a.ovr-row { color: inherit; text-decoration: none; }
.ovr-went svg { flex: none; width: 15px; height: 15px; }
.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=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; }
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-rise { from { opacity: 0; transform: translateY(6px); } }
@keyframes ovr-shimmer { 50% { opacity: .55; } }
@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); } }
@keyframes ovr-breathe { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); } }
@@ -24,7 +24,7 @@
@@ -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.
// 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 reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
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) || '';
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.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -40,6 +40,13 @@
@@ -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 { 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-sites a:hover { border-color: var(--accent, #3b82f6); }
.ovu-acts { display: flex; gap: 8px; margin: 2px 16px 0; }
.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 { 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:first-child { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
.ovu-acts a:hover { filter: brightness(1.1); }
.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
@@ -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 (a) { a.className = ''; acts.appendChild(a); }
}
}
if (acts.childNodes.length) section.appendChild(acts);
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 = {}) {
function card(host, options = {}) {
const username = usernameOf(options.username);
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
@@ -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.style.setProperty('--ovu-h', String(hueOf(username)));
section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');
section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');
const fetcher = options.fetchImpl || ((...args) => root.fetch(...args));
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',
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 => {
.then(async response => {
if (!response.ok) throw new Error('Profile unavailable');
if (!response.ok) throw new Error('Profile unavailable');
const person = await response.json();
const person = await response.json();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -1414,7 +1414,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -1414,7 +1414,9 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (!signedInTop()) return null;
if (!signedInTop()) return null;
if (actorAt && Date.now() - actorAt < 60000) return actorInfo;
if (actorAt && Date.now() - actorAt < 60000) return actorInfo;
try {
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;
actorInfo = r.ok ? await r.json() : null;
} catch { actorInfo = null; }
} catch { actorInfo = null; }
actorAt = Date.now();
actorAt = Date.now();
@@ -1436,13 +1438,25 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -1436,13 +1438,25 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
if (!signedInTop()) return [];
if (!signedInTop()) return [];
if (activeAt && Date.now() - activeAt < 30000) return activeList;
if (activeAt && Date.now() - activeAt < 30000) return activeList;
const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
- const [a, runs] = await Promise.all([
+ const [a, runs, faces] = await Promise.all([
loadActor(),
loadActor(),
fetch(`${net}/api/frame/runs?limit=20`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${ctx.token}` } })
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(() => []),
.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 = [];
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;
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',
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',
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; }
@@ -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). */
/** 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) {
function activeRows(panel) {
const old = panel.querySelector('.ovs-active'); if (old) old.remove();
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;
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 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);
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; }
@@ -1526,6 +1541,8 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
b.addEventListener('click', () => {
b.addEventListener('click', () => {
toggleYou(false);
toggleYou(false);
if (!canDoor()) { location.href = 'https://openvibe.chat/vibe'; return; }
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');
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); };
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();
go();
@@ -160,7 +160,9 @@ const kit = require('../kit');
@@ -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('<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('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.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 ──
// ── Kit 2: charts ──
@@ -64,7 +64,9 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
@@ -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/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 (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.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: [
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_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' } },
{ 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) => {
@@ -244,7 +246,7 @@ const server = http.createServer((req, res) => {
await s.evaluate(`document.querySelector('.ovs-lead').click(), true`);
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'));
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');
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
// 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
// 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.
// button (this site's own first, as icons), one AI notice, then one row of tabs with All and Edit at its end.
@@ -63,6 +63,37 @@ const event = (type, extras = {}) => { const e = new window.Event(type, { bubble
@@ -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');
mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4, 'rebinding does not duplicate listeners');
unbind(); mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4);
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.
// Native dialog opening/closing and request disposal through the default hook.
const create = document.createElement.bind(document);
const create = document.createElement.bind(document);
document.createElement = name => { const el = create(name); if (name === 'dialog') el.showModal = () => el.setAttribute('open', ''); return el; };
document.createElement = name => { const el = create(name); if (name === 'dialog') el.showModal = () => el.setAttribute('open', ''); return el; };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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); });