openvibers/openvibe.live Commits

Phone chat: the sheet reaches the bottom, a compact header, the gear and the ⋯ menu work (#875)

OP OpenVibers committed 7 h ago · 00ab035de5 · 4 files, +126 −7

- The sheet runs to the bottom of the screen. The live tabs bar's 40 px were taken while a phone hid the bar (one
  stream), and the page kept 32 px of bottom padding under it: 804 of 844 px, now 844.
- The grip lies over the header's top edge, a small pill between the tabs and the tools, not a row of its own
  (header 49 → 45 px).
- In the Robot tab the header's gear and people button open their panels over the robot's (they were inside the chat
  that tab hides, so nothing opened).
- The ⋯ menu opens in the top layer, placed on the screen (below its button, above when there is no room), with a
  small scale-in; a press elsewhere or Escape closes it. On a phone the live layout clipped it, so it opened unseen.
- Tab icons fill one 20 px box (the robot glyph was drawn smaller than the chat bubble); hover, press and the open
  tab's icon in the accent. The sheet folds with the set's solid chevron instead of the side panel's arrows turned.
- Test chat-sheet-fill.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex c531a63d..0e3ae1c5 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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(var
bottom 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; }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 1a2b0160..98f66ac9 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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">
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex df6635bb..7e59d7fa 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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);
ddiff --git a/test/chat-sheet-fill.test.js b/test/chat-sheet-fill.test.js
nnew file mode 100644
iindex 00000000..a2ab5342
---- /dev/null
++++ b/test/chat-sheet-fill.test.js
@@@ -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');