openvibers/openvibe.live Commits
Phone chat: the sheet reaches the bottom, a compact header, the gear and the ⋯ menu work (#875)
Changes
@@@ -1447,6 +1447,7 @@ a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }/* ── Hide / show the chat (desktop) ─────────────────────────── */.chat-header .ch-chat-collapse { margin-right: 2px; }++.ch-chat-collapse .ch-ic-sheet { display: none; }@media (min-width: 1181px) {#page-channel.chat-collapsed .stream-layout > .chat-sidebar { display: none !important; }#page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: chChatReveal var(--ov-dur-3, .32s) var(--ov-ease-out, cubic-bezier(.22,1,.36,1)); }@@@ -1666,7 +1667,10 @@ a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }display: flex; flex-direction: column; overflow: hidden;height: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px));}-- #page-channel.ch-is-live.has-live-tabs .stream-layout { --live-tabs-h: 40px; }++ #page-channel.ch-is-live.has-live-tabs:has(.live-stream-tabs .live-tab:nth-child(2)) .stream-layout { --live-tabs-h: 40px; }++ /* Down to the bottom of the screen (ask 371, 11-13: "empty space at the bottom … should be using the full size"): the++ bar's 40 px were taken while a phone hid the bar, and the page kept its 32 px of bottom padding under the sheet. */++ #page-channel.ch-is-live.page { padding-bottom: 0; }#page-channel.ch-is-live .stream-main { flex: 0 0 auto; overflow: visible; min-height: 0; }/* The chat: in the page, under the player, taking what is left. */@@@ -1922,6 +1926,14 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }++/* Open, it is in the top layer and placed on the screen by app-channel.js (a phone's live layout clipped it). */++#page-channel .ch-more-menu.is-layer { position: fixed; inset: auto; margin: 0; right: auto; max-height: calc(100dvh - 16px); overflow-y: auto; color: var(--text-primary);++ transform-origin: top right; animation: chMoreIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }++#page-channel .ch-more-menu.is-layer.is-above { transform-origin: bottom right; }++@keyframes chMoreIn { from { opacity: 0; transform: translateY(-4px) scale(.96); } }++#page-channel .ch-more-menu.is-layer.is-above { animation-name: chMoreUp; }++@keyframes chMoreUp { from { opacity: 0; transform: translateY(4px) scale(.96); } }++html[data-ov-motion="off"] #page-channel .ch-more-menu.is-layer { animation: none; }#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }@@@ -1955,7 +1967,10 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(varbottom right brings it back. The choice is kept (toggleChannelChat: ov_ch_chat_hidden). */@media (max-width: 768px) and (orientation: portrait) {#page-channel.ch-is-live .chat-header .ch-chat-collapse { display: inline-flex !important; align-items: center; justify-content: center; order: 3; margin: 0 0 0 2px; }-- #page-channel.ch-is-live .chat-header .ch-chat-collapse i { transform: rotate(90deg); }++ /* Folding the sheet down: the set's own chevron, solid like the people and gear beside it (ask 371: the side panel's++ double arrow turned on its side "doesn't match the style"). */++ #page-channel.ch-is-live .chat-header .ch-chat-collapse .ch-ic-side { display: none; }++ #page-channel.ch-is-live .chat-header .ch-chat-collapse .ch-ic-sheet { display: inline-block; }#page-channel.ch-is-live .chat-sidebar .chat-header { justify-content: flex-start; }#page-channel.ch-is-live .chat-sidebar .chat-header .chat-header-actions { margin-left: auto; }#page-channel.ch-is-live.chat-collapsed .stream-layout { height: auto; }@@@ -2086,6 +2101,17 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var.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); }++/* The tabs (ask 371, 11-13): one icon box for each, a hover, a press and the open tab's icon in the accent. The set's++ glyphs differ in shape (the robot is wide and short, the chat bubble square), so each fills the same 20 px box++ instead of sharing a font size ("the robot icon is so much smaller"). */++.ch-bot-tabs button { cursor: pointer; transition: background-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease), color var(--ov-dur-2, .15s), transform var(--ov-dur-1, .12s) var(--ov-ease-spring, ease); }++.ch-bot-tabs button:hover { color: var(--text-primary); }++.ch-bot-tabs button:not([aria-selected="true"]):hover { background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }++.ch-bot-tabs button:active { transform: scale(.96); }++.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; font-size: 16px; line-height: 1; transition: color var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease); }++.ch-bot-tabs button > i.fa-robot { font-size: 20px; }++.ch-bot-tabs button[aria-selected="true"] > :is(i, svg) { color: var(--accent-light, var(--accent)); }++.ch-bot-tabs button[aria-selected="true"]:hover > :is(i, svg) { transform: translateY(-1px); }.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); }@@@ -2140,7 +2166,14 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var#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; }++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :not(.chat-header):not(.chat-settings-panel):not(.chat-users-panel) { display: none !important; }++ /* Its gear and people button still work (ask 371, 11-13: "the settings button … doesn't open"): their panels open++ over the robot's, under the header, instead of inside the chat that this tab hides. */++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar { overflow: visible; z-index: 6; }++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :is(.chat-settings-panel, .chat-users-panel) {++ position: absolute; top: 100%; left: 0; right: 0; z-index: 20; max-height: min(70dvh, 520px); overflow-y: auto;++ border-bottom: 1px solid var(--border); box-shadow: 0 18px 40px -12px rgba(0, 0, 0, .6);++ }/* 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; }@@@ -2298,8 +2331,9 @@ html[data-ov-motion="off"] #page-channel.ch-tabbed .ch-tab-panel.is-entering { a#page-channel.ch-is-live:not(.chat-collapsed) .channel-below-fold { display: none; }body:has(#page-channel.active.ch-is-live:not(.chat-collapsed)) #ov-footer { display: none; }#page-channel.ch-is-live.chat-collapsed #ch-bot-section { display: none !important; }-- #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { position: absolute; top: 0; height: 16px; z-index: 4; }-- #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 8px; min-height: 46px; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { position: absolute; top: 0; height: 14px; width: 72px; margin-left: -36px; z-index: 4; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip::after { top: 4px; width: 32px; height: 4px; margin-left: -16px; border-radius: 4px; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 4px; padding-bottom: 4px; min-height: 44px; }#page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 3px 6px 0; }#page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 30px; }#page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 3px; }@@@ -194,7 +194,7 @@<!-- Chat Sidebar --><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>++ <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 ch-ic-side" aria-hidden="true"></i><i class="fa-solid fa-chevron-down ch-ic-sheet" aria-hidden="true"></i></button><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">@@@ -324,7 +324,7 @@<!-- Streamer's persistent chat (works while offline) — sidebar, mirrors live layout --><div class="offline-global-chat chat-sidebar" id="offline-global-chat"><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>++ <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 ch-ic-side" aria-hidden="true"></i><i class="fa-solid fa-chevron-down ch-ic-sheet" aria-hidden="true"></i></button><span><i class="fa-solid fa-comments"></i> STREAM CHAT</span><div class="chat-header-actions"><button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users">@@@ -4554,9 +4554,63 @@ async function offerLiveAlerts(name) {box.appendChild(card);}++/**++ * The ⋯ menu opens in the top layer, placed on the screen (ask 371, 2026-11-13: "the little three dots doesn't actually++ * open a menu, at least not on mobile"): on a phone the live layout clips what hangs out of it, so the menu opened++ * unseen. Below its button, or above it when there is no room; inside the visible screen; again on a scroll or a resize;++ * a press elsewhere or Escape closes it. Without the Popover API it is fixed, which a clipping ancestor still allows.++ */++function _wireChannelMoreLayer(menu) {++ if (menu.dataset.layer) return;++ menu.dataset.layer = '1';++ const pop = menu.querySelector('.ch-more-menu');++ const summary = menu.querySelector('summary');++ if (!pop || !summary) return;++ const place = () => {++ if (!menu.open) return;++ const s = summary.getBoundingClientRect();++ const vv = window.visualViewport;++ const ox = vv ? vv.offsetLeft : 0, oy = vv ? vv.offsetTop : 0;++ const vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight;++ const w = pop.offsetWidth, h = pop.offsetHeight, m = 8, gap = 6;++ const left = Math.min(Math.max(ox + m, s.right - w), ox + vw - w - m);++ let top = s.bottom + gap;++ const above = top + h > oy + vh - m;++ if (above) top = s.top - h - gap >= oy + m ? s.top - h - gap : Math.max(oy + m, oy + vh - h - m);++ pop.style.left = `${Math.round(left)}px`;++ pop.style.top = `${Math.round(top)}px`;++ pop.classList.toggle('is-above', above);++ };++ const onDown = (e) => { if (!menu.contains(e.target)) menu.open = false; };++ const onKey = (e) => { if (e.key === 'Escape') { menu.open = false; summary.focus(); } };++ menu.addEventListener('toggle', () => {++ const topLayer = typeof pop.showPopover === 'function';++ if (!menu.open) {++ if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* closed */ } pop.removeAttribute('popover'); }++ pop.classList.remove('is-layer', 'is-above');++ pop.style.left = pop.style.top = '';++ document.removeEventListener('pointerdown', onDown, true);++ document.removeEventListener('keydown', onKey, true);++ window.removeEventListener('scroll', place, true);++ window.removeEventListener('resize', place);++ window.visualViewport?.removeEventListener('resize', place);++ return;++ }++ pop.classList.add('is-layer');++ if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }++ place();++ document.addEventListener('pointerdown', onDown, true);++ document.addEventListener('keydown', onKey, true);++ window.addEventListener('scroll', place, { capture: true, passive: true });++ window.addEventListener('resize', place);++ window.visualViewport?.addEventListener('resize', place);++ });++}++function _setupChannelMore(ch) {const menu = document.getElementById('ch-more');if (!menu) return;++ _wireChannelMoreLayer(menu);menu.open = false;const username = ch.username;_setWatchAlert(document.getElementById('ch-watch-offline'), username);nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++// A phone watching live (ask 371, 2026-11-13): the chat sheet runs to the bottom of the screen, its grip lies over the++// header instead of taking a row, and in the Robot tab the header's gear and people button still open their panels.++//++// The browser check that measured it: the sheet stopped at 804 of an 844 px screen (a live tabs bar the phone hid still++// took 40 px, and the page kept 32 px of bottom padding), the header was 49 px, and the gear did nothing in the Robot tab++// (its panel was inside the chat that tab hides). After: 844, 45 px, and the panel over the robot's.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'channel.css'), 'utf8');++++assert.ok(css.includes('#page-channel.ch-is-live.has-live-tabs:has(.live-stream-tabs .live-tab:nth-child(2)) .stream-layout { --live-tabs-h: 40px; }'),++ 'the live tabs bar takes its height only while it is shown (a phone hides it with one stream)');++assert.ok(!/#page-channel\.ch-is-live\.has-live-tabs \.stream-layout \{ --live-tabs-h: 40px; \}/.test(css), 'never for a bar the phone hid');++assert.ok(css.includes('#page-channel.ch-is-live.page { padding-bottom: 0; }'), 'no bottom padding under the sheet');++assert.ok(/> \.chat-sidebar > :not\(\.chat-header\):not\(\.chat-settings-panel\):not\(\.chat-users-panel\) \{ display: none !important; \}/.test(css),++ 'the Robot tab hides the chat but not the settings and people panels');++assert.ok(/> \.chat-sidebar > :is\(\.chat-settings-panel, \.chat-users-panel\) \{\s*position: absolute; top: 100%;/.test(css),++ 'which open over the robot\'s panel, under the header');++assert.ok(/> \.chat-sidebar \.chat-header \{ padding-top: 4px; padding-bottom: 4px; min-height: 44px; \}/.test(css), 'a compact header');++assert.ok(/> \.chat-sidebar > \.chat-grip \{ position: absolute; top: 0; height: 14px;/.test(css), 'the grip over its top edge, no row of its own');++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');++assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /pop\.setAttribute\('popover', 'manual'\)/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),++ 'the ⋯ menu opens in the top layer, placed on the screen (a phone\'s live layout clipped it)');++assert.ok(css.includes('#page-channel .ch-more-menu.is-layer { position: fixed;'), 'and is fixed there');++assert.ok(css.includes('.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px;'),++ 'every tab icon fills the same box (the robot was drawn smaller than the chat bubble)');++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');++assert.strictEqual((html.match(/fa-chevron-down ch-ic-sheet/g) || []).length, 2, 'the sheet folds with the set\'s solid chevron');++console.log('chat-sheet-fill: to the bottom of the screen, a compact header, the gear and people in the Robot tab, the ⋯ menu, the tab icons');
@@ -1447,6 +1447,7 @@ a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }
@@ -1447,6 +1447,7 @@ a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }
/* ── Hide / show the chat (desktop) ─────────────────────────── */
/* ── Hide / show the chat (desktop) ─────────────────────────── */
.chat-header .ch-chat-collapse { margin-right: 2px; }
.chat-header .ch-chat-collapse { margin-right: 2px; }
+.ch-chat-collapse .ch-ic-sheet { display: none; }
@media (min-width: 1181px) {
@media (min-width: 1181px) {
#page-channel.chat-collapsed .stream-layout > .chat-sidebar { display: none !important; }
#page-channel.chat-collapsed .stream-layout > .chat-sidebar { display: none !important; }
#page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: chChatReveal var(--ov-dur-3, .32s) var(--ov-ease-out, cubic-bezier(.22,1,.36,1)); }
#page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: chChatReveal var(--ov-dur-3, .32s) var(--ov-ease-out, cubic-bezier(.22,1,.36,1)); }
@@ -1666,7 +1667,10 @@ a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }
@@ -1666,7 +1667,10 @@ a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }
display: flex; flex-direction: column; overflow: hidden;
display: flex; flex-direction: column; overflow: hidden;
height: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px));
height: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px));
}
}
- #page-channel.ch-is-live.has-live-tabs .stream-layout { --live-tabs-h: 40px; }
+ #page-channel.ch-is-live.has-live-tabs:has(.live-stream-tabs .live-tab:nth-child(2)) .stream-layout { --live-tabs-h: 40px; }
+ /* Down to the bottom of the screen (ask 371, 11-13: "empty space at the bottom … should be using the full size"): the
+ bar's 40 px were taken while a phone hid the bar, and the page kept its 32 px of bottom padding under the sheet. */
+ #page-channel.ch-is-live.page { padding-bottom: 0; }
#page-channel.ch-is-live .stream-main { flex: 0 0 auto; overflow: visible; min-height: 0; }
#page-channel.ch-is-live .stream-main { flex: 0 0 auto; overflow: visible; min-height: 0; }
/* The chat: in the page, under the player, taking what is left. */
/* The chat: in the page, under the player, taking what is left. */
@@ -1922,6 +1926,14 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -1922,6 +1926,14 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }
#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }
#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }
#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }
#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }
#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }
+/* Open, it is in the top layer and placed on the screen by app-channel.js (a phone's live layout clipped it). */
+#page-channel .ch-more-menu.is-layer { position: fixed; inset: auto; margin: 0; right: auto; max-height: calc(100dvh - 16px); overflow-y: auto; color: var(--text-primary);
+ transform-origin: top right; animation: chMoreIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
+#page-channel .ch-more-menu.is-layer.is-above { transform-origin: bottom right; }
+@keyframes chMoreIn { from { opacity: 0; transform: translateY(-4px) scale(.96); } }
+#page-channel .ch-more-menu.is-layer.is-above { animation-name: chMoreUp; }
+@keyframes chMoreUp { from { opacity: 0; transform: translateY(4px) scale(.96); } }
+html[data-ov-motion="off"] #page-channel .ch-more-menu.is-layer { animation: none; }
#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }
#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }
.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }
.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }
.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }
.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }
@@ -1955,7 +1967,10 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -1955,7 +1967,10 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
bottom right brings it back. The choice is kept (toggleChannelChat: ov_ch_chat_hidden). */
bottom right brings it back. The choice is kept (toggleChannelChat: ov_ch_chat_hidden). */
@media (max-width: 768px) and (orientation: portrait) {
@media (max-width: 768px) and (orientation: portrait) {
#page-channel.ch-is-live .chat-header .ch-chat-collapse { display: inline-flex !important; align-items: center; justify-content: center; order: 3; margin: 0 0 0 2px; }
#page-channel.ch-is-live .chat-header .ch-chat-collapse { display: inline-flex !important; align-items: center; justify-content: center; order: 3; margin: 0 0 0 2px; }
- #page-channel.ch-is-live .chat-header .ch-chat-collapse i { transform: rotate(90deg); }
+ /* Folding the sheet down: the set's own chevron, solid like the people and gear beside it (ask 371: the side panel's
+ double arrow turned on its side "doesn't match the style"). */
+ #page-channel.ch-is-live .chat-header .ch-chat-collapse .ch-ic-side { display: none; }
+ #page-channel.ch-is-live .chat-header .ch-chat-collapse .ch-ic-sheet { display: inline-block; }
#page-channel.ch-is-live .chat-sidebar .chat-header { justify-content: flex-start; }
#page-channel.ch-is-live .chat-sidebar .chat-header { justify-content: flex-start; }
#page-channel.ch-is-live .chat-sidebar .chat-header .chat-header-actions { margin-left: auto; }
#page-channel.ch-is-live .chat-sidebar .chat-header .chat-header-actions { margin-left: auto; }
#page-channel.ch-is-live.chat-collapsed .stream-layout { height: auto; }
#page-channel.ch-is-live.chat-collapsed .stream-layout { height: auto; }
@@ -2086,6 +2101,17 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -2086,6 +2101,17 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
.chat-header .ch-bot-tabs { margin-right: auto; }
.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 { 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-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text-primary); }
+/* The tabs (ask 371, 11-13): one icon box for each, a hover, a press and the open tab's icon in the accent. The set's
+ glyphs differ in shape (the robot is wide and short, the chat bubble square), so each fills the same 20 px box
+ instead of sharing a font size ("the robot icon is so much smaller"). */
+.ch-bot-tabs button { cursor: pointer; transition: background-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease), color var(--ov-dur-2, .15s), transform var(--ov-dur-1, .12s) var(--ov-ease-spring, ease); }
+.ch-bot-tabs button:hover { color: var(--text-primary); }
+.ch-bot-tabs button:not([aria-selected="true"]):hover { background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
+.ch-bot-tabs button:active { transform: scale(.96); }
+.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; font-size: 16px; line-height: 1; transition: color var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease); }
+.ch-bot-tabs button > i.fa-robot { font-size: 20px; }
+.ch-bot-tabs button[aria-selected="true"] > :is(i, svg) { color: var(--accent-light, var(--accent)); }
+.ch-bot-tabs button[aria-selected="true"]:hover > :is(i, svg) { transform: translateY(-1px); }
.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 { 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 { 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:hover { color: var(--text-primary); }
@@ -2140,7 +2166,14 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -2140,7 +2166,14 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
#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) .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) #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).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; }
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :not(.chat-header):not(.chat-settings-panel):not(.chat-users-panel) { display: none !important; }
+ /* Its gear and people button still work (ask 371, 11-13: "the settings button … doesn't open"): their panels open
+ over the robot's, under the header, instead of inside the chat that this tab hides. */
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar { overflow: visible; z-index: 6; }
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :is(.chat-settings-panel, .chat-users-panel) {
+ position: absolute; top: 100%; left: 0; right: 0; z-index: 20; max-height: min(70dvh, 520px); overflow-y: auto;
+ border-bottom: 1px solid var(--border); box-shadow: 0 18px 40px -12px rgba(0, 0, 0, .6);
+ }
/* The Chat tab: the panel's frame waits, still loaded (a driver keeps their place). */
/* 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; }
#page-channel.ch-is-live.ch-bot-panel.ch-bot-chat:not(.chat-collapsed) #ch-bot-section { display: none !important; }
@@ -2298,8 +2331,9 @@ html[data-ov-motion="off"] #page-channel.ch-tabbed .ch-tab-panel.is-entering { a
@@ -2298,8 +2331,9 @@ html[data-ov-motion="off"] #page-channel.ch-tabbed .ch-tab-panel.is-entering { a
#page-channel.ch-is-live:not(.chat-collapsed) .channel-below-fold { display: none; }
#page-channel.ch-is-live:not(.chat-collapsed) .channel-below-fold { display: none; }
body:has(#page-channel.active.ch-is-live:not(.chat-collapsed)) #ov-footer { display: none; }
body:has(#page-channel.active.ch-is-live:not(.chat-collapsed)) #ov-footer { display: none; }
#page-channel.ch-is-live.chat-collapsed #ch-bot-section { display: none !important; }
#page-channel.ch-is-live.chat-collapsed #ch-bot-section { display: none !important; }
- #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { position: absolute; top: 0; height: 16px; z-index: 4; }
- #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 8px; min-height: 46px; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { position: absolute; top: 0; height: 14px; width: 72px; margin-left: -36px; z-index: 4; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip::after { top: 4px; width: 32px; height: 4px; margin-left: -16px; border-radius: 4px; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 4px; padding-bottom: 4px; min-height: 44px; }
#page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 3px 6px 0; }
#page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 3px 6px 0; }
#page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 30px; }
#page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 30px; }
#page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 3px; }
#page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 3px; }
@@ -194,7 +194,7 @@
@@ -194,7 +194,7 @@
<!-- Chat Sidebar -->
<!-- Chat Sidebar -->
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-header">
<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>
+ <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 ch-ic-side" aria-hidden="true"></i><i class="fa-solid fa-chevron-down ch-ic-sheet" aria-hidden="true"></i></button>
<span class="ch-chat-title">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). -->
<!-- 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">
<div class="ch-bot-tabs" id="ch-bot-tabs" role="tablist" aria-label="Chat or robot">
@@ -324,7 +324,7 @@
@@ -324,7 +324,7 @@
<!-- Streamer's persistent chat (works while offline) — sidebar, mirrors live layout -->
<!-- Streamer's persistent chat (works while offline) — sidebar, mirrors live layout -->
<div class="offline-global-chat chat-sidebar" id="offline-global-chat">
<div class="offline-global-chat chat-sidebar" id="offline-global-chat">
<div class="chat-header">
<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>
+ <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 ch-ic-side" aria-hidden="true"></i><i class="fa-solid fa-chevron-down ch-ic-sheet" aria-hidden="true"></i></button>
<span><i class="fa-solid fa-comments"></i> STREAM CHAT</span>
<span><i class="fa-solid fa-comments"></i> STREAM CHAT</span>
<div class="chat-header-actions">
<div class="chat-header-actions">
<button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users">
<button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users">
@@ -4554,9 +4554,63 @@ async function offerLiveAlerts(name) {
@@ -4554,9 +4554,63 @@ async function offerLiveAlerts(name) {
box.appendChild(card);
box.appendChild(card);
}
}
+/**
+ * The ⋯ menu opens in the top layer, placed on the screen (ask 371, 2026-11-13: "the little three dots doesn't actually
+ * open a menu, at least not on mobile"): on a phone the live layout clips what hangs out of it, so the menu opened
+ * unseen. Below its button, or above it when there is no room; inside the visible screen; again on a scroll or a resize;
+ * a press elsewhere or Escape closes it. Without the Popover API it is fixed, which a clipping ancestor still allows.
+ */
+function _wireChannelMoreLayer(menu) {
+ if (menu.dataset.layer) return;
+ menu.dataset.layer = '1';
+ const pop = menu.querySelector('.ch-more-menu');
+ const summary = menu.querySelector('summary');
+ if (!pop || !summary) return;
+ const place = () => {
+ if (!menu.open) return;
+ const s = summary.getBoundingClientRect();
+ const vv = window.visualViewport;
+ const ox = vv ? vv.offsetLeft : 0, oy = vv ? vv.offsetTop : 0;
+ const vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight;
+ const w = pop.offsetWidth, h = pop.offsetHeight, m = 8, gap = 6;
+ const left = Math.min(Math.max(ox + m, s.right - w), ox + vw - w - m);
+ let top = s.bottom + gap;
+ const above = top + h > oy + vh - m;
+ if (above) top = s.top - h - gap >= oy + m ? s.top - h - gap : Math.max(oy + m, oy + vh - h - m);
+ pop.style.left = `${Math.round(left)}px`;
+ pop.style.top = `${Math.round(top)}px`;
+ pop.classList.toggle('is-above', above);
+ };
+ const onDown = (e) => { if (!menu.contains(e.target)) menu.open = false; };
+ const onKey = (e) => { if (e.key === 'Escape') { menu.open = false; summary.focus(); } };
+ menu.addEventListener('toggle', () => {
+ const topLayer = typeof pop.showPopover === 'function';
+ if (!menu.open) {
+ if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* closed */ } pop.removeAttribute('popover'); }
+ pop.classList.remove('is-layer', 'is-above');
+ pop.style.left = pop.style.top = '';
+ document.removeEventListener('pointerdown', onDown, true);
+ document.removeEventListener('keydown', onKey, true);
+ window.removeEventListener('scroll', place, true);
+ window.removeEventListener('resize', place);
+ window.visualViewport?.removeEventListener('resize', place);
+ return;
+ }
+ pop.classList.add('is-layer');
+ if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }
+ place();
+ document.addEventListener('pointerdown', onDown, true);
+ document.addEventListener('keydown', onKey, true);
+ window.addEventListener('scroll', place, { capture: true, passive: true });
+ window.addEventListener('resize', place);
+ window.visualViewport?.addEventListener('resize', place);
+ });
+}
+
function _setupChannelMore(ch) {
function _setupChannelMore(ch) {
const menu = document.getElementById('ch-more');
const menu = document.getElementById('ch-more');
if (!menu) return;
if (!menu) return;
+ _wireChannelMoreLayer(menu);
menu.open = false;
menu.open = false;
const username = ch.username;
const username = ch.username;
_setWatchAlert(document.getElementById('ch-watch-offline'), username);
_setWatchAlert(document.getElementById('ch-watch-offline'), username);
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+// A phone watching live (ask 371, 2026-11-13): the chat sheet runs to the bottom of the screen, its grip lies over the
+// header instead of taking a row, and in the Robot tab the header's gear and people button still open their panels.
+//
+// The browser check that measured it: the sheet stopped at 804 of an 844 px screen (a live tabs bar the phone hid still
+// took 40 px, and the page kept 32 px of bottom padding), the header was 49 px, and the gear did nothing in the Robot tab
+// (its panel was inside the chat that tab hides). After: 844, 45 px, and the panel over the robot's.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'channel.css'), 'utf8');
+
+assert.ok(css.includes('#page-channel.ch-is-live.has-live-tabs:has(.live-stream-tabs .live-tab:nth-child(2)) .stream-layout { --live-tabs-h: 40px; }'),
+ 'the live tabs bar takes its height only while it is shown (a phone hides it with one stream)');
+assert.ok(!/#page-channel\.ch-is-live\.has-live-tabs \.stream-layout \{ --live-tabs-h: 40px; \}/.test(css), 'never for a bar the phone hid');
+assert.ok(css.includes('#page-channel.ch-is-live.page { padding-bottom: 0; }'), 'no bottom padding under the sheet');
+assert.ok(/> \.chat-sidebar > :not\(\.chat-header\):not\(\.chat-settings-panel\):not\(\.chat-users-panel\) \{ display: none !important; \}/.test(css),
+ 'the Robot tab hides the chat but not the settings and people panels');
+assert.ok(/> \.chat-sidebar > :is\(\.chat-settings-panel, \.chat-users-panel\) \{\s*position: absolute; top: 100%;/.test(css),
+ 'which open over the robot\'s panel, under the header');
+assert.ok(/> \.chat-sidebar \.chat-header \{ padding-top: 4px; padding-bottom: 4px; min-height: 44px; \}/.test(css), 'a compact header');
+assert.ok(/> \.chat-sidebar > \.chat-grip \{ position: absolute; top: 0; height: 14px;/.test(css), 'the grip over its top edge, no row of its own');
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
+assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /pop\.setAttribute\('popover', 'manual'\)/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),
+ 'the ⋯ menu opens in the top layer, placed on the screen (a phone\'s live layout clipped it)');
+assert.ok(css.includes('#page-channel .ch-more-menu.is-layer { position: fixed;'), 'and is fixed there');
+assert.ok(css.includes('.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px;'),
+ 'every tab icon fills the same box (the robot was drawn smaller than the chat bubble)');
+const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');
+assert.strictEqual((html.match(/fa-chevron-down ch-ic-sheet/g) || []).length, 2, 'the sheet folds with the set\'s solid chevron');
+console.log('chat-sheet-fill: to the bottom of the screen, a compact header, the gear and people in the Robot tab, the ⋯ menu, the tab icons');