openvibers/openvibe.live Commits

Section heads that say what they are, and the robot's panel in a window (#799)

OP OpenVibers committed 2 days ago · 1c232cc081 · 5 files, +101 −21

The owner, on the first pass: "those button designs ... confusing ... needs a little more info and
better alignment and sizing and consistency ... add an icon for the header ... better font sizing";
and the robot's controls "can be popped out ... and resized".

- Every section's heading opens with its icon in a 30 px tile; the name at 1.15rem; the count as a
  quiet pill. The tools have one height, radius and tint; from 760 px each says what it is beside
  its icon (the pickers say the value they hold, with a caret: "All streams", "Newest first";
  "Video", "View all videos"); narrower, the icon carries it (the order's icon is the order:
  newest, oldest, views, peak; the filter shows a dot while it filters).
- A fifth place for the robot's controls, "In a window": the full panel in the shared window
  (ov-window.js), moved by its head and sized by its corner (with Shared 4.204), its place kept per
  robot; it grows out of the switch; closing it puts the panel back under the video. A phone keeps
  the full panel in its Robot tab.

Asks 355 and 356.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 5cfce97a..f98b5918 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -2184,3 +2184,31 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
#page-channel .ch-rec-watch { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: #fff; font-size: 1.6rem; opacity: 0; background: rgba(0, 0, 0, .28); transition: opacity var(--ov-dur-2, .2s); pointer-events: none; }
#page-channel .ch-rec-card:hover .ch-rec-watch, #page-channel .ch-rec-card:focus-visible .ch-rec-watch { opacity: 1; }
#page-channel .ch-ib .ch-ib-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++
++/* Section heads, second pass (ask 355, the owner: "those button designs … confusing … needs a little more info and better
++ alignment and sizing and consistency … improve the header of each, add an icon for the header … better font sizing").
++ - The heading: its icon in a 30 px tile, the name at 1.15rem/800, the count as a quiet pill.
++ - The tools: one height (36, 40 on touch), one radius, one tint; from 760 px each says what it is beside its icon
++ (the pickers say the value they hold, with a caret); narrower, the icon carries it (the order's icon is the order,
++ the filter shows a dot while it filters) and the title says it in words. */
++#page-channel .ch-sec-h { align-items: center; gap: 10px; font-size: 1.15rem; line-height: 1.2; }
++#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; }
++#page-channel .ch-sec-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .74rem; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
++#page-channel .ch-ib { gap: 6px; border-color: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); }
++#page-channel .ch-ib .ch-ib-caret { display: none; font-size: .6rem; opacity: .7; }
++@media (min-width: 760px) {
++ #page-channel .ch-ib:has(.ch-ib-t, .sel-all-label) { display: inline-flex; width: auto; padding: 0 12px 0 10px; font-size: .82rem; font-weight: 650; }
++ #page-channel .ch-ib .ch-ib-t, #page-channel .ch-ib .sel-all-label { position: static; width: auto; height: auto; overflow: visible; clip: auto; max-width: 16ch; text-overflow: ellipsis; white-space: nowrap; }
++ #page-channel .ch-ib .ch-ib-t { overflow: hidden; }
++ #page-channel .ch-ib-pick .ch-ib-caret { display: inline-block; margin-left: -1px; }
++ #page-channel .ch-ib-pick.is-on::after { top: 5px; right: 5px; }
++}
++@media (min-width: 760px) and (max-width: 1100px) { #page-channel .ch-ib .ch-ib-more { display: none; } }
++@media (max-width: 1100px) { #page-channel .ch-ib .ch-ib-more { display: none; } }
++
++/* The robot's panel in a window (ask 356): the frame fills the window's body; the window sizes it. */
++.ch-bot-win { height: 100%; min-height: 200px; }
++.ch-bot-win iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
++@media (max-width: 768px) { #page-channel [data-bot-mode="window"] { display: none !important; } }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex c1516d40..efa27403 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -64,6 +64,7 @@
<button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>
<button type="button" role="menuitemradio" data-bot-mode="compact">A row under the video</button>
<button type="button" role="menuitemradio" data-bot-mode="panel">The full panel</button>
++ <button type="button" role="menuitemradio" data-bot-mode="window">In a window</button>
<button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button>
</div>
</div>
@@@ -126,6 +127,7 @@
<button type="button" data-bot-mode="overlay" title="On the video" aria-label="On the video"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><span>On video</span></button>
<button type="button" data-bot-mode="compact" title="A row under the video" aria-label="A row under the video"><i class="fa-solid fa-sliders" aria-hidden="true"></i><span>Row</span></button>
<button type="button" data-bot-mode="panel" title="The full panel" aria-label="The full panel"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span>Panel</span></button>
++ <button type="button" data-bot-mode="window" title="In a window you can move and size" aria-label="In a window"><i class="fa-solid fa-window-maximize" aria-hidden="true"></i><span>Window</span></button>
<button type="button" data-bot-mode="hidden" title="Hide the controls" aria-label="Hide the controls"><i class="fa-solid fa-eye-slash" aria-hidden="true"></i><span>Hide</span></button>
</div>
<a class="ch-bot-open" id="ch-bot-open" href="https://openvibe.bot/" target="_blank" rel="noopener" title="Open on openvibe.bot" aria-label="Open on openvibe.bot"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>
@@@ -416,7 +418,7 @@
<!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->
<section class="ch-tab-panel" id="ch-panel-agents" aria-labelledby="ch-h-agents">
-- <h2 class="ch-sec-h" id="ch-h-agents">Agents</h2>
++ <h2 class="ch-sec-h" id="ch-h-agents"><i class="fa-solid fa-robot ch-sec-ic" aria-hidden="true"></i>Agents</h2>
<p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p>
<h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3>
<div class="agent-schedules" id="ch-agents-schedules"></div>
@@@ -426,12 +428,12 @@
<!-- Videos -->
<section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">
-- <h2 class="ch-sec-h" id="ch-h-videos">Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>
++ <h2 class="ch-sec-h" id="ch-h-videos"><i class="fa-solid fa-video ch-sec-ic" aria-hidden="true"></i>Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>
<div class="ch-panel-bar">
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
-- <a id="ch-vods-video-link" class="ch-ib" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists" aria-label="On OpenVibe.Video"><i class="fa-solid fa-film" aria-hidden="true"></i></a>
-- <a class="ch-view-all ch-ib" id="ch-vods-all" href="#" title="View all videos"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all videos</span></a>
++ <a id="ch-vods-video-link" class="ch-ib" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists" aria-label="On OpenVibe.Video"><i class="fa-solid fa-film" aria-hidden="true"></i><span class="ch-ib-t">Video</span></a>
++ <a class="ch-view-all ch-ib" id="ch-vods-all" href="#" title="View all videos"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> videos</span></span></a>
</div>
<div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div>
<div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>
@@@ -439,9 +441,9 @@
<!-- Clips (clips OF this streamer's streams) -->
<section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">
-- <h2 class="ch-sec-h" id="ch-h-clips">Clips <span class="ch-sec-n" id="ch-clips-of-n" hidden></span></h2>
++ <h2 class="ch-sec-h" id="ch-h-clips"><i class="fa-solid fa-scissors ch-sec-ic" aria-hidden="true"></i>Clips <span class="ch-sec-n" id="ch-clips-of-n" hidden></span></h2>
<div class="ch-panel-bar">
-- <a class="ch-view-all ch-ib" id="ch-clips-of-all" href="#" title="View all clips"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all clips</span></a>
++ <a class="ch-view-all ch-ib" id="ch-clips-of-all" href="#" title="View all clips"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> clips</span></span></a>
</div>
<p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>
@@@ -456,12 +458,12 @@
<!-- Clips Taken (clips this streamer made of others) -->
<section class="ch-tab-panel" id="ch-panel-clips-taken" aria-labelledby="ch-h-clips-taken">
-- <h2 class="ch-sec-h" id="ch-h-clips-taken">Clips taken <span class="ch-sec-n" id="ch-clips-taken-n" hidden></span></h2>
++ <h2 class="ch-sec-h" id="ch-h-clips-taken"><i class="fa-solid fa-film ch-sec-ic" aria-hidden="true"></i>Clips taken <span class="ch-sec-n" id="ch-clips-taken-n" hidden></span></h2>
<p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<div class="ch-panel-bar">
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
-- <a class="ch-view-all ch-ib" id="ch-clips-taken-all" href="#" title="View all clips taken"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all clips taken</span></a>
++ <a class="ch-view-all ch-ib" id="ch-clips-taken-all" href="#" title="View all clips taken"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> clips taken</span></span></a>
</div>
<div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div>
<div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>
@@@ -469,11 +471,11 @@
<!-- Pastes -->
<section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">
-- <h2 class="ch-sec-h" id="ch-h-pastes">Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2>
++ <h2 class="ch-sec-h" id="ch-h-pastes"><i class="fa-solid fa-paste ch-sec-ic" aria-hidden="true"></i>Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2>
<div class="ch-panel-bar" id="ch-pastes-header">
<span id="ch-pastes-sort"></span>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
-- <a class="ch-view-all ch-ib" id="ch-pastes-all" href="#" title="View all pastes"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all pastes</span></a>
++ <a class="ch-view-all ch-ib" id="ch-pastes-all" href="#" title="View all pastes"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> pastes</span></span></a>
</div>
<div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div>
<div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>
@@@ -481,13 +483,13 @@
<!-- Media Request -->
<section class="ch-tab-panel" id="ch-panel-media" aria-labelledby="ch-h-media">
-- <h2 class="ch-sec-h" id="ch-h-media">Media requests</h2>
++ <h2 class="ch-sec-h" id="ch-h-media"><i class="fa-solid fa-photo-film ch-sec-ic" aria-hidden="true"></i>Media requests</h2>
<div id="ch-media-content"><div class="loading">Loading…</div></div>
</section>
<!-- Analytics -->
<section class="ch-tab-panel" id="ch-panel-analytics" aria-labelledby="ch-h-analytics">
-- <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics">Analytics</h2><span class="ch-fold-say">Show</span></summary>
++ <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics"><i class="fa-solid fa-chart-line ch-sec-ic" aria-hidden="true"></i>Analytics</h2><span class="ch-fold-say">Show</span></summary>
<div class="ch-panel-bar" id="ch-analytics-header">
<div class="ch-analytics-period-btns" id="ch-analytics-period-btns">
<button class="btn btn-sm btn-ghost active" data-days="7">7d</button>
@@@ -518,14 +520,14 @@
<!-- AI Timeline — the streamer's whole AI-observed history -->
<section class="ch-tab-panel" id="ch-panel-ai-timeline" aria-labelledby="ch-h-ai-timeline">
-- <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline">AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary>
++ <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline"><i class="fa-solid fa-clock-rotate-left ch-sec-ic" aria-hidden="true"></i>AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary>
<div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div>
</details>
</section>
<!-- About (weather is now an optional panel inside here) -->
<section class="ch-tab-panel" id="ch-panel-about" aria-labelledby="ch-h-about">
-- <h2 class="ch-sec-h" id="ch-h-about">About</h2>
++ <h2 class="ch-sec-h" id="ch-h-about"><i class="fa-solid fa-circle-info ch-sec-ic" aria-hidden="true"></i>About</h2>
<div id="ch-about-content" class="ch-about-content"></div>
</section>
</div>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 7c0f363f..3fab03a7 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -292,14 +292,14 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';
filterBar.innerHTML = `
<label class="ch-ib ch-ib-pick${currentChannelVodFilter !== null ? ' is-on' : ''}" title="${esc(currentChannelVodFilter !== null ? `Stream: ${msName || 'one stream'}` : 'All streams')}">
-- <i class="fa-solid fa-filter" aria-hidden="true"></i>
++ <i class="fa-solid fa-filter" aria-hidden="true"></i><span class="ch-ib-t">${esc(currentChannelVodFilter !== null ? (msName || 'One stream') : 'All streams')}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i>
<select class="ch-vods-filter-select" data-ov-native aria-label="Which stream" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)">
<option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>
${msOptions}
</select>
</label>
<label class="ch-ib ch-ib-pick" data-order="${esc(currentChannelVodOrder)}" title="${esc(orderName)}">
-- <i class="fa-solid ${currentChannelVodOrder === 'oldest' ? 'fa-arrow-down-short-wide' : 'fa-arrow-down-wide-short'}" aria-hidden="true"></i>
++ <i class="fa-solid ${{ oldest: 'fa-arrow-down-short-wide', views: 'fa-eye', peak_viewers: 'fa-users' }[currentChannelVodOrder] || 'fa-arrow-down-wide-short'}" aria-hidden="true"></i><span class="ch-ib-t">${esc(orderName)}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i>
<select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">
${orderOptions}
</select>
@@@ -3075,7 +3075,41 @@ function _applyBotEmbed(botEmbed, username) {
}
return frame;
}
-- const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', hidden: 'hidden' };
++ const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', window: 'in a window', hidden: 'hidden' };
++ // The panel in the shared window (ov-window.js beside the navbar's scripts): moved by its head, sized by its corner,
++ // its place and size kept per robot. Closing it puts the panel back under the video.
++ function closeWindow() {
++ const w = _applyBotEmbed.win;
++ if (!w) return;
++ _applyBotEmbed.win = null;
++ _applyBotEmbed.closingWin = true;
++ try { w.close(); } catch { /* already gone */ }
++ _applyBotEmbed.closingWin = false;
++ }
++ function withWindow(fn) {
++ if (window.OVWindow) return fn();
++ let sc = document.getElementById('ov-window-script');
++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-window-script'; sc.async = true; sc.src = '/shared/ov-window.js'; document.head.appendChild(sc); }
++ sc.addEventListener('load', () => { if (window.OVWindow) fn(); }, { once: true });
++ }
++ function openWindow() {
++ withWindow(() => {
++ if (_applyBotEmbed.mode !== 'window' || _applyBotEmbed.win) return;
++ const wrap = document.createElement('div');
++ wrap.className = 'ch-bot-win';
++ const w = window.OVWindow.open({
++ // It grows out of the switch's Window button (on a page load too, where nothing was pressed).
++ from: (cardSwitch && cardSwitch.querySelector && cardSwitch.querySelector('[data-bot-mode="window"]')) || undefined,
++ id: `ov-bot-${robotId}`, kind: 'panel', title: 'Robot', label: 'Robot controls', width: 440, body: wrap,
++ resizable: true, minWidth: 300, minHeight: 240,
++ onClose: () => { if (!_applyBotEmbed.closingWin && _applyBotEmbed.mode === 'window') { _applyBotEmbed.win = null; _applyBotEmbed.choose('panel'); } },
++ });
++ if (w.body) w.body.style.padding = '0';
++ if (w.el && !w.el.style.height) w.el.style.height = '540px';
++ _applyBotEmbed.win = w;
++ _applyBotEmbed.frame = mountIn(wrap, 'panel', false);
++ });
++ }
// The page knows the place (phone layout in channel.css) and, for the full panel on a phone, which tab is open.
// The Robot tab is open while the full panel is the place and the person has not gone back to Chat; any other place
// keeps the sheet on Chat, and the Robot tab offers the panel.
@@@ -3128,7 +3162,14 @@ function _applyBotEmbed(botEmbed, username) {
if (switchWrap) switchWrap.hidden = !robotId;
if (cardBar) cardBar.hidden = !robotId;
if (openLink && botEmbed && typeof botEmbed.url === 'string') openLink.href = botEmbed.url.replace(/\/embed$/, '');
-- if (mode === 'overlay') {
++ if (mode !== 'window') closeWindow();
++ if (mode === 'window') {
++ stopFrame();
++ if (slot) { clear(slot); slot.hidden = true; }
++ clear(box); box.hidden = true;
++ if (section) section.hidden = false;
++ openWindow();
++ } else if (mode === 'overlay') {
if (slot) {
slot.hidden = false;
_applyBotEmbed.frame = mountIn(slot, 'overlay', false);
@@@ -3237,6 +3278,7 @@ function _applyBotEmbed(botEmbed, username) {
render();
if (window.ov) ov.scope().onDispose(() => {
stopFrame();
++ closeWindow();
clear(slot);
pageClasses(null);
if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
ddiff --git a/public/js/bot-embed.js b/public/js/bot-embed.js
iindex 2ea74d81..29ba7c0a 100644
---- a/public/js/bot-embed.js
++++ b/public/js/bot-embed.js
@@@ -37,7 +37,8 @@
const PANEL_MODES = ['panel', 'overlay', 'compact'];
// Where a viewer puts the controls: over the video, a row under it (Bot's compact view), the full panel, or hidden.
-- const VIEW_MODES = ['overlay', 'compact', 'panel', 'hidden'];
++ // 'window' (ask 356, 2026-11-11: "can be popped out … and resized"): the full panel in the shared window (ov-window.js).
++ const VIEW_MODES = ['overlay', 'compact', 'panel', 'window', 'hidden'];
const CHOICE_KEY = 'ov:live:bot-controls';
function embedSrc(botEmbed, mode = null) {
@@@ -109,7 +110,10 @@
return phone ? 'overlay' : 'panel';
}
function effectiveMode(botEmbed, stored, opts) {
-- return validView(stored) || defaultMode(botEmbed, opts);
++ const v = validView(stored);
++ // A phone has no floating windows: its full panel is the Robot tab.
++ if (v === 'window' && opts && opts.phone) return 'panel';
++ return v || defaultMode(botEmbed, opts);
}
function readChoice(win, channel, robotId) {
try {
@@@ -211,6 +215,8 @@
// The row (Bot's compact view) is about 120 px: it keeps its own height, the panel at least MIN_HEIGHT.
const clamped = Math.max(mode === 'compact' ? COMPACT_MIN : MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));
try {
++ // In a window the window's size is the frame's: the panel scrolls inside it.
++ if (typeof frame.closest === 'function' && frame.closest('.ovw')) return;
const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;
const host = card || frame;
host.style.height = `${clamped}px`;
ddiff --git a/test/bot-embed-page.test.js b/test/bot-embed-page.test.js
iindex 94827351..98956b24 100644
---- a/test/bot-embed-page.test.js
++++ b/test/bot-embed-page.test.js
@@@ -126,7 +126,9 @@ async function check(name, fn) {
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'bogus'), 'overlay', 'a bad stored value is ignored');
assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'bogus' }, null), 'panel', 'a bad streamer default is panel');
// Ask 353 (2026-11-11): a fourth place, a row under the video; on a phone held upright the panel starts on the video.
-- assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'hidden']);
++ assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'window', 'hidden']);
++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'window'), 'window', 'a computer may pop the panel out');
++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'window', { phone: true }), 'panel', 'a phone keeps it in the Robot tab');
assert.strictEqual(BotEmbed.effectiveMode(SIM, null, { phone: true }), 'overlay', 'a phone starts with the controls on the video, so the chat keeps its room');
assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null, { phone: true }), 'compact', 'a streamer\'s row stays a row on a phone');
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, null, { phone: true }), 'overlay', 'the streamer\'s on-the-video stays on a phone');