openvibers/openvibe.live Commits

A robot channel on a phone, the chat as a sheet, and the sections' tools as icons (#797)

OP OpenVibers committed 2 days ago · ecc3a4f972 · 14 files, +429 −88

* A robot channel on a phone: the chat back in reach, the controls in one row of icons

On a phone the robot's full panel sat between the player and the chat in a screen-high column and
pushed the chat (and the channel line) out of reach; even without a robot the folded channel line
had grown to 252 px and the chat showed no messages at 390 x 844.

- The robot card is one head row: the name, the four places for the controls as icons (on the
  video, a row, the full panel, hidden; the chosen one says its name), and the robot's own page.
  Hidden keeps that row, so the controls come back from where they went.
- A fourth place, "a row under the video": OpenVibe.Bot's compact view (a small remote), about
  120 px. The streamer may make it the default (migration 0039 widens the check; an older release
  reads it as the panel).
- A phone held upright: the channel line comes right under the player; the controls start on the
  video (a streamer's row stays a row); the full panel takes the chat's place behind Robot and Chat
  tabs, its frame kept loaded while the chat is shown. The switch is the player's robot button.
- The folded channel line is who, live now, the title and its actions (a chevron; a tap opens the
  followers, alert, labels and links), and the goal bar is one slim row: the messages have room.

Ask 353.

* The phone's chat reaches the player, sizes with a grip, folds to a round button

The owner, with the first version open: "the chat should go all the way up to the video ... then
it can be collapsed ... a little circle in the corner ... resize how tall the chat is, both on
mobile and desktop".

- A phone upright, live: the chat is one sheet right under the player, with one header row (Chat
  and Robot tabs when there is a robot, its tools, the fold). The grip on its top edge (drag, or
  the arrow keys on it) sets its height and the channel line shows in the room it leaves; all the
  way up is the default again; most of the way down folds it to a round chat button.
- The Robot tab is the full panel in the same sheet; tapping it while the controls are elsewhere
  asks for the panel. The robot card's head row is the desktop's.
- Desktop with two streams live: the stream tabs bar stays under the navbar and the chat under the
  bar; its tools under the message box were 42 px below the window.

Ask 353.

* A channel's sections: the tools as icons in the heading's row, a recording that runs

The owner, on a channel's Videos: "Buttons need to be moved to where the title bar is, and the
filters need to use icons ... compact ... auto update how long they've been live ... updating
thumbnail".

- Videos, Clips, Clips taken and Pastes: the count sits beside the heading; the tools are 36 px
  icon buttons (40 on a touch screen) in the same row at every width: filter and order with the
  native picker laid over the icon (a phone opens its own list; the filter shows a dot while it
  filters), select, OpenVibe.Video, view all. Small press and hover motions.
- A recording in progress shows the stream's own picture (it had a camera placeholder), as a live
  card with a Recording pill whose dot breathes; its clock runs from the stream's start, and its
  picture is fetched again every 30 s (10 s under a pointer) while on screen and the tab is in front.
- Live's CSS no longer names the shared glyph class (glyph-class test).

Ask 355.

* The sections' View all keeps its words for readers and crawlers; the server renders the count beside the heading

Changes

ddiff --git a/migrations/0039_bot_controls_compact.sql b/migrations/0039_bot_controls_compact.sql
nnew file mode 100644
iindex 00000000..6039aae3
---- /dev/null
++++ b/migrations/0039_bot_controls_compact.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- The streamer's default place for a robot's controls may also be 'compact': a row under the video (ask 353,
++-- 2026-11-11). The release before this reads an unknown value as 'panel' (server/bot/embed.js controlMode), so it
++-- keeps working on a channel that chose the row.
++ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_bot_controls_mode;
++ALTER TABLE channels ADD CONSTRAINT channels_bot_controls_mode CHECK (bot_controls_mode IS NULL OR bot_controls_mode IN ('overlay', 'compact', 'panel'));
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 50a93126..5cfce97a 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1160,6 +1160,14 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
align-self: flex-start;
}
}
++/* Two streams live at once (the stream tabs bar above the player): the bar stays under the navbar and the chat under
++ the bar. Before, the chat started under the bar but was the navbar's height short of the window, so its last 42 px,
++ the tools under the message box, sat below the screen (found 2026-11-11). The sections' jump bar sticks under both. */
++@media (min-width: 1181px) {
++ #page-channel.has-live-tabs .live-stream-tabs { position: sticky; top: var(--navbar-h); z-index: 7; }
++ #page-channel.has-live-tabs .stream-layout > .chat-sidebar { top: calc(var(--navbar-h) + 42px); height: calc(100vh - var(--navbar-h) - 42px); }
++ #page-channel.has-live-tabs .ch-tabs-wrap { top: calc(var(--ovnav-h, 56px) + 42px); }
++}
.ch-stream-ai { margin-top: 8px; max-width: 760px; }
.cu-btn-sm { padding: 4px 10px; font-size: 0.78rem; }
.cu-btn-ghost { background: transparent; border: 1px solid var(--border); }
@@@ -2040,19 +2048,139 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
.ch-bot-mode-menu[hidden] { display: none; }
.ch-bot-mode-menu button { min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #fff; text-align: left; font-size: .8rem; }
.ch-bot-mode-menu button[aria-checked="true"] { background: color-mix(in srgb, var(--accent) 35%, transparent); }
--/* The card's switch and its one-line state when the controls are on the video. */
--.ch-bot-card-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid var(--border); }
--.ch-bot-card-bar[hidden] { display: none; }
--.ch-bot-card-label { color: var(--text-muted, var(--text-secondary)); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
--.ch-bot-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
--.ch-bot-switch button { min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }
--.ch-bot-switch button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent-strong, #fff); }
--.ch-bot-card-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; color: var(--text-secondary); font-size: .9rem; }
--.ch-bot-card-line[hidden] { display: none; }
--.ch-bot-card-line button { min-height: 36px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: transparent; color: var(--text-primary); font-size: .85rem; font-weight: 600; }
++/* The card (ask 353, 2026-11-11: "more compact, use icons and buttons in a smarter way"): one head row with the name,
++ the four places for the controls as icons (the chosen one says its name) and the robot's own page; then the frame,
++ which is the full panel or a row (Bot's compact view). On the video, or hidden, the card is just that row. */
#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }
--#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }
--#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
--#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 160px; max-height: 80vh; }
++#ch-bot-section[hidden] { display: none !important; }
++.ch-bot-head { display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 5px 6px 5px 14px; }
++.ch-bot-head[hidden] { display: none; }
++#ch-bot-section:has(#ch-bot-container:not([hidden])) > .ch-bot-head { border-bottom: 1px solid var(--border); }
++.ch-bot-head h3 { display: flex; align-items: center; gap: 9px; flex: 1 1 auto; min-width: 0; margin: 0; font-size: .9rem; font-weight: 700; color: var(--text-primary); }
++.ch-bot-head h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
++.ch-bot-tabs { display: none; flex: 1 1 auto; min-width: 0; gap: 4px; }
++.chat-header .ch-bot-tabs { margin-right: auto; }
++.ch-bot-tabs button { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); font: 700 .85rem/1 inherit; }
++.ch-bot-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text-primary); }
++.ch-bot-switch { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
++.ch-bot-switch button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 36px; min-height: 34px; padding: 0 10px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }
++.ch-bot-switch button:hover { color: var(--text-primary); }
++.ch-bot-switch button[aria-pressed="true"] { background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); }
++.ch-bot-open { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 10px; color: var(--text-secondary); text-decoration: none; }
++.ch-bot-open:hover, .ch-bot-open:focus-visible { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
++@media (max-width: 1100px) {
++ .ch-bot-switch button:not([aria-pressed="true"]) { width: 36px; padding: 0; }
++ .ch-bot-switch button:not([aria-pressed="true"]) span { display: none; }
++}
++#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 112px; max-height: 80vh; }
++#ch-bot-container[hidden] { display: none; }
#ch-bot-container iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
++#page-channel.ch-bot-compact #ch-bot-container { height: 120px; resize: none; }
@media (max-width: 768px) { #ch-bot-section { margin: 8px; } #ch-bot-container { height: clamp(220px, 44vh, 420px); resize: none; } }
++
++/* A phone held upright, live (ask 353: "how do you even open the chat anymore?"; then, with the first version open:
++ "the chat should go all the way up to the video … then it can be collapsed … a little circle in the corner … resize
++ how tall the chat is"). The column under the player is one sheet:
++ - the chat fills it, right up to the player, with one header row: Chat and Robot tabs (with a robot), its tools and
++ the fold. The grip on its top edge sets its height (or the arrow keys on it): the channel line shows in the room it
++ leaves; all the way up is the default again; most of the way down folds it to a round chat button in the corner.
++ - the robot: over the video (the default here), a row just under the player, or the full panel in the Robot tab.
++ - folded, the page reads as before: the player, the channel line, the robot's card, the sections.
++ Where the robot's controls go is the player's robot button. A portrait stream keeps its own layout. */
++@media (max-width: 768px) and (orientation: portrait) {
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main { display: contents; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .video-container { order: 0; flex: none; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-captions, .ch-info-bar, .ch-weather-widget) { order: 1; flex: none; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > #ch-bot-section { order: 2; flex: none; margin: 0; border-width: 1px 0 0; border-radius: 0; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { order: 3; }
++ /* All the way up: the channel line waits under the chat (fold or lower the chat to see it). */
++ #page-channel.ch-is-live:not(.ch-chat-sized):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-info-bar, .ch-weather-widget) { display: none !important; }
++ /* A height of its own: the chat keeps it, the channel line takes the rest above it. */
++ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-info-bar { flex: 1 1 0; min-height: 0; overflow-y: auto; align-items: flex-start; align-content: flex-start; }
++ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-weather-widget { display: none !important; }
++ #page-channel.ch-is-live.ch-chat-sized.page .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { flex: 0 1 var(--chat-sheet-h, 60%); min-height: 120px; }
++
++ /* The chat's header: one row with the grip above it. */
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { display: block !important; height: 16px; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip::after { top: 5px; opacity: .8; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 12px; min-height: 52px; }
++ #page-channel.ch-is-live.ch-has-bot .chat-header .ch-chat-title { display: none; }
++ #page-channel.ch-is-live.ch-has-bot .ch-bot-tabs { display: flex; flex: 0 1 auto; }
++
++ /* The robot's place. Its card's own head row is the desktop's; here the tabs and the player's button do that. */
++ #page-channel.ch-is-live .ch-bot-head { display: none !important; }
++ #page-channel.ch-is-live:not(.ch-bot-panel):not(.ch-bot-compact) #ch-bot-section { display: none !important; }
++ #page-channel.ch-is-live.ch-bot-compact #ch-bot-container { height: 120px; }
++ /* The Robot tab: the panel takes the sheet under the chat's header, and scrolls inside its frame. */
++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-info-bar, .ch-weather-widget) { display: none !important; }
++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > #ch-bot-section { order: 4; flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; border: 0; }
++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) #ch-bot-container { flex: 1 1 0; min-height: 0; height: auto !important; max-height: none; }
++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed).page .stream-layout > .chat-sidebar { flex: none; min-height: 0; }
++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :not(.chat-header) { display: none !important; }
++ /* The Chat tab: the panel's frame waits, still loaded (a driver keeps their place). */
++ #page-channel.ch-is-live.ch-bot-panel.ch-bot-chat:not(.chat-collapsed) #ch-bot-section { display: none !important; }
++
++ /* Folded: the round chat button in the corner brings the sheet back. */
++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen:not([hidden]) { width: 56px; height: 56px; padding: 0; gap: 0; justify-content: center; border-radius: 50%; }
++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen :is(.fa-comments, svg) { font-size: 22px; width: 22px; height: 22px; }
++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen .fa-angles-left { display: none !important; }
++
++ /* The channel line, folded: who, live now and the title, with its actions; a tap on it (the chevron) opens the
++ followers, the alert, the labels and the links. The goal bar is one slim row. */
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar:not(.ch-info-open) :is(.ch-username, .ch-meta, .ch-social-pills, .stream-labels) { display: none !important; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar { row-gap: 8px; padding: 8px 12px; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar-top h2::after { content: ""; display: inline-block; flex: none; width: 6px; height: 6px; margin: 0 0 3px 8px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); opacity: .6; transition: transform .2s; }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar.ch-info-open .ch-info-bar-top h2::after { transform: translateY(3px) rotate(225deg); }
++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar-right :is(.ch-action, .ovk-ask) { height: 40px; min-height: 40px; }
++ #page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 4px 6px 0; }
++ #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 32px; }
++ #page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 4px; }
++}
++
++/* ── A section's head, compact (ask 355, 2026-11-11: "buttons need to be moved to where the title bar is … the filters
++ need to use icons … smaller, compact … button icons with nice custom animations"). The count sits beside the
++ heading; the tools are 36 px icon buttons (40 on a touch screen) in the same row, at every width: filter and order
++ carry the native picker over the icon (a phone opens its own list), then select, OpenVibe.Video, and View all. ─── */
++#page-channel .ch-sec-h { display: flex; align-items: baseline; gap: 8px; }
++#page-channel .ch-sec-n { font-size: .78rem; font-weight: 700; color: var(--text-muted, var(--text-secondary)); font-variant-numeric: tabular-nums; letter-spacing: 0; }
++#page-channel .ch-sec-n[hidden] { display: none; }
++#page-channel .ch-panel-bar { gap: 4px; }
++#page-channel .ch-panel-bar > .ch-vods-filter-bar { display: inline-flex; gap: 4px; margin: 0; }
++#page-channel .ch-ib {
++ position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; min-height: 0; padding: 0;
++ border: 1px solid transparent; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);
++ color: var(--text-secondary); cursor: pointer; text-decoration: none; font-size: .95rem;
++ transition: background-color var(--ov-dur-1, .12s) var(--ov-ease-out, ease), color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease);
++}
++#page-channel .ch-ib:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary, #fff) 11%, transparent); }
++#page-channel .ch-ib:active { transform: scale(.9); }
++#page-channel .ch-ib:focus-visible, #page-channel .ch-ib:has(select:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
++#page-channel .ch-ib > :is(i, svg) { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, cubic-bezier(.22,1,.36,1)); pointer-events: none; }
++#page-channel .ch-ib:hover > :is(i, svg) { transform: translateY(-1px); }
++#page-channel .ch-view-all.ch-ib:hover > :is(i, svg) { transform: scale(1.12); }
++#page-channel .ch-ib .sel-all-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++/* The picker over the icon: the whole button is the select, invisible, so the platform's own list opens. */
++#page-channel .ch-ib-pick select { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; margin: 0; padding: 0; border: 0; opacity: 0; cursor: pointer; font-size: 16px; -webkit-appearance: none; appearance: none; }
++#page-channel .ch-ib-pick.is-on { color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 16%, transparent); }
++#page-channel .ch-ib-pick.is-on::after { content: ""; position: absolute; top: 6px; right: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg-primary); }
++#page-channel .ch-ib-pick[data-order="views"] > :is(i, svg), #page-channel .ch-ib-pick[data-order="peak_viewers"] > :is(i, svg) { color: var(--accent-light, var(--accent)); }
++@media (hover: none) { #page-channel .ch-ib { width: 40px; height: 40px; } }
++@media (max-width: 600px) {
++ /* The tools stay in the heading's row: they are icons now. */
++ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) { grid-template-columns: minmax(0, 1fr) auto; }
++ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 2; grid-row: 1; justify-content: flex-end; }
++ #page-channel .ch-view-all.ch-ib { min-height: 0; }
++}
++
++/* A recording in progress: the stream's own picture as a live card (style.css .stream-card.is-live), a Recording pill
++ whose dot breathes, the clock that runs, and a play mark on hover. */
++#page-channel .ch-rec-card { border-width: 1px; }
++#page-channel .ch-rec-pill { position: absolute; top: 8px; left: 8px; z-index: 2; display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px 0 8px; border-radius: 999px; background: var(--live-red, #e5484d); color: #fff; font-size: .7rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
++#page-channel .ch-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: chRecBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
++@keyframes chRecBreathe { 50% { opacity: .35; transform: scale(.8); } }
++html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
++#page-channel .ch-rec-time { font-variant-numeric: tabular-nums; }
++#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; }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 0b6348fe..c1516d40 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -62,7 +62,8 @@
</button>
<div class="ch-bot-mode-menu" id="ch-bot-mode-menu" role="menu" hidden>
<button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>
-- <button type="button" role="menuitemradio" data-bot-mode="panel">Under 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="hidden">Hidden</button>
</div>
</div>
@@@ -116,19 +117,18 @@
</div>
</div>
<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
++ <!-- One head row (ask 353): the name, the four places for the controls, and the robot's own page. Hidden
++ keeps this row. On a phone the row steps aside: the Chat/Robot tabs are in the chat's header. -->
<section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
-- <h3 id="ch-bot-heading">Robot</h3>
-- <div class="ch-bot-card-bar" id="ch-bot-card-bar" hidden>
-- <span class="ch-bot-card-label">Controls</span>
-- <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Robot controls">
-- <button type="button" data-bot-mode="overlay">On the video</button>
-- <button type="button" data-bot-mode="panel">Under the video</button>
-- <button type="button" data-bot-mode="hidden">Hidden</button>
++ <div class="ch-bot-head" id="ch-bot-card-bar" hidden>
++ <h3 id="ch-bot-heading">Robot</h3>
++ <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Where the robot's controls go">
++ <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="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>
-- </div>
-- <div class="ch-bot-card-line" id="ch-bot-card-line" hidden>
-- <span>Robot · controls are on the video</span>
-- <button type="button" data-bot-show-panel>Show panel</button>
++ <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>
</div>
<div id="ch-bot-container"></div>
</section>
@@@ -189,7 +189,12 @@
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-header">
<button type="button" class="chat-settings-btn ch-chat-collapse" onclick="toggleChannelChat(true)" title="Hide chat" aria-label="Hide chat"><i class="fa-solid fa-angles-right"></i></button>
-- <span>STREAM CHAT</span>
++ <span class="ch-chat-title">STREAM CHAT</span>
++ <!-- A channel with a robot, on a phone: Chat and Robot share this sheet (app-channel.js _applyBotEmbed). -->
++ <div class="ch-bot-tabs" id="ch-bot-tabs" role="tablist" aria-label="Chat or robot">
++ <button type="button" role="tab" data-bot-tab="chat" aria-selected="true"><i class="fa-solid fa-comments" aria-hidden="true"></i><span>Chat</span></button>
++ <button type="button" role="tab" data-bot-tab="robot" aria-selected="false"><i class="fa-solid fa-robot" aria-hidden="true"></i><span>Robot</span></button>
++ </div>
<div class="chat-header-actions">
<button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users">
<i class="fa-solid fa-users"></i>
@@@ -421,12 +426,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</h2>
++ <h2 class="ch-sec-h" id="ch-h-videos">Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>
<div class="ch-panel-bar">
-- <a class="ch-view-all" id="ch-vods-all" href="#">View all videos</a>
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
-- <a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>
-- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
++ <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>
</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>
@@@ -434,8 +439,10 @@
<!-- 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</h2>
-- <div class="ch-panel-bar"><a class="ch-view-all" id="ch-clips-of-all" href="#">View all clips</a></div>
++ <h2 class="ch-sec-h" id="ch-h-clips">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>
++ </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>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
@@@ -449,12 +456,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</h2>
++ <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>
<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">
-- <a class="ch-view-all" id="ch-clips-taken-all" href="#">View all clips taken</a>
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
-- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
++ <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>
</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>
@@@ -462,11 +469,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</h2>
++ <h2 class="ch-sec-h" id="ch-h-pastes">Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2>
<div class="ch-panel-bar" id="ch-pastes-header">
-- <a class="ch-view-all" id="ch-pastes-all" href="#">View all pastes</a>
<span id="ch-pastes-sort"></span>
-- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
++ <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>
</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>
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex fbf38c5f..889cf0a6 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -136,7 +136,8 @@
<label class="settings-row-label" for="dash-bot-controls-mode">Controls start</label>
<div class="settings-row-content">
<select id="dash-bot-controls-mode">
-- <option value="panel">Under the video</option>
++ <option value="panel">The full panel under the video</option>
++ <option value="compact">A row under the video</option>
<option value="overlay">On the video</option>
</select>
<p class="muted" style="font-size:0.8rem;margin:4px 0 0">Each viewer can move them or hide them; this is where they start before anyone chooses.</p>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex ce6b7afa..7c0f363f 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -198,6 +198,70 @@ function _channelRail(grid, offset, count, total, loadNext, old = []) {
grid._railObserver = observer;
observer.observe(sentinel);
}
++// A section's count beside its heading (ask 355: "View all (96)" was a button of its own).
++function _secCount(id, total) {
++ const n = document.getElementById(id);
++ if (!n) return;
++ const v = Number(total) || 0;
++ n.textContent = v > 999 ? `${Math.floor(v / 100) / 10}k` : String(v);
++ n.hidden = v <= 0;
++}
++
++// Recordings in progress (ask 355, "auto update how long they've been live … updating thumbnail … preview"): one clock
++// for every such card on the page, ticking each second while one is on screen, and the stream's picture fetched again
++// every 30 s (10 s while a pointer rests on it), only while visible and the tab is in front. Nothing runs without them.
++let _chRecTimer = 0, _chRecIO = null;
++const _chRecSeen = new Set();
++function _chRecSince(raw) {
++ const s = String(raw || '');
++ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);
++ return Number.isFinite(t) ? t : 0;
++}
++function _chRecTick() {
++ // The clock stops when no recording card is left on the page; a card off screen or a tab behind costs nothing.
++ if (!document.querySelector('.ch-rec-card')) { clearInterval(_chRecTimer); _chRecTimer = 0; _chRecSeen.clear(); return; }
++ const cards = [..._chRecSeen].filter((c) => c.isConnected);
++ if (!cards.length || document.hidden) return;
++ const now = Date.now();
++ for (const card of cards) {
++ const time = card.querySelector('.ch-rec-time[data-since] .ch-rec-t');
++ const since = time ? _chRecSince(time.closest('.ch-rec-time').dataset.since) : 0;
++ if (since) time.textContent = formatDuration(Math.max(0, Math.floor((now - since) / 1000)));
++ const every = card.matches(':hover') ? 10000 : 30000;
++ if (now - (card._ovPicAt || now) >= every && !card._ovPicBusy) _chRecPicture(card);
++ if (!card._ovPicAt) card._ovPicAt = now;
++ }
++}
++async function _chRecPicture(card) {
++ const id = card.dataset.streamId;
++ if (!id) return;
++ card._ovPicBusy = true;
++ try {
++ const s = await api(`/streams/${encodeURIComponent(id)}`);
++ const url = s && s.thumbnail_url;
++ const img = card.querySelector('.stream-card-thumb > img');
++ if (url && img && img.getAttribute('src') !== url) {
++ // The next picture loads off screen, then takes the old one's place without a blank frame.
++ const next = new Image();
++ next.onload = () => { img.src = url; img.style.display = ''; const ph = img.nextElementSibling; if (ph && ph.tagName === 'I') ph.style.display = 'none'; };
++ next.src = url;
++ }
++ if (s && !s.is_live) _chRecSeen.delete(card);
++ } catch { /* the next tick tries again */ }
++ card._ovPicAt = Date.now();
++ card._ovPicBusy = false;
++}
++function _chRecWatch(root) {
++ const cards = root ? [...root.querySelectorAll('.ch-rec-card')] : [];
++ if (!cards.length) return;
++ if (typeof IntersectionObserver === 'function') {
++ if (!_chRecIO) _chRecIO = new IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) _chRecSeen.add(e.target); else _chRecSeen.delete(e.target); }));
++ cards.forEach((c) => _chRecIO.observe(c));
++ } else cards.forEach((c) => _chRecSeen.add(c));
++ if (!_chRecTimer) _chRecTimer = setInterval(_chRecTick, 1000);
++ if (window.ov && ov.scope) ov.scope().onDispose(() => { clearInterval(_chRecTimer); _chRecTimer = 0; _chRecSeen.clear(); if (_chRecIO) { _chRecIO.disconnect(); _chRecIO = null; } });
++}
++
// Channel VOD filter/sort state
let currentChannelVodFilter = null; // numeric managed stream id or null = all
let currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers
@@@ -222,14 +286,24 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
].map(([val, label]) =>
`<option value="${val}" ${currentChannelVodOrder === val ? 'selected' : ''}>${label}</option>`
).join('');
++ // Ask 355 (2026-11-11, "use icons, buttons"): two icon buttons in the head row, each with the native picker laid
++ // over it, so a phone opens its own list and a computer its own menu; the filter shows a dot while it filters.
++ const msName = (currentChannelManagedStreams.find((ms) => ms.id === currentChannelVodFilter) || {}).title;
++ const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';
filterBar.innerHTML = `
-- <select class="ch-vods-filter-select" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)">
-- <option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>
-- ${msOptions}
-- </select>
-- <select class="ch-vods-filter-select" onchange="setChannelVodOrder(this.value)">
-- ${orderOptions}
-- </select>
++ <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>
++ <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>
++ <select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">
++ ${orderOptions}
++ </select>
++ </label>
`;
filterBar.style.display = '';
} else if (filterBar) {
@@@ -257,17 +331,22 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const liveVod = await api(`/vods/stream/${ls.id}/live`);
if (liveVod && liveVod.vod) {
const v = liveVod.vod;
++ // Ask 355: a recording in progress shows the stream's own picture (the recording has none until it
++ // ends), refreshed while it is on screen; its clock runs from the stream's start (_chRecWatch).
++ const pic = ls.thumbnail_url || v.thumbnail_url;
++ const bg = typeof _liveBg === 'function' ? _liveBg(pic) : '';
liveVodHtml += `
-- <a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative">
++ <a class="stream-card ch-rec-card is-live" data-stream-id="${esc(String(ls.id))}"${bg ? ` style="--live-bg:${esc(bg)}"` : ''} href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
<div class="stream-card-thumb">
-- ${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
++ ${thumbImg(pic, 'fa-video', v.title, ls.thumbnail_url ? null : `/api/thumbnails/generate/vod/${v.id}`)}
${audioChip(v)}
-- <span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span>
-- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || 0)}</span>
++ <span class="ch-rec-pill"><span class="ch-rec-dot" aria-hidden="true"></span>Recording</span>
++ <span class="stream-card-viewers ch-rec-time"${ls.started_at ? ` data-since="${esc(ls.started_at)}"` : ''}><i class="fa-solid fa-clock" aria-hidden="true"></i> <span class="ch-rec-t">${formatDuration(v.duration_seconds || 0)}</span></span>
++ <span class="ch-rec-watch" aria-hidden="true"><i class="fa-solid fa-play"></i></span>
</div>
<div class="stream-card-info">
<div class="stream-card-title">${esc(v.title || 'Live Recording')}</div>
-- <div class="stream-card-streamer muted">In progress — ${esc(ls.title || 'Live Stream')}</div>
++ <div class="stream-card-streamer muted">Recording now · ${esc(ls.title || 'Live Stream')}</div>
</div>
</a>`;
}
@@@ -299,8 +378,10 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
}
_channelRail(vodsGrid, offset, vods.length, total, () => setChannelVodsPage(page + 1), oldCards);
++ _chRecWatch(vodsGrid);
const all = document.getElementById('ch-vods-all');
-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=videos`; all.textContent = `View all (${total})`; }
++ if (all) all.href = `/@${encodeURIComponent(username)}?section=videos`;
++ _secCount('ch-vods-n', total);
document.getElementById('ch-vods-pagination').style.display = 'none';
_selSyncAllBtns();
}
@@@ -462,7 +543,8 @@ function _renderClipsTakenGrid(username, data) {
const page = Math.floor((data.offset || 0) / CLIPS_TAKEN_PAGE_SIZE) + 1;
_channelRail(grid, data.offset || 0, clips.length, data.total || 0, () => setClipsTakenPage(page + 1), oldCards);
const all = document.getElementById('ch-clips-taken-all');
-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips-taken`; all.textContent = `View all (${data.total || 0})`; }
++ if (all) all.href = `/@${encodeURIComponent(username)}?section=clips-taken`;
++ _secCount('ch-clips-taken-n', data.total || 0);
document.getElementById('ch-clips-pagination').style.display = 'none';
_selSyncAllBtns();
}
@@@ -510,7 +592,8 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
_channelRail(grid, offset, clips.length, total, () => setChannelClipsOfPage(page + 1), oldCards);
const all = document.getElementById('ch-clips-of-all');
-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips`; all.textContent = `View all (${total})`; }
++ if (all) all.href = `/@${encodeURIComponent(username)}?section=clips`;
++ _secCount('ch-clips-of-n', total);
document.getElementById('ch-clips-of-pagination').style.display = 'none';
}
@@@ -650,7 +733,8 @@ async function loadChannelPastes(username = currentChannelUsername, { append = f
const total = Number(data.total) || pastes.length;
_channelRail(grid, offset, pastes.length, total, () => loadChannelPastes(username, { append: true }), oldCards);
const all = document.getElementById('ch-pastes-all');
-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=pastes`; all.textContent = `View all (${total})`; }
++ if (all) all.href = `/@${encodeURIComponent(username)}?section=pastes`;
++ _secCount('ch-pastes-n', total);
if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
_selSyncAllBtns();
} catch { /* silent */ }
@@@ -2938,7 +3022,11 @@ function _applyBotEmbed(botEmbed, username) {
const menuBtn = document.getElementById('ch-bot-mode-btn');
const cardBar = document.getElementById('ch-bot-card-bar');
const cardSwitch = document.getElementById('ch-bot-card-switch');
-- const line = document.getElementById('ch-bot-card-line');
++ const tabs = document.getElementById('ch-bot-tabs');
++ const openLink = document.getElementById('ch-bot-open');
++ const pageEl = document.getElementById('page-channel');
++ // A phone held upright: the full panel shares the chat's place behind two tabs, and the default is a row.
++ const phoneMq = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 768px) and (orientation: portrait)') : null;
const player = document.getElementById('video-container');
const robotId = botEmbed && botEmbed.robot_id ? botEmbed.robot_id : null;
@@@ -2960,7 +3048,7 @@ function _applyBotEmbed(botEmbed, username) {
_applyBotEmbed.setBar = setBar;
function mountIn(container, mode, toggleSection) {
stopFrame();
-- const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'overlay' ? 'overlay' : null, toggleSection });
++ const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'panel' ? null : mode, toggleSection });
if (frame && typeof BotEmbed.listen === 'function') {
_botEmbedStop = BotEmbed.listen({
frame, botEmbed,
@@@ -2975,8 +3063,8 @@ function _applyBotEmbed(botEmbed, username) {
stopFrame();
clear(slot); clear(box);
if (slot) slot.hidden = true;
-- if (line) line.hidden = true;
if (section) section.hidden = true;
++ pageClasses(null);
},
onOpenPanel: () => _applyBotEmbed.choose('panel'),
onFocusPlayer: () => {
@@@ -2987,7 +3075,28 @@ function _applyBotEmbed(botEmbed, username) {
}
return frame;
}
-- const MODE_NAME = { overlay: 'on the video', panel: 'under the video', hidden: 'hidden' };
++ const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', hidden: 'hidden' };
++ // 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.
++ function pageClasses(mode) {
++ if (!pageEl || !pageEl.classList) return;
++ const robotPane = mode === 'panel' && _applyBotEmbed.tab !== 'chat';
++ pageEl.classList.toggle('ch-has-bot', !!mode);
++ pageEl.classList.toggle('ch-bot-panel', mode === 'panel');
++ pageEl.classList.toggle('ch-bot-compact', mode === 'compact');
++ pageEl.classList.toggle('ch-bot-chat', mode === 'panel' && !robotPane);
++ if (tabs && tabs.querySelectorAll) {
++ for (const b of tabs.querySelectorAll('[data-bot-tab]')) b.setAttribute('aria-selected', String((b.dataset.botTab === 'robot') === robotPane));
++ }
++ }
++ function setTab(tab) {
++ _applyBotEmbed.tab = tab === 'chat' ? 'chat' : 'robot';
++ pageClasses(_applyBotEmbed.mode);
++ // A folded chat comes back with its tab.
++ if (pageEl && pageEl.classList && pageEl.classList.contains('chat-collapsed') && typeof toggleChannelChat === 'function') toggleChannelChat(false);
++ }
++ _applyBotEmbed.setTab = setTab;
function closeMenu() {
if (!menu) return;
menu.hidden = true;
@@@ -3012,12 +3121,13 @@ function _applyBotEmbed(botEmbed, username) {
}
function render() {
if (typeof BotEmbed === 'undefined') return;
-- const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId));
++ const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId), { phone: !!(phoneMq && phoneMq.matches) });
_applyBotEmbed.mode = mode;
_applyBotEmbed.frame = null;
_applyBotEmbed.botEmbed = botEmbed;
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 (slot) {
slot.hidden = false;
@@@ -3025,27 +3135,27 @@ function _applyBotEmbed(botEmbed, username) {
}
clear(box);
box.hidden = true;
-- if (line) line.hidden = false;
if (section) section.hidden = false;
-- } else if (mode === 'panel') {
++ } else if (mode === 'panel' || mode === 'compact') {
if (slot) { clear(slot); slot.hidden = true; }
-- if (line) line.hidden = true;
box.hidden = false;
if (section) section.hidden = false;
-- _applyBotEmbed.frame = mountIn(box, 'panel', true);
++ _applyBotEmbed.frame = mountIn(box, mode, true);
} else {
++ // Hidden: no frame, and the head row stays so the controls can come back from where they went.
stopFrame();
clear(box); clear(slot);
box.hidden = true;
if (slot) slot.hidden = true;
-- if (line) line.hidden = true;
-- if (section) section.hidden = true;
++ if (section) section.hidden = !robotId;
}
++ pageClasses(mode);
sync(mode);
}
function choose(mode, persist = true) {
if (typeof BotEmbed === 'undefined' || !BotEmbed.validView(mode)) return;
if (persist && robotId && BotEmbed.writeChoice) BotEmbed.writeChoice(window, username, robotId, mode);
++ if (mode === 'panel') setTab('robot'); // asked for the panel: show it, on a phone too
closeMenu();
render();
}
@@@ -3081,12 +3191,27 @@ function _applyBotEmbed(botEmbed, username) {
if (b) _applyBotEmbed.choose(b.dataset.botMode);
});
}
-- if (line && line.dataset.botBound !== '1') {
-- line.dataset.botBound = '1';
-- line.addEventListener('click', (e) => {
-- if (e.target && e.target.closest && e.target.closest('[data-bot-show-panel]')) _applyBotEmbed.choose('panel');
++ if (tabs && tabs.dataset && tabs.dataset.botBound !== '1') {
++ tabs.dataset.botBound = '1';
++ tabs.addEventListener('click', (e) => {
++ const b = e.target && e.target.closest ? e.target.closest('[data-bot-tab]') : null;
++ if (!b) return;
++ // Robot asks for the full panel when the controls are somewhere else (on the video, a row, hidden).
++ if (b.dataset.botTab === 'robot' && _applyBotEmbed.mode !== 'panel') _applyBotEmbed.choose('panel');
++ else _applyBotEmbed.setTab(b.dataset.botTab);
++ });
++ }
++ // Turning the phone (or a window crossing the phone width) changes the default; a viewer's own choice stays.
++ if (phoneMq && typeof phoneMq.addEventListener === 'function' && !_applyBotEmbed.mqBound) {
++ _applyBotEmbed.mqBound = true;
++ phoneMq.addEventListener('change', () => {
++ const be = _applyBotEmbed.botEmbed;
++ if (!be || typeof BotEmbed === 'undefined' || !_applyBotEmbed.render) return;
++ if (BotEmbed.readChoice(window, _applyBotEmbed.username, be.robot_id)) return;
++ _applyBotEmbed.render();
});
}
++ _applyBotEmbed.username = username;
// The pointer on the page (outside the clipped controls) wakes the overlay's idle fade.
if (player && player.dataset.botActivity !== '1') {
player.dataset.botActivity = '1';
@@@ -3113,6 +3238,7 @@ function _applyBotEmbed(botEmbed, username) {
if (window.ov) ov.scope().onDispose(() => {
stopFrame();
clear(slot);
++ pageClasses(null);
if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
else clear(box);
if (_applyBotEmbed.barObserver) { _applyBotEmbed.barObserver.disconnect(); _applyBotEmbed.barObserver = null; }
ddiff --git a/public/js/bot-embed.js b/public/js/bot-embed.js
iindex 7fb0b3a4..2ea74d81 100644
---- a/public/js/bot-embed.js
++++ b/public/js/bot-embed.js
@@@ -36,7 +36,8 @@
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
const PANEL_MODES = ['panel', 'overlay', 'compact'];
-- const VIEW_MODES = ['overlay', 'panel', 'hidden'];
++ // 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'];
const CHOICE_KEY = 'ov:live:bot-controls';
function embedSrc(botEmbed, mode = null) {
@@@ -99,9 +100,16 @@
return `${CHOICE_KEY}:${String(channel || '')}:${String(robotId || '')}`;
}
function validView(mode) { return VIEW_MODES.includes(mode) ? mode : null; }
-- function defaultMode(botEmbed) { return botEmbed && botEmbed.controls_mode === 'overlay' ? 'overlay' : 'panel'; }
-- function effectiveMode(botEmbed, stored) {
-- return validView(stored) || defaultMode(botEmbed);
++ // The streamer's default, except that on a phone held upright the full panel starts on the video (ask 353,
++ // 2026-11-11: the panel under the player took the whole screen and the chat could not be reached; Maticus, 11-10:
++ // the stick and the head slider over the stream "would be great for phone"). A streamer's row stays a row.
++ function defaultMode(botEmbed, { phone = false } = {}) {
++ const m = botEmbed && botEmbed.controls_mode;
++ if (m === 'overlay' || m === 'compact') return m;
++ return phone ? 'overlay' : 'panel';
++ }
++ function effectiveMode(botEmbed, stored, opts) {
++ return validView(stored) || defaultMode(botEmbed, opts);
}
function readChoice(win, channel, robotId) {
try {
@@@ -161,6 +169,7 @@
// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.
const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
const MIN_HEIGHT = 160;
++ const COMPACT_MIN = 96;
const MAX_HEIGHT = 720;
function pageTheme(win) {
@@@ -196,10 +205,11 @@
// The panel's height: it measures its content (a ResizeObserver on its end, at most 5×/s) and the page sets
// the card around the frame to it, between MIN_HEIGHT and MAX_HEIGHT. Once it has spoken the card is no
// longer hand-resizable (#ch-bot-container's resize handle in public/css/features/channel.css).
-- const setHeight = (value) => {
++ const setHeight = (value, mode) => {
const height = Number(value);
if (!Number.isFinite(height) || height <= 0) return;
-- const clamped = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));
++ // 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 {
const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;
const host = card || frame;
@@@ -301,7 +311,7 @@
if (d.robot_id == null || d.robot_id === robotId) {
// An overlay fills the player; its clip keeps the presses outside the controls going through.
if (d.mode === 'overlay') applyClip(frame, d);
-- else { applyClip(frame, null); setHeight(d.height); }
++ else { applyClip(frame, null); setHeight(d.height, d.mode); }
if (typeof onSize === 'function') onSize(d);
}
return;
ddiff --git a/public/js/channel-chat-resize.js b/public/js/channel-chat-resize.js
iindex 07fbd222..ecf01dbb 100644
---- a/public/js/channel-chat-resize.js
++++ b/public/js/channel-chat-resize.js
@@@ -6,6 +6,10 @@
sheet 1180px and narrower, portrait: the bottom sheet; the grip on its top edge sets the height, and
letting go most of the way down closes the sheet
++ A phone held upright while a stream is live (ask 353, 2026-11-11): the chat is docked under the player and starts
++ all the way up to it; its grip sets how tall it is (the channel line shows in the room it leaves), all the way up
++ again is the default, and letting go most of the way down folds it to the round chat button.
++
Double-click or double-tap a grip (or press Enter on it) for the default size; arrow keys move it 24px.
Each layout remembers its own size (localStorage), applied as CSS variables on #page-channel, which
public/css/features/channel.css reads: --chat-w, --chat-ow, --chat-sheet-h.
@@@ -14,12 +18,29 @@
'use strict';
const WIDE = window.matchMedia('(min-width: 1181px)');
const LAND = window.matchMedia('(orientation: landscape)');
++ const PHONE = window.matchMedia('(max-width: 768px) and (orientation: portrait)');
++ const page = () => document.getElementById('page-channel');
++ // Docked: a phone upright, live, and not a portrait stream (that one keeps its chat over the picture).
++ const docked = () => {
++ const p = page();
++ return PHONE.matches && !!p && p.classList.contains('ch-is-live') && !p.querySelector('.video-container.is-vertical');
++ };
++ // The docked chat's room: from the player's bottom (or the robot's row under it) to the column's end.
++ function dockRoom() {
++ const p = page();
++ const layout = p && p.querySelector('.stream-layout');
++ const v = p && p.querySelector('#video-container');
++ if (!layout || !v) return window.innerHeight * 0.6;
++ let top = v.getBoundingClientRect().bottom;
++ const row = p.classList.contains('ch-bot-compact') ? p.querySelector('#ch-bot-section') : null;
++ if (row) top += row.getBoundingClientRect().height;
++ return Math.max(180, layout.getBoundingClientRect().bottom - top);
++ }
const MODES = {
side: { key: 'openvibe_chat_width', prop: '--chat-w', axis: 'x', min: 260, max: () => Math.min(window.innerWidth * 0.5, 760) },
overlay: { key: 'openvibe_chat_overlay_w', prop: '--chat-ow', axis: 'x', min: 260, max: () => window.innerWidth * 0.7 },
-- sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => window.innerHeight * 0.92 },
++ sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => (docked() ? dockRoom() : window.innerHeight * 0.92) },
};
-- const page = () => document.getElementById('page-channel');
const modeOf = () => (WIDE.matches ? 'side' : LAND.matches ? 'overlay' : 'sheet');
const clamp = (v, a, b) => Math.round(Math.max(a, Math.min(b, v)));
const read = (k) => { try { return parseInt(localStorage.getItem(k), 10) || 0; } catch { return 0; } };
@@@ -29,6 +50,8 @@
const p = page(); const m = MODES[mode];
if (!p) return;
if (v) p.style.setProperty(m.prop, `${clamp(v, m.min, m.max())}px`); else p.style.removeProperty(m.prop);
++ // A sheet with a height of its own leaves the rest to the channel line; without one it fills (channel.css).
++ if (mode === 'sheet') p.classList.toggle('ch-chat-sized', !!v);
}
function applySaved() { for (const mode of Object.keys(MODES)) setSize(mode, read(MODES[mode].key)); }
function settle() { window.dispatchEvent(new Event('resize')); } // the player and the diagrams re-measure
@@@ -78,7 +101,7 @@
if (Math.abs(delta) > 3) drag.moved = true;
// The sheet may go below its minimum while dragging: letting go there closes it.
drag.cur = Math.round(Math.max(drag.mode === 'sheet' ? 60 : m.min, Math.min(m.max(), drag.from + delta)));
-- const p = page(); if (p) p.style.setProperty(m.prop, `${drag.cur}px`);
++ const p = page(); if (p) { p.style.setProperty(m.prop, `${drag.cur}px`); if (drag.mode === 'sheet') p.classList.add('ch-chat-sized'); }
sync(g, sidebar);
});
const end = (e) => {
@@@ -93,6 +116,16 @@
return;
}
const m = MODES[d.mode];
++ if (d.mode === 'sheet' && docked()) {
++ // Docked: most of the way down folds the chat to its round button; all the way up is "fill" again.
++ if (d.cur < m.min) {
++ setSize('sheet', read(m.key));
++ if (typeof window.toggleChannelChat === 'function') window.toggleChannelChat(true);
++ sync(g, sidebar);
++ return;
++ }
++ if (d.cur >= m.max() - 16) { reset('sheet'); sync(g, sidebar); return; }
++ }
if (d.mode === 'sheet' && d.cur < m.min) {
setSize('sheet', read(m.key)); // keep the last real height for next time
const s = document.querySelector('#page-channel .chat-sidebar.mobile-chat-open');
@@@ -115,6 +148,7 @@
e.preventDefault();
const r = sidebar.getBoundingClientRect();
const v = clamp((m.axis === 'x' ? r.width : r.height) + grow[e.key] * 24, m.min, m.max());
++ if (mode === 'sheet' && docked() && v >= m.max()) { reset('sheet'); sync(g, sidebar); return; }
setSize(mode, v); write(m.key, v); settle();
sync(g, sidebar);
});
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 0381ade2..bbafd421 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -776,7 +776,7 @@ async function loadBotEmbedSetting() {
if (!be || !be.enabled) { card.style.display = 'none'; return; }
document.getElementById('dash-bot-robot-id').value = be.robot_id || '';
const mode = document.getElementById('dash-bot-controls-mode');
-- if (mode) mode.value = be.controls_mode === 'overlay' ? 'overlay' : 'panel';
++ if (mode) mode.value = ['overlay', 'compact'].includes(be.controls_mode) ? be.controls_mode : 'panel';
card.style.display = '';
} catch { card.style.display = 'none'; }
}
@@@ -788,7 +788,7 @@ async function saveBotEmbedSetting() {
const id = input.value.trim();
try {
const body = { robot_id: id || null };
-- if (mode && (mode.value === 'overlay' || mode.value === 'panel')) body.controls_mode = mode.value;
++ if (mode && ['overlay', 'compact', 'panel'].includes(mode.value)) body.controls_mode = mode.value;
const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body });
const bound = data && data.bot_embed && data.bot_embed.robot_id;
if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';
ddiff --git a/server/bot/embed.js b/server/bot/embed.js
iindex ae4f83d2..fd6a202e 100644
---- a/server/bot/embed.js
++++ b/server/bot/embed.js
@@@ -49,8 +49,8 @@ function embedUrl(robotId) {
return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;
}
--/** The two places a streamer may put the controls by default; a viewer may still hide them or move them. */
--const CONTROL_MODES = ['overlay', 'panel'];
++/** The places a streamer may put the controls by default (a row since ask 353); a viewer may still hide or move them. */
++const CONTROL_MODES = ['overlay', 'compact', 'panel'];
const controlMode = (value) => (CONTROL_MODES.includes(value) ? value : 'panel');
/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url, controls_mode }. */
ddiff --git a/server/bot/routes.js b/server/bot/routes.js
iindex c0cf9912..7f5b2e0b 100644
---- a/server/bot/routes.js
++++ b/server/bot/routes.js
@@@ -43,7 +43,7 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
let mode = embed.controlMode(channel.bot_controls_mode);
if (hasMode) {
if (!embed.CONTROL_MODES.includes(body.controls_mode)) {
-- return res.status(400).json({ error: "controls_mode must be 'overlay' or 'panel'" });
++ return res.status(400).json({ error: "controls_mode must be 'overlay', 'compact' or 'panel'" });
}
mode = body.controls_mode;
}
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 18385376..f3f461f1 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -974,8 +974,12 @@ function render(meta, urlPath) {
const contentEnd = html.indexOf('</div>', contentStart);
if (contentEnd !== -1) html = html.slice(0, contentStart) + cards + html.slice(contentEnd);
}
-- for (const [section, id] of [['videos', 'ch-vods-all'], ['clips', 'ch-clips-of-all'], ['clips-taken', 'ch-clips-taken-all'], ['pastes', 'ch-pastes-all']]) {
-- html = html.replace(`id="${id}" href="#">View all`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}">View all (${Number(meta.railCounts[section]) || 0})`);
++ // Each section's View all is an icon button with its words for readers and crawlers; the count sits beside the
++ // heading (ask 355, 2026-11-11).
++ for (const [section, id, countId] of [['videos', 'ch-vods-all', 'ch-vods-n'], ['clips', 'ch-clips-of-all', 'ch-clips-of-n'], ['clips-taken', 'ch-clips-taken-all', 'ch-clips-taken-n'], ['pastes', 'ch-pastes-all', 'ch-pastes-n']]) {
++ const count = Number(meta.railCounts[section]) || 0;
++ html = html.replace(`id="${id}" href="#"`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}"`);
++ if (count > 0) html = html.replace(`<span class="ch-sec-n" id="${countId}" hidden></span>`, `<span class="ch-sec-n" id="${countId}">${count > 999 ? `${Math.floor(count / 100) / 10}k` : count}</span>`);
}
}
ddiff --git a/test/bot-embed-binding.test.js b/test/bot-embed-binding.test.js
iindex df1d0a46..4e17c7a8 100644
---- a/test/bot-embed-binding.test.js
++++ b/test/bot-embed-binding.test.js
@@@ -138,6 +138,11 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
assert.strictEqual(mode.status, 200, mode.text);
assert.deepStrictEqual(mode.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'overlay' });
assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'overlay', 'the channel serializer carries it to the page');
++ // A row under the video (ask 353): the database takes it (migration 0039) and the page gets it.
++ const row = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'compact' } });
++ assert.strictEqual(row.status, 200, row.text);
++ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'compact');
++ assert.strictEqual((await db.getChannelByUserId(1)).bot_controls_mode, 'compact');
// Both together, then an invalid value changes nothing.
const both = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { robot_id: 'rob_sim123', controls_mode: 'panel' } });
assert.strictEqual(both.status, 200);
ddiff --git a/test/bot-embed-page.test.js b/test/bot-embed-page.test.js
iindex 12f74d34..94827351 100644
---- a/test/bot-embed-page.test.js
++++ b/test/bot-embed-page.test.js
@@@ -125,6 +125,14 @@ async function check(name, fn) {
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'hidden'), 'hidden', 'or hide them');
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.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');
++ assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null), 'compact', 'a streamer may start everyone with a row');
++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'compact'), 'compact', 'and a viewer may choose it');
++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'panel', { phone: true }), 'panel', 'a viewer\'s own choice beats the phone default');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'overlay' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=overlay');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'compact' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=compact');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'bogus' }), null, 'only the three views');
ddiff --git a/test/chat-sheet-width.test.js b/test/chat-sheet-width.test.js
iindex 22dc50f2..d7411999 100644
---- a/test/chat-sheet-width.test.js
++++ b/test/chat-sheet-width.test.js
@@@ -39,10 +39,22 @@ for (const [name, text] of [['channel.css', css]]) {
assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*landscape/, `${name}: --chat-ow outside the landscape overlay`);
}
for (const i of uses(text, '--chat-sheet-h')) {
-- assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);
++ // The phone's docked chat (768px and narrower, upright; ask 353) is the same sheet on a smaller screen.
++ assert.match(String(mediaAt(text, i)), /max-width:\s*(1180|768)px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);
}
}
assert.ok(uses(css, '--chat-w').length === 1, 'the side width is set in one place');
// The sheet itself is the full width of the screen.
assert.match(css, /@media \(max-width: 1180px\) and \(orientation: portrait\) \{ #page-channel \.stream-layout > \.chat-sidebar \{ width: 100%;/);
++// The phone's docked chat (ask 353, 2026-11-11): it fills up to the player until it has a height of its own
++// (ch-chat-sized, set by public/js/channel-chat-resize.js with --chat-sheet-h), its grip is shown, and letting go low
++// folds it to the round chat button rather than the tablet sheet's close.
++const resize = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'channel-chat-resize.js'), 'utf8');
++assert.match(resize, /if \(mode === 'sheet'\) p\.classList\.toggle\('ch-chat-sized', !!v\);/, 'a saved height marks the page');
++assert.match(resize, /max: \(\) => \(docked\(\) \? dockRoom\(\) : window\.innerHeight \* 0\.92\)/, 'docked, the room under the player is the most');
++assert.match(resize, /if \(d\.cur < m\.min\) \{\s*setSize\('sheet', read\(m\.key\)\);\s*if \(typeof window\.toggleChannelChat === 'function'\) window\.toggleChannelChat\(true\);/, 'low folds the chat');
++assert.match(resize, /if \(d\.cur >= m\.max\(\) - 16\) \{ reset\('sheet'\);/, 'all the way up is the default again');
++assert.match(css, /#page-channel\.ch-is-live:not\(\.ch-chat-sized\):not\(\.chat-collapsed\) [^{]*:is\(\.ch-info-bar, \.ch-weather-widget\) \{ display: none !important; \}/, 'full: the chat reaches the player');
++assert.match(css, /#page-channel\.ch-is-live\.ch-chat-sized\.page [^{]*> \.chat-sidebar \{ flex: 0 1 var\(--chat-sheet-h, 60%\); min-height: 120px; \}/, 'sized: the chat keeps its height');
++assert.match(css, /> \.chat-sidebar > \.chat-grip \{ display: block !important;/, 'the grip shows on the docked chat');
console.log('chat-sheet-width: each saved chat size applies only in its own layout');