openvibers/openvibe.live Commits

Channel and chat polish: the PiP's handle, one emote button, a reply in the box, the AI ticker, glyphs in tiles; openvibe-shared 4.235.0 (#877)

OP OpenVibers committed 1 h ago · 662bb5b6a6 · 46 files, +267 −217

The owner's phone, watching Maticus's stream, 11-13 (ask 371).
- PiP (openvibe-shared 4.235.0 ov-float):
  - tucked, it folds into a small handle at the edge that slides along it and pulls out;
  - it grows to the window's width (past the player it floats); the corner grip is the engine's;
  - popped out into the browser's own window, the box in the page steps aside until that window closes.
- The ⋯ menu is the network's layer (OVSurfaces.popover, anchored in the top layer); Live's own placement is gone.
- The chat box holds what goes into a message:
  - Live's emote picker is the box's emoji button;
  - a GIF and "Add an emote or sound" are in its + sheet;
  - the row under the box keeps the channel's actions (sound, coins, tips); nothing is there twice.
  - A reply is said inside the box (the composer's reply()), not in a bar above it.
- The global chat's AI line is a ticker: one sentence at a time, a long one panning to its end, still under a pointer;
  a read older than 12 hours steps aside.
- A section's glyph sits inside its tile (62%, it was drawn to the tile's edges); AI wears the sparkles, not a wand.
- Checked by chat-box-and-ticker, chat-sheet-fill, pip-camera-slot and golive-setup.

Changes

ddiff --git a/README.md b/README.md
iindex 2639ba8c..a742fda0 100644
---- a/README.md
++++ b/README.md
@@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
- openvibe-contracts: v0.419.0
- openvibe-sdk: v0.161.0
--- openvibe-shared: v4.233.0
++- openvibe-shared: v4.235.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 94e9f7bf..13875d9b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.419.0",
"sdk": "openvibe-sdk v0.161.0",
-- "shared": "openvibe-shared v4.233.0",
++ "shared": "openvibe-shared v4.235.0",
"capabilities": [
"live.chat_context.read",
"live.chat_effects.write",
ddiff --git a/package-lock.json b/package-lock.json
iindex 3ecdc018..3512bd10 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -3417,9 +3417,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.233.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
-- "integrity": "sha512-kaNnKiQfMIVB5SasCeJX03Z+N/T3n/3Qklh4Be9EdCFz3PjAjUrPTqXOAgy1WMj2iuFXEODy5CLhonN7eLIOHw==",
++ "version": "4.235.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",
++ "integrity": "sha512-szSdbilNQWBPuZAUinpmtInKtyalGDzhy+Lso3f8uJ/wiJgR0msDL5E5gvcnajExWdygK48QWdycj9FGIUYSng==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex c4ee6f48..534995a4 100644
---- a/package.json
++++ b/package.json
@@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex f5400d0c..465195f0 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -33,7 +33,7 @@
".btn-primary",
".btn-small",
".card-ai-clamp",
-- ".card-ai-clamp i.fa-wand-magic-sparkles",
++ ".card-ai-clamp i.fa-sparkles",
".card-ai-overview",
".card-ai-static",
".card-ai-toggle",
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 0e3ae1c5..129e57d7 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1926,14 +1926,8 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }
#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }
#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }
--/* Open, it is in the top layer and placed on the screen by app-channel.js (a phone's live layout clipped it). */
--#page-channel .ch-more-menu.is-layer { position: fixed; inset: auto; margin: 0; right: auto; max-height: calc(100dvh - 16px); overflow-y: auto; color: var(--text-primary);
-- transform-origin: top right; animation: chMoreIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
--#page-channel .ch-more-menu.is-layer.is-above { transform-origin: bottom right; }
--@keyframes chMoreIn { from { opacity: 0; transform: translateY(-4px) scale(.96); } }
--#page-channel .ch-more-menu.is-layer.is-above { animation-name: chMoreUp; }
--@keyframes chMoreUp { from { opacity: 0; transform: translateY(4px) scale(.96); } }
--html[data-ov-motion="off"] #page-channel .ch-more-menu.is-layer { animation: none; }
++/* Open, the network's layer anchors it to its button in the top layer (OVSurfaces, app-channel.js). */
++#page-channel .ch-more-menu[data-ov-anchored] { color: var(--text-primary); }
#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }
.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }
.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }
@@@ -2252,6 +2246,9 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
#page-channel .ch-sec-ic { display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 9px; font-size: .9rem;
color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); }
#page-channel svg.ch-sec-ic { padding: 7px; box-sizing: border-box; }
++/* The glyph inside the tile, not the tile: an OV glyph draws its picture at 100% of its element, and here the element
++ is the tile, so every glyph ran to the tile's edges (ask 371, 11-13: "a tiny bit of padding"). 62% of it. */
++#page-channel .ch-sec-ic > svg { width: 62%; height: 62%; }
/* A part of a section (AI Moments in Clips, About's latest videos and popular clips): the same tile, a size down. */
#page-channel .ch-sub-h { display: flex; align-items: center; gap: 9px; margin: 22px 0 6px; font-size: 1rem; font-weight: 800; line-height: 1.2; letter-spacing: -.005em; text-transform: none; color: var(--text-primary); }
#page-channel .ch-sub-h > .ch-sec-ic { width: 26px; height: 26px; border-radius: 8px; font-size: .78rem; }
ddiff --git a/public/css/features/chatpage.css b/public/css/features/chatpage.css
iindex 135fc0b0..6dd13acd 100644
---- a/public/css/features/chatpage.css
++++ b/public/css/features/chatpage.css
@@@ -16,6 +16,16 @@
}
.global-ai-strip:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 20%, transparent), transparent); }
.gai-strip-caret { flex-shrink: 0; opacity: 0.6; transition: transform var(--ov-dur-2, 0.2s); }
++/* The ticker (app-chatpage.js): a sentence rolls up into place; one longer than the strip pans to its end, then holds. */
++.global-ai-strip .gai-strip-text { position: relative; text-overflow: clip; }
++.global-ai-strip .gai-t { display: inline-block; white-space: nowrap; }
++.global-ai-strip .gai-strip-text.gai-roll .gai-t { animation: gaiRoll var(--ov-dur-3, .42s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
++@keyframes gaiRoll { from { opacity: 0; transform: translateY(70%); } }
++.global-ai-strip .gai-strip-text.gai-pan .gai-t { animation: gaiPan var(--gai-pan-s, 4s) ease-in-out 1.4s both; }
++.global-ai-strip .gai-strip-text.gai-roll.gai-pan .gai-t { animation: gaiRoll var(--ov-dur-3, .42s) var(--ov-ease-out, ease-out), gaiPan var(--gai-pan-s, 4s) ease-in-out 1.6s both; }
++@keyframes gaiPan { to { transform: translateX(var(--gai-shift, 0)); } }
++.global-ai-strip:not(.open) .gai-strip-text { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
++html[data-ov-motion="off"] .global-ai-strip .gai-t { animation: none !important; }
.global-ai-strip.open .gai-strip-caret { transform: rotate(180deg); }
.global-ai-panel {
ddiff --git a/public/css/features/pip.css b/public/css/features/pip.css
iindex a3f8b89d..e058b607 100644
---- a/public/css/features/pip.css
++++ b/public/css/features/pip.css
@@@ -2,8 +2,9 @@
One stylesheet for every PIP (2026-11-11, owner: "move it anywhere … more solid feeling and more responsive … use the
OV animations"). A PIP sits in the player (absolute, its place in fractions of the player) or over the page
(.is-floating: fixed, fractions of the window, above the page and below the navbar's menus); while it is moved it is
-- lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side, only its
-- tab stays on screen (the `translate` property; `transform` is the landing's). Its waits are the mark at work. */
++ lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side it folds
++ into a small handle at the edge (openvibe-shared 4.235.0's clip; the handle is .ov-pip-tab). Its waits are the mark
++ at work. */
.ov-pip {
container: ovpip / inline-size;
@@@ -21,8 +22,10 @@
-webkit-tap-highlight-color: transparent;
contain: layout paint;
transition: box-shadow var(--ov-dur-2, .15s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease),
-- translate var(--ov-dur-3, .3s) var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1)), opacity var(--ov-dur-2, .2s);
++ opacity var(--ov-dur-2, .2s), visibility 0s;
}
++/* Out of the browser in its own window: the box in the page steps aside until that window closes. */
++.ov-pip.is-popped { opacity: 0; visibility: hidden; pointer-events: none; transition: opacity var(--ov-dur-2, .2s), visibility 0s linear var(--ov-dur-2, .2s); }
.ov-pip.is-floating { position: fixed; z-index: 900; }
.ov-pip.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
.ov-pip:hover { border-color: rgba(255, 255, 255, .3); }
@@@ -102,20 +105,29 @@
.ov-pip-state-t { max-width: 100%; overflow-wrap: anywhere; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
.ov-pip[data-state="offline"] .ov-pip-state-t { color: color-mix(in srgb, #fbbf24 70%, #fff); }
--/* Tucked into a side: only the tab and an edge of its picture stay on screen; a tap brings it back. */
--.ov-pip.is-tucked-left { translate: calc(-100% + 30px) 0; }
--.ov-pip.is-tucked-right { translate: calc(100% - 30px) 0; }
--.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state-t { opacity: 0; pointer-events: none; }
--.ov-pip.is-tucked .ov-pip-video { filter: brightness(.75); }
++/* Tucked into a side: it folds into a small handle at the edge (the engine's clip), its picture still going behind the
++ glass; a tap opens it, a drag up or down slides it along the side, a pull brings it out. */
++.ov-pip.is-tucked { border-color: transparent; }
++.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state { opacity: 0; pointer-events: none; }
.ov-pip-tab {
-- position: absolute; top: 0; bottom: 0; z-index: 4; width: 30px; display: none; place-items: center; padding: 0; cursor: pointer;
-- border: 0; color: #fff; font-size: .85rem;
-- background: linear-gradient(90deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25));
-- -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
++ position: absolute; top: 50%; z-index: 4; display: none; place-items: center; padding: 0; cursor: pointer; touch-action: none;
++ width: var(--ov-float-tab-w, 30px); height: min(var(--ov-float-tab-h, 64px), 100%); translate: 0 -50%;
++ border: 0; color: #fff; font-size: .9rem;
++ background: color-mix(in srgb, rgba(10, 12, 18, .58) 82%, var(--accent, #3b82f6));
++ -webkit-backdrop-filter: blur(10px) saturate(1.25); backdrop-filter: blur(10px) saturate(1.25);
}
--.ov-pip.is-tucked .ov-pip-tab { display: grid; }
--.ov-pip.is-tucked-left .ov-pip-tab { right: 0; }
--.ov-pip.is-tucked-right .ov-pip-tab { left: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25)); }
++.ov-pip.is-tucked .ov-pip-tab { display: grid; animation: ovPipTabIn var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out); }
++@keyframes ovPipTabIn { from { opacity: 0; } }
++.ov-pip.is-tucked-left .ov-pip-tab { left: 0; border-radius: 0 12px 12px 0; box-shadow: inset -1px 0 0 rgba(255, 255, 255, .22); }
++.ov-pip.is-tucked-right .ov-pip-tab { right: 0; border-radius: 12px 0 0 12px; box-shadow: inset 1px 0 0 rgba(255, 255, 255, .22); }
++.ov-pip-tab:hover i, .ov-pip-tab:focus-visible i { transform: scale(1.18); }
++.ov-pip-tab i { transition: transform var(--ov-dur-1, .12s) var(--ov-ease-spring, ease); }
++/* Its live dot: the picture is still going behind the handle. */
++.ov-pip-tab::after {
++ content: ""; position: absolute; top: 9px; left: 50%; width: 6px; height: 6px; margin-left: -3px; border-radius: 50%;
++ background: #ef4444; box-shadow: 0 0 0 2px rgba(239, 68, 68, .25);
++}
++.ov-pip:not([data-state="live"]) .ov-pip-tab::after { background: rgba(255, 255, 255, .5); box-shadow: none; }
.ov-pip-tab-l, .ov-pip-tab-r { display: none; }
.ov-pip.is-tucked-right .ov-pip-tab-l, .ov-pip.is-tucked-left .ov-pip-tab-r { display: grid; place-items: center; }
@@@ -143,6 +155,9 @@
.ov-pip { border-radius: 12px; }
}
++/* While it moves, the glass under its buttons is plain (a blur repainted every frame made a phone's drag stutter). */
++.ov-pip.is-lifted .ov-pip-actions button, .ov-pip.is-lifted .ov-pip-state { -webkit-backdrop-filter: none; backdrop-filter: none; }
++
/* Motion off: no spring and no fade, it is simply where it goes. */
html[data-ov-motion="off"] .ov-pip, html[data-ov-motion="off"] .ov-pip-bar { transition: none; animation: none; }
@media (prefers-reduced-motion: reduce) {
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 39095a46..0c1456a7 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -4729,7 +4729,7 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
}
.gai-strip-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gai-panel-head h3 { margin: 0; font-size: 1.05rem; display: flex; align-items: center; gap: 8px; }
--.gai-panel-head h3 .fa-wand-magic-sparkles { color: var(--accent); }
++.gai-panel-head h3 .fa-sparkles { color: var(--accent); }
.gai-col h4 { margin: 0 0 10px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
/* Timeline browser: head (title + search), period chips, scrollable list with day dividers. */
.gai-tl-head h4 { margin: 0; }
@@@ -9098,7 +9098,7 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card-ai-overview { position: relative; }
--.card-ai-clamp i.fa-wand-magic-sparkles { opacity: 0.85; margin-right: 2px; }
++.card-ai-clamp i.fa-sparkles { opacity: 0.85; margin-right: 2px; }
/* Reserve space at the end so the chevron never overlaps the text. */
.card-ai-clamp { padding-right: 16px; }
.card-ai-overview.expanded .card-ai-clamp { -webkit-line-clamp: unset; overflow: visible; }
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 8ab88d4d..70621543 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -1157,12 +1157,6 @@ WantedBy=multi-user.target</code>
</button>
</div>
<div class="chat-tools-row">
-- <button class="emote-picker-btn" onclick="toggleEmotePicker('bc-chat-input')" title="Emotes">
-- <i class="fa-solid fa-face-smile"></i>
-- </button>
-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('bc-chat-input')" title="GIFs">
-- <span class="gif-btn-text">GIF</span>
-- </button>
<button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<i class="fa-solid fa-volume-high"></i>
</button>
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 98f66ac9..c491dd47 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -244,15 +244,6 @@
</button>
</div>
<div class="chat-tools-row">
-- <button class="emote-picker-btn" onclick="toggleEmotePicker('chat-input')" title="Emotes">
-- <i class="fa-solid fa-face-smile"></i>
-- </button>
-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('chat-input')" title="GIFs">
-- <span class="gif-btn-text">GIF</span>
-- </button>
-- <button class="emote-picker-btn" onclick="openChannelUploadModal()" title="Add channel emote / sound">
-- <i class="fa-solid fa-plus"></i>
-- </button>
<button class="emote-picker-btn chat-limits-btn" style="display:none" onclick="toggleChatLimitsPopover(this)" title="Chat limits (message + TTS length)">
<i class="fa-solid fa-sliders"></i>
</button>
@@@ -369,15 +360,6 @@
</button>
</div>
<div class="chat-tools-row">
-- <button class="emote-picker-btn" onclick="toggleEmotePicker('offline-chat-input')" title="Emotes">
-- <i class="fa-solid fa-face-smile"></i>
-- </button>
-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('offline-chat-input')" title="GIFs">
-- <span class="gif-btn-text">GIF</span>
-- </button>
-- <button class="emote-picker-btn" onclick="openChannelUploadModal()" title="Add channel emote / sound">
-- <i class="fa-solid fa-plus"></i>
-- </button>
<button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<i class="fa-solid fa-volume-high"></i>
</button>
@@@ -457,7 +439,7 @@
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->
<div class="ch-ai-clips" id="ch-ai-clips-section" hidden>
-- <h3 class="ch-sub-h"><i class="fa-solid fa-wand-magic-sparkles ch-sec-ic" aria-hidden="true"></i>AI Moments</h3>
++ <h3 class="ch-sub-h"><i class="fa-solid fa-sparkles ch-sec-ic" aria-hidden="true"></i>AI Moments</h3>
<p class="ch-panel-hint">Clips OpenVibe's AI cut automatically when chat reacted. No one clipped these.</p>
<div class="stream-grid" id="ch-ai-clips-grid"></div>
<div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>
ddiff --git a/public/fragments/chat.html b/public/fragments/chat.html
iindex d4b016b5..ca989442 100644
---- a/public/fragments/chat.html
++++ b/public/fragments/chat.html
@@@ -124,7 +124,7 @@
</div>
</div>
<div class="global-ai-strip" id="global-ai-strip" style="display:none" onclick="toggleGlobalAiPanel()" title="AI read of the chat — click for the full insight &amp; timeline">
-- <span class="gai-badge"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</span>
++ <span class="gai-badge"><i class="fa-solid fa-sparkles"></i> AI</span>
<span class="gai-strip-text" id="global-ai-strip-text"></span>
<i class="fa-solid fa-chevron-down gai-strip-caret" id="global-ai-strip-caret"></i>
</div>
@@@ -155,12 +155,6 @@
</button>
</div>
<div class="chat-tools-row">
-- <button class="emote-picker-btn" onclick="toggleEmotePicker('global-chat-input')" title="Emotes">
-- <i class="fa-solid fa-face-smile"></i>
-- </button>
-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('global-chat-input')" title="GIFs">
-- <span class="gif-btn-text">GIF</span>
-- </button>
</div>
</div>
</div>
@@@ -169,7 +163,7 @@
<!-- ── Global Chat AI insight + timeline (below the layout) ── -->
<section class="global-ai-panel" id="global-ai-panel" style="display:none">
<div class="gai-panel-head">
-- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> Global Chat &mdash; AI Insight</h3>
++ <h3><i class="fa-solid fa-sparkles"></i> Global Chat &mdash; AI Insight</h3>
<span class="gai-meta" id="global-ai-meta"></span>
<button class="gai-collapse-btn" onclick="toggleGlobalAiPanel()" title="Collapse"><i class="fa-solid fa-chevron-up"></i></button>
</div>
ddiff --git a/public/fragments/content.html b/public/fragments/content.html
iindex 2945047c..0f91baf8 100644
---- a/public/fragments/content.html
++++ b/public/fragments/content.html
@@@ -5,7 +5,7 @@
<p class="cf-sub">Streams, clips and pastes made by people on OpenVibe.</p>
</div>
<div class="cf-head-actions">
-- <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> AI Moments</a>
++ <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-sparkles" aria-hidden="true"></i> AI Moments</a>
<button type="button" class="btn btn-small btn-outline cf-new-paste" data-cf-new-paste hidden><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</button>
</div>
</header>
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex c5566e98..d4558567 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -26,7 +26,7 @@
<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button>
<button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button>
<button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>
-- <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button>
++ <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button>
<button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button>
</div>
<div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div>
@@@ -134,7 +134,7 @@
</label>
<ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul>
<p class="dash-el-actions">
-- <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Make one with AI</a>
++ <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-sparkles" aria-hidden="true"></i> Make one with AI</a>
<a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a>
<a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a>
</p>
@@@ -193,7 +193,7 @@
<!-- Vibe on stream (server/ai/vibe-voice.js): the streamer's call word wakes OpenVibe's assistant -->
<div class="dash-card dash-card-wide" id="dash-card-vibe">
-- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> Vibe on stream <span class="muted">your call word</span></h3>
++ <h3><i class="fa-solid fa-sparkles"></i> Vibe on stream <span class="muted">your call word</span></h3>
<p class="muted" style="font-size:0.9rem">Say your call word on stream and Vibe, OpenVibe's assistant, jumps in. <b>"<span data-vibe-word>hey vibe</span>, clip that"</b> clips the last 45 seconds and posts the link. Anything else, like <b>"<span data-vibe-word>hey vibe</span>, what was the score last night?"</b>, gets an answer in your chat.</p>
<form class="vibe-form" id="dash-vibe-form">
<label class="vibe-switch"><input type="checkbox" name="enabled"> <span>Listen for my call word</span></label>
@@@ -333,7 +333,7 @@
Clips
</label>
<button class="btn btn-small btn-danger" onclick="dashBulkActionByAge()">
-- <i class="fa-solid fa-wand-magic-sparkles"></i> Apply
++ <i class="fa-solid fa-sparkles"></i> Apply
</button>
</div>
<div id="dash-vods-list" class="vod-grid"></div>
@@@ -365,7 +365,7 @@
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
-- <h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>
++ <h3 style="margin:0 0 6px"><i class="fa-solid fa-sparkles"></i> AI Moments of My Stream</h3>
<p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home &rarr; AI Moments from my streams.</p>
<div id="dash-ai-clips" class="vod-grid"></div>
</div>
@@@ -499,7 +499,7 @@
<!-- AI Moments from my streams: the channel's AI derivation switch (roadmap 33.7) -->
<div class="dash-card dash-card-wide" id="dash-card-ai-derivation">
-- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments from my streams</h3>
++ <h3><i class="fa-solid fa-sparkles"></i> AI Moments from my streams</h3>
<label class="staff-inline-toggle" style="margin-bottom:8px">
<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
<span>Let OpenVibe's AI make Moments from my streams</span>
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 93ec591d..af77543a 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -3,7 +3,7 @@
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.5rem">
<div>
-- <h1 style="margin-bottom:0.25rem"><i class="fa-solid fa-wand-magic-sparkles"></i> OpenVibe.Live API Docs</h1>
++ <h1 style="margin-bottom:0.25rem"><i class="fa-solid fa-sparkles"></i> OpenVibe.Live API Docs</h1>
<p class="text-muted" style="margin:0">Modular docs for vibe coders. Copy just the section you need, or grab everything.</p>
</div>
<div style="display:flex;gap:0.5rem;flex-shrink:0">
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 6a2687ee..b7ce144e 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -26,12 +26,12 @@
<a href="https://openvibe.games" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:3" class="hb hb-vibe"
data-ovtip-title="Mod it, bot it, build on it"
-- data-ovtip-icon="fa-wand-magic-sparkles"
++ data-ovtip-icon="fa-sparkles"
data-ovtip-accent="#f472b6"
data-ovtip-place="top"
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"
-- data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">&amp; Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">&amp; Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:4" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:5" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</nav>
@@@ -294,7 +294,7 @@
<span>Set funding goals with custom art, celebrate live in chat, and keep a real creator economy — with fair, transparent payouts.</span>
</div>
<div class="home-cta-feature">
-- <i class="fa-solid fa-wand-magic-sparkles"></i>
++ <i class="fa-solid fa-sparkles"></i>
<strong>AI clips, VODs &amp; transcripts</strong>
<span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
</div>
@@@ -360,9 +360,9 @@
<p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>
<h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="55">55</span> sites.</h2>
<p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>
-- <nav class="ovnet-jump" data-ovk-scroll aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams &amp; places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games &amp; laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News &amp; answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>Make &amp; build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop &amp; swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People &amp; you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>
++ <nav class="ovnet-jump" data-ovk-scroll aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams &amp; places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games &amp; laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News &amp; answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-sparkles" aria-hidden="true"></i>Make &amp; build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop &amp; swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People &amp; you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>
</header>
-- <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams &amp; places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live&#39;s cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games &amp; laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet&#39;s meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News &amp; answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Make &amp; build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop &amp; swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People &amp; you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>
++ <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams &amp; places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live&#39;s cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games &amp; laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet&#39;s meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News &amp; answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Make &amp; build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop &amp; swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People &amp; you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>
<footer class="ovnet-foot">
<a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
<button type="button" class="ovnet-switch" data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb" viewBox="0 0 24 24" width="22" height="22"><rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/><rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/></svg></span><span class="ovnet-switch-t"><b>Every site is in the menu</b><small>This button, at the top of every page</small></span><i class="ovnet-switch-go" aria-hidden="true"></i></button>
ddiff --git a/public/fragments/moments.html b/public/fragments/moments.html
iindex bb0a6e8f..1833d082 100644
---- a/public/fragments/moments.html
++++ b/public/fragments/moments.html
@@@ -1,7 +1,7 @@
<div class="cf-wrap cf-wrap--ai" data-feed="moments">
<header class="cf-head">
<div class="cf-head-text">
-- <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI Moments</h1>
++ <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-sparkles"></i></span> AI Moments</h1>
<p class="cf-sub">Auto-clips, standout moments and after-show recaps the AI made from OpenVibe streams. Everything here is AI-generated.</p>
</div>
<div class="cf-head-actions">
ddiff --git a/public/js/ai-viewers.js b/public/js/ai-viewers.js
iindex 15582733..ffd3638b 100644
---- a/public/js/ai-viewers.js
++++ b/public/js/ai-viewers.js
@@@ -128,7 +128,7 @@
<div class="aiv-senses" id="aiv-senses"></div>
${GROUPS.map((g, i) => `<details class="aiv-group" ${i === 0 ? 'open' : ''}><summary><i class="fa-solid ${g.icon}"></i> ${g.title}</summary><div class="aiv-group-body">${g.keys.filter(k => byKey[k]).map(k => fieldHtml(byKey[k], s[k])).join('')}</div></details>`).join('')}
<div class="ai-bot-actions">
-- <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview what they'd say now</button>
++ <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-sparkles"></i> Preview what they'd say now</button>
<span class="muted" id="aiv-save-state" style="font-size:0.8rem"></span>
</div>
<div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>`;
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 7e59d7fa..20cb0866 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -763,7 +763,7 @@ function _channelPasteCardHTML(p, canManage) {
<div class="ch-paste-info">
<div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>
<div class="ch-paste-meta muted"><span>${timeAgo(p.created_at)}</span> · <span><i class="fa-solid fa-eye"></i> ${p.views || 0}</span></div>
-- ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(p.ai_summary)}</div>` : '')}
++ ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-sparkles"></i> ${esc(p.ai_summary)}</div>` : '')}
</div>
</a>`;
}
@@@ -2975,7 +2975,7 @@ async function loadChannelAiTimeline(username) {
const hasBothOverviews = !!(streamerOv && chatOverall);
const topText = hasBothOverviews ? (combined || `${streamerOv}\n\n${chatOverall}`) : '';
const header = topText
-- ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-wand-magic-sparkles"></i> Overall AI overview <span class="ai-tl-ov-sub">as a streamer &amp; chatter</span></div>${_collapsibleOverview(topText)}</div>`
++ ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-sparkles"></i> Overall AI overview <span class="ai-tl-ov-sub">as a streamer &amp; chatter</span></div>${_collapsibleOverview(topText)}</div>`
: '';
// ── As a streamer (inner content, no label — the sub-tab / side-label supplies it) ──
@@@ -3498,7 +3498,7 @@ function _aboutAiEffectiveShow() {
function _aboutAiOverviewHTML() {
if (!_aboutAiEffectiveShow()) return '';
return `<section class="ch-about-sec about-ai-overview" aria-label="AI summary">
-- <h3 class="ch-about-h ch-sub-h"><i class="fa-solid fa-wand-magic-sparkles ch-sec-ic" aria-hidden="true"></i>AI summary <span class="ch-about-h-note">from past streams</span></h3>
++ <h3 class="ch-about-h ch-sub-h"><i class="fa-solid fa-sparkles ch-sec-ic" aria-hidden="true"></i>AI summary <span class="ch-about-h-note">from past streams</span></h3>
${_collapsibleOverview(_aboutAiOverview)}
</section>`;
}
@@@ -3952,7 +3952,7 @@ function _renderAboutEdit() {
<div class="ch-about-ai-toggle">
<label class="ch-about-mods-toggle" title="Show the AI-generated overview at the top of your About tab">
<input type="checkbox" id="ch-about-ai-overview-toggle" ${_aboutAiEffectiveShow() ? 'checked' : ''} onchange="_aboutAiPref=this.checked?'show':'hide';_markAboutDirty()">
-- <span><i class="fa-solid fa-wand-magic-sparkles"></i> Show AI overview</span>
++ <span><i class="fa-solid fa-sparkles"></i> Show AI overview</span>
</label>
<span class="muted ch-about-ai-hint">An AI-written summary of your streams. Shown automatically until you add a bio; turn it on here to keep showing it, or off to hide it.</span>
</div>` : ''}
@@@ -4555,55 +4555,20 @@ async function offerLiveAlerts(name) {
}
/**
-- * The ⋯ menu opens in the top layer, placed on the screen (ask 371, 2026-11-13: "the little three dots doesn't actually
-- * open a menu, at least not on mobile"): on a phone the live layout clips what hangs out of it, so the menu opened
-- * unseen. Below its button, or above it when there is no room; inside the visible screen; again on a scroll or a resize;
-- * a press elsewhere or Escape closes it. Without the Popover API it is fixed, which a clipping ancestor still allows.
++ * The ⋯ menu is the network's layer (openvibe-shared 4.235.0 OVSurfaces): anchored to its button in the top layer,
++ * placed inside the visible screen, above the button when there is no room below, again on a scroll or a resize; one
++ * open at a time, a press elsewhere or Escape closes it (ask 371: on a phone the live layout clipped it).
*/
function _wireChannelMoreLayer(menu) {
if (menu.dataset.layer) return;
menu.dataset.layer = '1';
const pop = menu.querySelector('.ch-more-menu');
const summary = menu.querySelector('summary');
-- if (!pop || !summary) return;
-- const place = () => {
-- if (!menu.open) return;
-- const s = summary.getBoundingClientRect();
-- const vv = window.visualViewport;
-- const ox = vv ? vv.offsetLeft : 0, oy = vv ? vv.offsetTop : 0;
-- const vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight;
-- const w = pop.offsetWidth, h = pop.offsetHeight, m = 8, gap = 6;
-- const left = Math.min(Math.max(ox + m, s.right - w), ox + vw - w - m);
-- let top = s.bottom + gap;
-- const above = top + h > oy + vh - m;
-- if (above) top = s.top - h - gap >= oy + m ? s.top - h - gap : Math.max(oy + m, oy + vh - h - m);
-- pop.style.left = `${Math.round(left)}px`;
-- pop.style.top = `${Math.round(top)}px`;
-- pop.classList.toggle('is-above', above);
-- };
-- const onDown = (e) => { if (!menu.contains(e.target)) menu.open = false; };
-- const onKey = (e) => { if (e.key === 'Escape') { menu.open = false; summary.focus(); } };
++ let surface = null;
menu.addEventListener('toggle', () => {
-- const topLayer = typeof pop.showPopover === 'function';
-- if (!menu.open) {
-- if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* closed */ } pop.removeAttribute('popover'); }
-- pop.classList.remove('is-layer', 'is-above');
-- pop.style.left = pop.style.top = '';
-- document.removeEventListener('pointerdown', onDown, true);
-- document.removeEventListener('keydown', onKey, true);
-- window.removeEventListener('scroll', place, true);
-- window.removeEventListener('resize', place);
-- window.visualViewport?.removeEventListener('resize', place);
-- return;
-- }
-- pop.classList.add('is-layer');
-- if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }
-- place();
-- document.addEventListener('pointerdown', onDown, true);
-- document.addEventListener('keydown', onKey, true);
-- window.addEventListener('scroll', place, { capture: true, passive: true });
-- window.addEventListener('resize', place);
-- window.visualViewport?.addEventListener('resize', place);
++ if (!window.OVSurfaces || !pop || !summary) return;
++ if (!surface) surface = window.OVSurfaces.popover({ el: menu, trigger: summary, pop, place: 'bottom-end', close: () => { menu.open = false; } });
++ if (menu.open) surface.open(); else surface.close();
});
}
ddiff --git a/public/js/app-chatpage.js b/public/js/app-chatpage.js
iindex e3ed8833..414c2092 100644
---- a/public/js/app-chatpage.js
++++ b/public/js/app-chatpage.js
@@@ -21,7 +21,11 @@ async function loadGlobalChatAi() {
if (!strip || !panel) return;
let insight = null;
try { insight = (await api('/chat/ai/global')).insight; } catch { /* silent */ }
++ // A read older than 12 hours is not "now": the strip steps aside (the panel keeps the notes and the timeline). The
++ // owner, 11-13: Global's line was three days old.
++ if (insight && insight.updated_at && _gaiAgeMs(insight.updated_at) > 12 * 3600e3) insight = { ...insight, overview: '' };
if (!insight || !insight.overview) {
++ _gaiTicker(null, '');
strip.style.display = 'none';
// Keep the panel only if the user had already opened it.
if (!panel.classList.contains('gai-user-opened')) panel.style.display = 'none';
@@@ -29,8 +33,8 @@ async function loadGlobalChatAi() {
}
// Header strip (compact)
const stripText = document.getElementById('global-ai-strip-text');
-- if (stripText) stripText.textContent = insight.overview;
strip.style.display = 'flex';
++ if (stripText) _gaiTicker(stripText, insight.overview);
// Full panel
const ov = document.getElementById('global-ai-overview');
@@@ -50,6 +54,45 @@ async function loadGlobalChatAi() {
if (mem && !insight.memory) { mem.innerHTML = '<span class="gai-empty">Still building a picture of the community…</span>'; }
}
++/** How old a read is; Chat's times are UTC with no zone ("2026-11-10 08:38:23"). */
++function _gaiAgeMs(ts) {
++ const s = String(ts || '');
++ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);
++ return Number.isFinite(t) ? Date.now() - t : 0;
++}
++
++/**
++ * The strip reads like a ticker (the owner, 11-13: "make it scroll … scrolls through the summary … click to expand"):
++ * one sentence at a time, each rolling in every five seconds; one longer than the strip pans to its end first. It
++ * waits while a pointer is over it, while the panel is open and while the tab is hidden. With motion off the words
++ * change in place.
++ */
++let _gaiTick = { timer: 0, parts: [], i: 0 };
++function _gaiTicker(el, text) {
++ clearInterval(_gaiTick.timer);
++ const parts = (String(text || '').match(/[^.!?…]+[.!?…]+["”’)]*|[^.!?…]+$/g) || []).map((x) => x.trim()).filter(Boolean).slice(0, 4);
++ _gaiTick = { timer: 0, parts, i: 0 };
++ if (!el) return;
++ const show = (i, roll) => {
++ let t = el.querySelector('.gai-t');
++ if (!t) { t = document.createElement('span'); t.className = 'gai-t'; el.replaceChildren(t); }
++ t.textContent = parts[i] || '';
++ el.classList.remove('gai-roll', 'gai-pan');
++ void el.offsetWidth;
++ if (roll) el.classList.add('gai-roll');
++ const over = t.scrollWidth - el.clientWidth;
++ if (over > 4) { el.style.setProperty('--gai-shift', `${-over - 8}px`); el.style.setProperty('--gai-pan-s', `${Math.min(9, 2 + over / 40)}s`); el.classList.add('gai-pan'); }
++ };
++ show(0, false);
++ if (parts.length < 2 && !el.classList.contains('gai-pan')) return;
++ _gaiTick.timer = setInterval(() => {
++ if (document.hidden || !el.isConnected || el.matches(':hover') || el.closest('.global-ai-strip.open')) return;
++ if (parts.length < 2) { show(0, false); return; }
++ _gaiTick.i = (_gaiTick.i + 1) % parts.length;
++ show(_gaiTick.i, true);
++ }, 5200 + (el.classList.contains('gai-pan') ? 2000 : 0));
++}
++
// ── Global chat AI timeline browser (search + period jump + infinite scroll) ─────────────
let _gaiTlInited = false;
let _gaiTl = { q: '', since: null, periodBefore: null, oldestTs: 0, loading: false, done: false, lastDay: null };
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex a69842cb..02cc1f76 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -2453,12 +2453,12 @@ function _renderHomeStar(data) {
} else if (bioSrc) {
bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;
}
-- const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
++ const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
const pick = s.pick || null;
// Who picked them and when the next star lands are two separate facts, each kept on one line:
// as one uppercase run the countdown broke in half ("NEXT STAR / IN 3H") on a phone.
const why = pick && (pick.headline || pick.reason)
-- ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`
++ ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`
: '';
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 5637d276..ce759a49 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -117,7 +117,7 @@ function _cardAiHTML(text, full) {
// -webkit-line-clamp overflow can't clip it away; it sits at the bottom-right.
const showToggle = hasMore || display.length > 100;
return `<div class="card-ai-overview${showToggle ? '' : ' card-ai-static'}" role="button" tabindex="0" onclick="toggleCardAi(event,this)" onkeydown="if(event.key==='Enter'||event.key===' ')toggleCardAi(event,this)"${hasMore ? ` data-full="${esc(long)}"` : ''}>
-- <div class="card-ai-clamp"><i class="fa-solid fa-wand-magic-sparkles"></i> <span class="card-ai-text">${esc(display)}</span></div>
++ <div class="card-ai-clamp"><i class="fa-solid fa-sparkles"></i> <span class="card-ai-text">${esc(display)}</span></div>
${showToggle ? '<i class="card-ai-toggle fa-solid fa-chevron-down" aria-label="Toggle overview"></i>' : ''}
</div>`;
}
@@@ -1487,7 +1487,7 @@ async function _openChatInsightModal(opts) {
<div class="user-ai-modal">
<div class="user-ai-modal-head">
<h3><i class="fa-solid ${opts.iconClass || 'fa-user-tag'}"></i> ${esc(opts.title || 'User')}</h3>
-- <span class="gai-badge"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</span>
++ <span class="gai-badge"><i class="fa-solid fa-sparkles"></i> AI</span>
<button class="uai-close" onclick="document.getElementById('user-ai-modal-overlay').remove()"><i class="fa-solid fa-xmark"></i></button>
</div>
<div id="user-ai-modal-body"><div class="gai-empty">Loading insight…</div></div>
@@@ -1523,7 +1523,7 @@ async function _openChatInsightModal(opts) {
html += `<div class="uai-group-label"><i class="fa-solid fa-tower-broadcast"></i> As a streamer</div>`;
html += `<p class="uai-sub uai-group-sub">Who they are as a streamer — from the AI analysis of their streams.</p>`;
html += `
-- ${_uaiCollapsible('Streamer overview', '<span class="uai-tag uai-tag-streamer">channel</span>', esc(st.overview || st.overview_short || ''), { icon: 'fa-wand-magic-sparkles' })}
++ ${_uaiCollapsible('Streamer overview', '<span class="uai-tag uai-tag-streamer">channel</span>', esc(st.overview || st.overview_short || ''), { icon: 'fa-sparkles' })}
${mems.length ? `<div class="uai-section">
<h4><i class="fa-solid fa-timeline"></i> Recent stream moments</h4>
<div class="gai-timeline">${mems.map(m => `
@@@ -1578,7 +1578,7 @@ function _uaiCollapsible(title, tagHtml, bodyHtml, opts = {}) {
const preview = plain.length > 90 ? plain.slice(0, 90).trimEnd() + '…' : plain;
return `<div class="uai-section uai-collapsible">
<h4 class="uai-collapse-toggle" onclick="_uaiToggleSection(this)" role="button" tabindex="0">
-- <i class="fa-solid ${opts.icon || 'fa-wand-magic-sparkles'}"></i> ${title} ${tagHtml || ''}
++ <i class="fa-solid ${opts.icon || 'fa-sparkles'}"></i> ${title} ${tagHtml || ''}
<i class="fa-solid fa-chevron-down uai-collapse-caret"></i>
</h4>
<div class="uai-collapse-preview">${esc(preview)}</div>
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex f872e8e2..64c7a436 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -349,7 +349,7 @@ function _wsRenderPanel() {
placeholder="What are you streaming?" maxlength="200"
oninput="_wsMarkDirty()">
<button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">
-- <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>
++ <i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Suggest</span>
</button>
<span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
</div>
@@@ -3385,7 +3385,7 @@ function _wsShowRestreamForm(existing) {
<p style="margin:0;font-size:0.82rem;color:var(--text-secondary)"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> <strong>RobotStreamer</strong> uses a dedicated WebSocket connection (not RTMP). The token and robot below apply to <strong>this stream slot only</strong> — each slot can restream to a different robot.</p>
</div>
<div class="form-group">
-- <label><i class="fa-solid fa-wand-magic-sparkles" style="color:#4a9eff"></i> Easiest: log in with RobotStreamer</label>
++ <label><i class="fa-solid fa-sparkles" style="color:#4a9eff"></i> Easiest: log in with RobotStreamer</label>
<div style="display:flex;gap:8px">
<input type="text" id="ws-rs-rs-username" class="form-input form-input-sm" style="flex:1" placeholder="RobotStreamer username" autocomplete="off">
<input type="password" id="ws-rs-rs-password" class="form-input form-input-sm" style="flex:1" placeholder="Password" autocomplete="off">
@@@ -3477,7 +3477,7 @@ function _wsShowRestreamForm(existing) {
</div>
</div>
<div class="bc-ws-toggle-group bc-ws-pc-only">
-- <div class="bc-ws-toggle-group-title"><i class="fa-solid fa-wand-magic-sparkles"></i> PowerChat overlay</div>
++ <div class="bc-ws-toggle-group-title"><i class="fa-solid fa-sparkles"></i> PowerChat overlay</div>
<div class="bc-ws-toggle-grid">
<label class="bc-toggle-label bc-ws-toggle-chip" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat relay)">
<input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex a735e23b..ebb9f550 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -4878,7 +4878,7 @@ function _renderCameraSwitcherChoices(devices, activeCameraId) {
options.forEach((device, index) => {
const current = (activeCameraId || 'default') === device.deviceId;
const meta = device._default
-- ? { icon: 'fa-solid fa-wand-magic-sparkles', title: 'Default Camera', detail: 'Let the browser choose the preferred camera for this device.' }
++ ? { icon: 'fa-solid fa-sparkles', title: 'Default Camera', detail: 'Let the browser choose the preferred camera for this device.' }
: _describeCameraRole(device, index - 1);
const titleText = device._default ? 'System Default Camera' : (device.label || meta.title);
const subtitleText = device._default ? meta.detail : `${meta.title} • ${meta.detail}`;
ddiff --git a/public/js/chat-shared.js b/public/js/chat-shared.js
iindex d113d014..59b31d81 100644
---- a/public/js/chat-shared.js
++++ b/public/js/chat-shared.js
@@@ -394,7 +394,7 @@
</div>
<div class="ctx-actions">
${replyBtn}
-- <button class="ctx-btn" onclick="dismissContextMenu();window.openRelayUserChatInsight&&openRelayUserChatInsight('${esc(platform)}','${esc(externalUsername)}','${esc(displayPlatform)}')"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>
++ <button class="ctx-btn" onclick="dismissContextMenu();window.openRelayUserChatInsight&&openRelayUserChatInsight('${esc(platform)}','${esc(externalUsername)}','${esc(displayPlatform)}')"><i class="fa-solid fa-sparkles"></i> AI Insight</button>
${cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('relay','${esc(platform)}:${esc(externalUsername)}','${esc(externalUsername)}')"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" onclick="dismissContextMenu();ctxViewRelayLogs('${esc(platform)}','${esc(externalUsername)}')"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${modBtns ? '<div class="ctx-divider"></div>' + modBtns : ''}
@@@ -427,7 +427,7 @@
<div class="ctx-actions">
${replyBtn}
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>
-- <button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();window.openAnonChatInsight&&openAnonChatInsight(this.dataset.username)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>
++ <button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();window.openAnonChatInsight&&openAnonChatInsight(this.dataset.username)"><i class="fa-solid fa-sparkles"></i> AI Insight</button>
${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();ctxViewAnonLogs(this.dataset.username)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${modBtns}
${banBtns}
@@@ -492,7 +492,7 @@
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>
${cu && cu.id && cu.id !== p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxCallUser(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-phone"></i> Call this user</button>` : ''}
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxViewChannel(this.dataset.username)"><i class="fa-solid fa-user"></i> Channel</button>
-- ${p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.openUserChatInsight&&openUserChatInsight(this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>` : ''}
++ ${p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.openUserChatInsight&&openUserChatInsight(this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-sparkles"></i> AI Insight</button>` : ''}
${p.id && cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('user', this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxViewLogs(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${cap.manage_users ? `<div class="ctx-rename-group">
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 29a39210..0dc5b465 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2456,7 +2456,7 @@ function buildChatMessageEl(msg, opts = {}) {
? `<div class="clip-chat-thumb"><img src="${esc(clip.thumbnail_url)}" alt="" loading="lazy">${dur ? `<span class="clip-chat-dur">${esc(dur)}</span>` : ''}<span class="clip-chat-play"><i class="fa-solid fa-play"></i></span></div>`
: `<div class="clip-chat-thumb clip-chat-thumb-ph"><i class="fa-solid fa-scissors"></i></div>`;
const label = isAuto
-- ? `<div class="clip-chat-label"><i class="fa-solid fa-wand-magic-sparkles"></i> AI clip</div>`
++ ? `<div class="clip-chat-label"><i class="fa-solid fa-sparkles"></i> AI clip</div>`
: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;
const creator = esc(clip.creator || msg.username || 'someone');
const avatar = clip.creator_avatar
@@@ -3496,6 +3496,13 @@ function setChatReply(replyData) {
_chatReplyTo = replyData;
// Show/update reply bar on all chat input areas
document.querySelectorAll('.chat-input-area').forEach(area => {
++ // The shared box says it inside itself, joined to it, with its own × (ask 371, 11-13: "more connected to the
++ // actual composer"); clearing it there clears the reply here.
++ const ovc = area.querySelector('form.ovc-v2')?.__ovc;
++ if (ovc && typeof ovc.reply === 'function') {
++ ovc.reply({ name: replyData.username, text: _spoilFree(replyData.message || ''), data: replyData, onClear: () => { if (_chatReplyTo === replyData) _chatReplyTo = null; } });
++ return;
++ }
let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');
if (!bar) {
bar = document.createElement('div');
@@@ -3520,7 +3527,9 @@ function setChatReply(replyData) {
* Clear the reply target — hides the reply bar.
*/
function clearChatReply() {
++ const was = _chatReplyTo;
_chatReplyTo = null;
++ if (was) document.querySelectorAll('.chat-input-area form.ovc-v2').forEach(f => { if (f.__ovc && f.classList.contains('is-replying')) f.__ovc.clearBar(); });
document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {
bar.style.display = 'none';
});
ddiff --git a/public/js/composer.js b/public/js/composer.js
iindex 9c1c3049..5e72dbbd 100644
---- a/public/js/composer.js
++++ b/public/js/composer.js
@@@ -4,13 +4,36 @@
Loaded after /shared/ov-composer.js; without it the old textarea stays. */
(function () {
'use strict';
++ /* Live's tools in the shared box (ask 371, 2026-11-13: an emoji button and a "+" in the box, and again in the row under
++ it). The box holds what goes into a message: Live's emote picker (the channel's emotes and every emoji) in its row,
++ and in its + sheet a GIF and adding a channel emote or sound. The row under the box keeps the channel's own actions
++ (sound, coins, tips). Each tool finds its box's textarea by id, as the pickers always have. */
++ var SMILE = 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM8.5 14.2s1.3 1.8 3.5 1.8 3.5-1.8 3.5-1.8M9.2 9.6h.01M14.8 9.6h.01';
++ var GIF = 'M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3zM10.2 10H8.6a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1.6v-1.7M12.6 10v4M15.2 14v-4h2.2M15.2 12h1.8';
++ var ADD = 'M20 12a8 8 0 1 1-8-8M8.6 14.2s1.2 1.6 3.4 1.6 3.4-1.6 3.4-1.6M9.3 10h.01M14.7 10h.01M18.5 2.5v5M16 5h5';
++ function inputOf(form) { var t = form && form.querySelector('textarea'); return t ? t.id : ''; }
++ function registerTools() {
++ var C = window.OpenVibeComposer;
++ if (!C || typeof C.tool !== 'function' || registerTools.done) return;
++ registerTools.done = true;
++ C.tool('emote', { label: 'Emotes and emoji', path: SMILE, run: function (ctx, button, form) {
++ if (typeof window.toggleEmotePicker === 'function') window.toggleEmotePicker(inputOf(form || (ctx && ctx.form)));
++ } });
++ C.tool('gif', { label: 'GIF', path: GIF, extra: true, run: function (ctx, button, form) {
++ if (typeof window.toggleGifPicker === 'function') window.toggleGifPicker(inputOf(form || (ctx && ctx.form)));
++ } });
++ C.tool('emote-add', { label: 'Add an emote or sound', path: ADD, extra: true, run: function () {
++ if (typeof window.openChannelUploadModal === 'function') window.openChannelUploadModal();
++ } });
++ }
function upgrade(row) {
if (!row || row.dataset.ovcMounted || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
++ registerTools();
var old = row.querySelector('textarea');
if (!old) return;
var id = old.id, max = Number(old.getAttribute('maxlength')) || 500, placeholder = old.placeholder || 'Send a message...';
var c = window.OpenVibeComposer.mount(document.createElement('div'), {
-- preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emoji'],
++ preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emote'],
onSubmit: function () {
var before = c.text.value;
if (id === 'fcw-chat-input' && typeof window.fcwSendChat === 'function') window.fcwSendChat();
ddiff --git a/public/js/content-feed.js b/public/js/content-feed.js
iindex 32973a8e..c8fd6620 100644
---- a/public/js/content-feed.js
++++ b/public/js/content-feed.js
@@@ -34,7 +34,7 @@
types: ['all', 'clips', 'shots', 'recaps'],
titles: { all: 'AI Moments', clips: 'AI auto-clips', shots: 'AI moments', recaps: 'AI recaps' },
empty: {
-- all: ['fa-wand-magic-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],
++ all: ['fa-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],
clips: ['fa-scissors', 'No auto-clips yet', 'When chat erupts during a stream, the AI cuts a clip of it.'],
shots: ['fa-image', 'No AI moments yet', 'The AI picks standout frames from recent streams.'],
recaps: ['fa-clipboard-list', 'No AI recaps yet', 'After a long enough stream the AI writes a recap.'],
@@@ -129,7 +129,7 @@
});
return img;
}
-- const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');
++ const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-sparkles'), 'AI');
// ── cards ──────────────────────────────────────────────────────────────────────────────
function meta(it, extra) {
@@@ -220,7 +220,7 @@
const stats = el('span', { class: 'cf-recap-stats' });
if (it.duration_seconds) stats.append(el('span', null, el('b', { text: duration(it.duration_seconds) }), ' live'));
if (it.views) stats.append(el('span', null, el('b', { text: compact(it.views) }), ' peak'));
-- band.append(el('span', { class: 'cf-recap-head' }, el('span', { class: 'cf-recap-label' }, icon('fa-wand-magic-sparkles'), ' AI recap'), el('span', { class: 'cf-recap-sub', text: 'After-show report' }), stats));
++ band.append(el('span', { class: 'cf-recap-head' }, el('span', { class: 'cf-recap-label' }, icon('fa-sparkles'), ' AI recap'), el('span', { class: 'cf-recap-sub', text: 'After-show report' }), stats));
const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }));
if (it.stream_title && it.stream_title !== it.title) body.append(el('div', { class: 'cf-streamtitle', text: it.stream_title }));
body.append(meta(it));
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex df4931ff..caeb2719 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -1150,7 +1150,7 @@ function renderDashTokenPresetButtons() {
<div style="display:flex;flex-wrap:wrap;gap:8px">
${dashTokenPresets.map((preset) => `
<button type="button" class="btn btn-small btn-outline" onclick="applyDashTokenPreset('${preset.id}')">
-- <i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(preset.label)}
++ <i class="fa-solid fa-sparkles"></i> ${esc(preset.label)}
</button>`).join('')}
</div>
<p class="muted" id="token-preset-hint" style="font-size:0.78rem;margin-top:8px">Use a preset to preselect the scopes needed for a common integration.</p>`;
ddiff --git a/public/js/emotes.js b/public/js/emotes.js
iindex 41d42cc4..b86f3033 100644
---- a/public/js/emotes.js
++++ b/public/js/emotes.js
@@@ -620,7 +620,8 @@ function closeEmotePicker() {
document.addEventListener('pointerdown', (e) => {
if (!_emotePickerOpen) return;
const t = e.target && e.target.closest ? e.target : null;
-- if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn'))) return;
++ // Its own button toggles it (the shared box's emote tool, or an old row's): a press there is left to that button.
++ if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn') || t.closest('.ovc-t-emote'))) return;
closeEmotePicker();
}, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });
ddiff --git a/public/js/gif-picker.js b/public/js/gif-picker.js
iindex 8b3c7814..a34c839e 100644
---- a/public/js/gif-picker.js
++++ b/public/js/gif-picker.js
@@@ -85,7 +85,7 @@ function _getGifPicker() {
document.addEventListener('pointerdown', (event) => {
if (!_gifPickerOpen || !_gifPickerEl) return;
const t = event.target;
-- if (_gifPickerEl.contains(t) || (t && t.closest && t.closest('.gif-btn'))) return;
++ if (_gifPickerEl.contains(t) || (t && t.closest && (t.closest('.gif-btn') || t.closest('.ovc-t-gif') || t.closest('.ovc-tray-i')))) return;
closeGifPicker();
}, true);
document.addEventListener('keydown', (event) => {
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex c084a39b..9092d575 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -139,9 +139,9 @@
el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on');
if (b.dataset.pick === 'new') { D.slot = null; form.classList.remove('is-hidden'); el.querySelector('#ovg-title-in').focus(); }
else { D.slot = D.slots.find(s => String(s.id) === b.dataset.pick) || null; form.classList.add('is-hidden'); }
-- const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';
++ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-sparkles"></i> Create my stream';
}));
-- const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';
++ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-sparkles"></i> Create my stream';
},
validate: async () => {
if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); return true; }
@@@ -519,7 +519,7 @@
<div class="ovg-join-cta">
<a class="btn btn-primary btn-lg ovg-join-go" href="/api/auth/sso/login"><i class="fa-solid fa-user-plus"></i> Create your free account</a>
<a class="btn btn-outline ovg-join-in" href="/api/auth/sso/login"><i class="fa-solid fa-right-to-bracket"></i> I already have one</a>
-- <button type="button" class="ovg-join-tour"><i class="fa-solid fa-wand-magic-sparkles"></i> Just show me around first</button>
++ <button type="button" class="ovg-join-tour"><i class="fa-solid fa-sparkles"></i> Just show me around first</button>
</div>
</div>`;
el.querySelector('.ovg-join-tour').addEventListener('click', () => openSiteTour());
ddiff --git a/public/js/home-featured.js b/public/js/home-featured.js
iindex 42ec85e2..8792b10b 100644
---- a/public/js/home-featured.js
++++ b/public/js/home-featured.js
@@@ -171,7 +171,7 @@
${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
</a>
</div>
-- ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}
++ ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}
${overview ? `<div class="hf-overview">${escText(overview)}</div>` : ''}
<div class="hf-actions">
<a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch &amp; chat</a>
ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex de5a20a3..ae0d8e2c 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -30,7 +30,7 @@
{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },
// VODs, clips and pastes people made are one feed; what the AI made is the other.
{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
-- { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
++ { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-sparkles' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'rundown', page: 'rundown', label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex 1479d501..d25df3be 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -83,7 +83,7 @@ function renderPasteCard(p) {
<span class="paste-card-time">${timeAgo}</span>
</div>
</div>
-- ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${escapeHtml(p.ai_summary)}</div>` : '')}
++ ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-sparkles"></i> ${escapeHtml(p.ai_summary)}</div>` : '')}
</div>
</a>`;
// Wrap for admin bulk-select (ctrl/shift multi-select + bulk delete) when enabled.
@@@ -344,7 +344,7 @@ async function loadPasteViewer(slug) {
try { _aiTags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { _aiTags = []; }
const aiHtml = p.ai_summary ? `
<div class="paste-ai-analysis">
-- <div class="paste-ai-title"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Analysis</div>
++ <div class="paste-ai-title"><i class="fa-solid fa-sparkles"></i> AI Analysis</div>
<div class="paste-ai-summary">${escapeHtml(p.ai_summary)}</div>
${_aiTags.length ? `<div class="paste-ai-tags">${_aiTags.map(t => `<span class="paste-ai-tag">${escapeHtml(String(t))}</span>`).join('')}</div>` : ''}
</div>` : '';
ddiff --git a/public/js/recap.js b/public/js/recap.js
iindex 4675d724..c455ddce 100644
---- a/public/js/recap.js
++++ b/public/js/recap.js
@@@ -69,7 +69,7 @@
<header class="recap-head">
<div class="recap-grade" title="${esc(g.label)}"><span>${esc(w.grade || 'B')}</span><small>${esc(g.label)}</small></div>
<div class="recap-head-body">
-- <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-wand-magic-sparkles"></i> written by OpenVibe AI</span>' : ''}</div>
++ <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-sparkles"></i> written by OpenVibe AI</span>' : ''}</div>
<h1 class="recap-headline">${esc(w.headline || s.title)}</h1>
<div class="recap-by">${av(u, 34)}<a class="recap-by-name" ${go(path)}>${esc(u.display_name)}</a><span class="muted">streamed</span><b>${esc(s.title)}</b><span class="recap-by-meta">${esc(when(s.started_at))} · ${dur(s.duration_seconds)}${s.category ? ` · ${esc(s.category)}` : ''}</span></div>
${(w.tags || []).length ? `<div class="recap-tags">${w.tags.map(t => `<span>#${esc(t)}</span>`).join('')}</div>` : ''}
ddiff --git a/public/js/stream-pip.js b/public/js/stream-pip.js
iindex bdad3531..773fbf94 100644
---- a/public/js/stream-pip.js
++++ b/public/js/stream-pip.js
@@@ -17,11 +17,15 @@
- a PIP lives in the player (docked) or over the page (floating, position: fixed, it stays on screen while the page
scrolls); it follows the finger or the mouse with a transform each frame and lands with a spring (FLIP), dropped
over the player it docks, anywhere else it floats; a flick carries on the way it was thrown;
-- - thrown past the side of the screen (or of the player) it tucks into that side, a tab with its picture still going
-- at the edge, and a tap brings it out ("get out of the way" without hiding it);
-- - it is never smaller than a 150 px wide 16:9 box on a phone; two fingers pinch it, the corner sizes it, a double
-- tap (or click) toggles small and large; with focus the arrows move it, + and − size it, M mutes, T tucks, F pops it
-- out of the player or back in, Home puts it back where the streamer put it.
++ - thrown past the side of the screen (or of the player) it tucks into that side: it folds into a small handle at
++ the edge (openvibe-shared 4.235.0; owner 11-13: "a small button that lets you pop it in and out"), the handle
++ slides up and down its side, a tap opens it where it was and a pull brings it out under the finger;
++ - it is never smaller than a 150 px wide 16:9 box on a phone and grows to the whole width of the window (past the
++ player it floats over the page); two fingers pinch it, the corner sizes it, a double tap (or click) toggles small
++ and large (on a phone, the screen's width); with focus the arrows move it, + and − size it, M mutes, T tucks, F
++ pops it out of the player or back in, Home puts it back where the streamer put it;
++ - popped out of the browser (its own window on top of everything), the box in the page steps aside until that
++ window closes, so the picture is not shown twice.
Waiting (owner: "this black thing that's just stuck there … use the OV animations"): until its first frame, and
whenever its frames stop for 4 s, it shows the mark at work over its last picture, and tries again by itself after
15 s and on a backoff when its connection drops (2, 4, 8, 15, 30 s); a source that ended leaves with an animation.
@@@ -38,7 +42,7 @@
const LS_KEY = (slot, sourceMsid) => `ov-pip-${slot}-${sourceMsid}`;
const LS_V1 = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`; // per session, before 2026-11-11
-- const MIN_W = 0.08, MAX_W = 0.60;
++ const MIN_W = 0.08, MAX_W = 1; // a viewer may make it as wide as the window (the streamer's default stays ≤ 0.6)
const POLL_MS = 30000;
const BASE = 16 / 9; // the shape `w` is measured for
const MAX_H = 0.85; // of the frame's height, whatever the shape
@@@ -202,7 +206,7 @@
<button type="button" data-act="tuck" title="Tuck ${escapeHtml(label)} to the side" aria-label="Tuck ${escapeHtml(label)} to the side"><i class="fa-solid fa-angles-right"></i></button>
</span>
</div>
-- <button type="button" class="ov-pip-tab" data-act="untuck" data-ov-float-tab title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button>
++ <button type="button" class="ov-pip-tab" data-ov-float-tab title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button>
<div class="ov-pip-resize" data-ov-float-resize title="Drag to resize" aria-hidden="true"></div>`;
o.el = el;
o.container = container;
@@@ -233,8 +237,6 @@
minPx: MIN_PX,
bigW: BIG_W,
tabPx: TAB_PX,
-- tab: false,
-- resize: false,
hintClass: 'ov-pip-dock-hint',
ignore: '[data-act], .ov-pip-resize, [data-ov-float-resize], a',
onChange(geom) { o.geom = geom; saveGeom(o); },
@@@ -251,41 +253,29 @@
container.appendChild(el);
}
wireMuteKey(o);
-- wireResize(o);
++ wirePopout(o);
setTimeout(() => el.classList.remove('is-entering'), 420);
}
-- /** The corner grip: the shared engine owns pinch and the keys; this is the same sizing on the PIP's own handle. */
-- function wireResize(o) {
-- const el = o.el;
-- const handle = el.querySelector('.ov-pip-resize');
-- let start = null;
-- handle.addEventListener('pointerdown', (e) => {
-- if (e.button > 0) return;
-- const b = o.float.metrics();
-- start = { px: e.clientX, py: e.clientY, w: o.float.geom().w, rw: b.rw };
-- el.classList.add('is-resizing');
-- try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
-- e.preventDefault();
-- e.stopPropagation();
-- });
-- handle.addEventListener('pointermove', (e) => {
-- if (!start || !start.rw) return;
-- // The handle's diagonal pull is the box's own width; `w` is the 16:9 width of the same area.
-- const k = Math.sqrt((o.aspect || BASE) / BASE) || 1;
-- const pull = ((e.clientX - start.px) + (e.clientY - start.py) * (o.aspect || BASE)) / 2;
-- o.float.set({ w: clamp(start.w + pull / start.rw / k, MIN_W, MAX_W, start.w) });
-- });
-- const end = (e) => {
-- if (!start) return;
-- start = null;
-- el.classList.remove('is-resizing');
-- try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
-- o.float.settle();
-- o.float.save();
++ /**
++ * Out of the browser: while the browser's own picture-in-picture window shows this PIP, the box in the page steps
++ * aside (owner, 11-13: "it just goes twice like this and ugly"), and comes back when that window closes.
++ */
++ function wirePopout(o) {
++ const v = o.el.querySelector('.ov-pip-video');
++ const btn = o.el.querySelector('[data-act="popout"]');
++ const set = (out) => {
++ o.el.classList.toggle('is-popped', out);
++ if (btn) btn.setAttribute('aria-pressed', String(out));
++ if (!out && !reduced()) {
++ o.el.classList.remove('is-entering');
++ void o.el.offsetWidth;
++ o.el.classList.add('is-entering');
++ setTimeout(() => o.el && o.el.classList.remove('is-entering'), 420);
++ }
};
-- handle.addEventListener('pointerup', end);
-- handle.addEventListener('pointercancel', end);
++ v.addEventListener('enterpictureinpicture', () => set(true));
++ v.addEventListener('leavepictureinpicture', () => set(false));
}
/** With focus, M mutes; the engine owns the arrows, + and −, T, F and Home. */
@@@ -321,7 +311,6 @@
}
if (!o.float) return;
if (what === 'tuck') o.float.tuck();
-- else if (what === 'untuck') o.float.untuck();
else if (what === 'reset') o.float.reset();
else if (what === 'float') { o.float.toggleMode(); }
else if (what === 'mute') {
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex f71e4f2b..241f0348 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -409,7 +409,7 @@ function _ensurePlayerLoaderStructure(placeholder) {
<div class="player-loader-expanded">
<p class="player-loader-detail"></p>
<p class="player-loader-hint">
-- <i class="fa-solid fa-wand-magic-sparkles"></i>
++ <i class="fa-solid fa-sparkles"></i>
<span></span>
</p>
<div class="player-loader-actions">
ddiff --git a/public/kiosk.html b/public/kiosk.html
iindex 9212794c..583951b5 100644
---- a/public/kiosk.html
++++ b/public/kiosk.html
@@@ -551,7 +551,7 @@ button{font:inherit}
<div class="vital"><div class="vlabel"><i class="fa-solid fa-scissors"></i> Clips</div><div class="vnum" id="v-clips">–</div><div class="vdelta zero" id="v-clips-d">&nbsp;</div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-paste"></i> Pastes</div><div class="vnum" id="v-pastes">–</div><div class="vdelta zero" id="v-pastes-d">&nbsp;</div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-hourglass-half"></i> Stream hrs</div><div class="vnum" id="v-hours">–</div><div class="vdelta zero" id="v-hours-d">&nbsp;</div></div>
-- <div class="vital"><div class="vlabel"><i class="fa-solid fa-wand-magic-sparkles"></i> AI moments</div><div class="vnum" id="v-ai">–</div><div class="vdelta zero" id="v-ai-d">&nbsp;</div></div>
++ <div class="vital"><div class="vlabel"><i class="fa-solid fa-sparkles"></i> AI moments</div><div class="vnum" id="v-ai">–</div><div class="vdelta zero" id="v-ai-d">&nbsp;</div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-message"></i> Messages</div><div class="vnum" id="v-msgs">–</div><div class="vdelta zero" id="v-msgs-d">&nbsp;</div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-users"></i> OpenVibes</div><div class="vnum" id="v-users">–</div><div class="vdelta zero" id="v-users-d">&nbsp;</div></div>
</section>
ddiff --git a/public/obs/alerts.html b/public/obs/alerts.html
iindex 2456a889..9ccaf530 100644
---- a/public/obs/alerts.html
++++ b/public/obs/alerts.html
@@@ -74,7 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}
sub: { icon: 'fa-star', say: 'subscribed' },
raid: { icon: 'fa-users', say: 'raided' },
goal: { icon: 'fa-trophy', say: 'Goal reached' },
-- element: { icon: 'fa-wand-magic-sparkles', say: 'pressed' },
++ element: { icon: 'fa-sparkles', say: 'pressed' },
};
var queue = [], showing = false, seen = new Set();
function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex a26eb8c2..6fb94fcc 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -191,15 +191,6 @@
</button>
</div>
<div class="chat-tools-row">
-- <button class="emote-picker-btn" onclick="toggleEmotePicker('pc-input')" title="Emotes">
-- <i class="fa-solid fa-face-smile"></i>
-- </button>
-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('pc-input')" title="GIFs">
-- <span class="gif-btn-text">GIF</span>
-- </button>
-- <button class="emote-picker-btn" onclick="if(typeof openChannelUploadModal==='function')openChannelUploadModal()" title="Add channel emote / sound">
-- <i class="fa-solid fa-plus"></i>
-- </button>
<button class="chat-tts-toggle tts-active" id="pc-tts-btn" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<i class="fa-solid fa-volume-high"></i>
</button>
ddiff --git a/public/privacy.html b/public/privacy.html
iindex bf5e0e86..4f8732d5 100644
---- a/public/privacy.html
++++ b/public/privacy.html
@@@ -246,7 +246,7 @@
<!-- AI & transcription -->
<div class="section">
-- <h2><i class="fa-solid fa-wand-magic-sparkles"></i> AI &amp; Transcription</h2>
++ <h2><i class="fa-solid fa-sparkles"></i> AI &amp; Transcription</h2>
<p>OpenVibe.Live has optional AI features that generate overviews, memories, and summaries of streams, VODs, clips, and pastes to make them easier to browse.</p>
<p><strong>Audio transcription runs locally on our own server</strong> (whisper.cpp) — your stream audio is <strong>not</strong> sent to a third-party transcription API. Frame analysis for stream memories may use an AI provider, gated by a strict daily budget and only on content you've made available on the platform.</p>
<p>Generated transcripts, timestamps, and overviews are stored alongside the content they describe and follow the same visibility you chose for that content. If you delete a VOD, clip, or paste, its AI-generated data goes with it.</p>
ddiff --git a/test/chat-box-and-ticker.test.js b/test/chat-box-and-ticker.test.js
nnew file mode 100644
iindex 00000000..0f5c35e1
---- /dev/null
++++ b/test/chat-box-and-ticker.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++// The owner's phone, 2026-11-13 (ask 371): the chat box holds what goes into a message (Live's emote picker in its
++// row; a GIF and adding an emote or sound in its + sheet) and the row under it only the channel's own actions, so
++// nothing is there twice; a reply is said inside the box; the global chat's AI line is a ticker that steps aside when
++// it is old; a section's glyph is drawn inside its tile, not to its edges; AI wears the sparkles.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
++
++const composer = read('public/js/composer.js');
++assert.ok(/C\.tool\('emote', \{ label: 'Emotes and emoji'/.test(composer) && /tools: \['emote'\]/.test(composer), 'the box\'s row has Live\'s emote picker');
++assert.ok(/C\.tool\('gif', \{ label: 'GIF', path: GIF, extra: true/.test(composer) && /C\.tool\('emote-add', \{ label: 'Add an emote or sound', path: ADD, extra: true/.test(composer),
++ 'its + sheet has a GIF and adding an emote or sound');
++for (const f of ['public/fragments/channel.html', 'public/fragments/chat.html', 'public/fragments/broadcast.html', 'public/popout-chat.html']) {
++ const html = read(f);
++ assert.ok(!/onclick="toggleEmotePicker\(|onclick="toggleGifPicker\(|title="Add channel emote/.test(html), `${f}: no emote, GIF or add button under the box (they are in it)`);
++}
++assert.ok(/t\.closest\('\.ovc-t-emote'\)/.test(read('public/js/emotes.js')), 'a press on the box\'s emote button is left to it');
++assert.ok(/t\.closest\('\.ovc-t-gif'\) \|\| t\.closest\('\.ovc-tray-i'\)/.test(read('public/js/gif-picker.js')), 'and on the sheet\'s GIF');
++
++const chat = read('public/js/chat.js');
++assert.ok(/const ovc = area\.querySelector\('form\.ovc-v2'\)\?\.__ovc;\n\s*if \(ovc && typeof ovc\.reply === 'function'\) \{\n\s*ovc\.reply\(\{ name: replyData\.username/.test(chat), 'a reply is said inside the shared box');
++assert.ok(/f\.__ovc\.clearBar\(\)/.test(chat), 'and cleared there');
++
++const page = read('public/js/app-chatpage.js');
++assert.ok(/_gaiAgeMs\(insight\.updated_at\) > 12 \* 3600e3/.test(page), 'a read older than 12 hours steps aside');
++assert.ok(/function _gaiTicker\(el, text\)/.test(page) && /el\.matches\(':hover'\) \|\| el\.closest\('\.global-ai-strip\.open'\)/.test(page), 'a ticker that waits under a pointer and while open');
++const css = read('public/css/features/chatpage.css');
++assert.ok(css.includes('@keyframes gaiRoll') && css.includes('@keyframes gaiPan') && css.includes('html[data-ov-motion="off"] .global-ai-strip .gai-t { animation: none !important; }'), 'it rolls and pans, and not with motion off');
++
++assert.ok(read('public/css/features/channel.css').includes('#page-channel .ch-sec-ic > svg { width: 62%; height: 62%; }'), 'a section\'s glyph inside its tile');
++const wand = ['public/js/app-channel.js', 'public/js/chat.js', 'public/fragments/chat.html', 'public/fragments/channel.html'].filter((f) => read(f).includes('fa-wand-magic-sparkles'));
++assert.deepStrictEqual(wand, [], 'AI wears the sparkles, not a wand');
++console.log('chat-box-and-ticker: one emote button, the extras in the +, a reply in the box, a ticker that steps aside, glyphs in their tiles');
ddiff --git a/test/chat-sheet-fill.test.js b/test/chat-sheet-fill.test.js
iindex a2ab5342..eeb8d0e9 100644
---- a/test/chat-sheet-fill.test.js
++++ b/test/chat-sheet-fill.test.js
@@@ -21,9 +21,9 @@ assert.ok(/> \.chat-sidebar > :is\(\.chat-settings-panel, \.chat-users-panel\) \
assert.ok(/> \.chat-sidebar \.chat-header \{ padding-top: 4px; padding-bottom: 4px; min-height: 44px; \}/.test(css), 'a compact header');
assert.ok(/> \.chat-sidebar > \.chat-grip \{ position: absolute; top: 0; height: 14px;/.test(css), 'the grip over its top edge, no row of its own');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
--assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /pop\.setAttribute\('popover', 'manual'\)/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),
-- 'the ⋯ menu opens in the top layer, placed on the screen (a phone\'s live layout clipped it)');
--assert.ok(css.includes('#page-channel .ch-more-menu.is-layer { position: fixed;'), 'and is fixed there');
++assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /window\.OVSurfaces\.popover\(\{ el: menu, trigger: summary, pop, place: 'bottom-end'/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),
++ 'the ⋯ menu is the network\'s layer: anchored to its button in the top layer (a phone\'s live layout clipped it)');
++assert.ok(!/_wireChannelMoreLayer[\s\S]{0,1500}showPopover/.test(js), 'Live keeps no placement of its own');
assert.ok(css.includes('.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px;'),
'every tab icon fills the same box (the robot was drawn smaller than the chat bubble)');
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');
ddiff --git a/test/golive-setup.test.js b/test/golive-setup.test.js
iindex 28a81f03..c6085d72 100644
---- a/test/golive-setup.test.js
++++ b/test/golive-setup.test.js
@@@ -83,7 +83,7 @@ assert.ok(/\.gl-chips \{ display: grid; grid-template-columns: repeat\(2, minmax
assert.ok(panel.includes('<span class="gl-chip-k">Address</span><span class="gl-chip-v" id="gl-link">') && panel.includes('class="gl-chip gl-chip--link" onclick="_glCopyLink()"'), 'the address tile copies');
assert.ok((panel.match(/class="gl-chip-i" aria-hidden="true"/g) || []).length === 4, 'each tile has its icon in a tile of its own');
// Suggest sits against the title with the network's glyph, not an emoji.
--assert.ok(panel.includes('<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');
++assert.ok(panel.includes('<i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');
assert.ok(/\.gl-title-row \.gl-title \{ flex: 0 1 auto; width: auto; max-width: 100%; field-sizing: content;/.test(css), 'the title is as wide as its words, so Suggest follows them');
// The head: Streamer setup at the row's end, and the line under it never leaves one word alone.
assert.ok(frag.includes('<span class="gl-head-t">Go Live</span> <button type="button" class="glw-open-btn" onclick="openSetupHub()"'), 'the head');
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex 76f88e40..ee7f95ea 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -102,8 +102,10 @@ console.log('OK H: overlay is a full independent viewer session, attached and de
assert.ok(/localStorage\.setItem\(LS_KEY\(o\.slot, o\.pip\.source_msid\)/.test(pip), 'the place persists per slot and per PIP');
assert.ok(/account\.put\(`\$\{o\.slot\}:\$\{o\.pip\.source_msid\}`, o\.geom\)/.test(pip), 'and in the account when signed in');
assert.ok(/localStorage\.getItem\(LS_V1\(streamId, pip\.source_msid\)\)/.test(pip), 'a place saved per session before still counts');
--assert.ok(/data-act="untuck"/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid; }'),
-- 'a PIP tucked aside keeps its tab on screen to bring it back');
++assert.ok(/class="ov-pip-tab" data-ov-float-tab/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid;'),
++ 'a PIP tucked aside keeps its handle on screen to bring it back');
++assert.ok(/enterpictureinpicture/.test(pip) && /leavepictureinpicture/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-popped {'),
++ 'popped out of the browser, the box in the page steps aside until that window closes');
assert.ok(/muted: pip\.muted !== false/.test(pip),
'the overlay starts as the streamer chose, muted unless they said otherwise');
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');