Merge branch 'fix/web-window-polish' into main
The web call's title pill is the title and its status; the standing line is in its menu and the relay notice a dismissible notice, once a session. A tile's controls are one ⋯ menu (Pin, the moves, Remove) that comes and goes with the pointer and the bar, and a name's label is only the name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+461
−85
internal/calls/static/call-layout.js+138 −25internal/calls/static/call-window.js+46 −2internal/calls/static/calls.css+106 −34internal/calls/static/room.js+10 −13internal/calls/templates/room.html+28 −5internal/callstest/room_test.go+34 −2test/browser/call.mjs+87 −4test/browser/room-window.test.mjs+12 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore (cached) ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.627s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 9.164s ? amadan.net/oficina/calls/protocol [no test files] ? amadan.net/oficina/calls/scripts/writetheme [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 10.013s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 34.810s
diff --git a/internal/calls/static/call-layout.js b/internal/calls/static/call-layout.js| index 30273f1..4966c29 100644 |
| --- a/internal/calls/static/call-layout.js |
| +++ b/internal/calls/static/call-layout.js |
| @@ -152,6 +152,7 @@ |
| const unreadLive = document.getElementById('chat-unread-live'); |
| const layoutLive = document.getElementById('layout-live'); |
| const toolsTemplate = document.getElementById('tile-tools'); |
| + const menu = document.getElementById('tile-menu'); |
| if (!room || !tiles || !controls || !chat || !toggle || !overlay) return; |
| const LAYOUT_KEY = 'calls.videoLayout'; |
| @@ -184,6 +185,8 @@ |
| let corner = /^(top|bottom)-(left|right)$/.test(load('localStorage', CORNER_KEY) || '') |
| ? load('localStorage', CORNER_KEY) : 'bottom-right'; |
| let pipDrag = null; |
| + // The tile whose ⋯ menu is open, if any. |
| + let menuFig = null; |
| let drag = null; |
| let suppressClick = false; |
| // Leaving — Leave, a reload, closing the tab — takes the tiles down |
| @@ -230,9 +233,10 @@ |
| keep('sessionStorage', ORDER_KEY, JSON.stringify(order.keys)); |
| } |
| - // tools gives a tile its buttons, once: Pin, and for a person, the |
| - // three moves. They are the keyboard's and a screen reader's way to do |
| - // what a drag does, and a click's way to pin from Grid. |
| + // tools gives a tile its two buttons, once: a Pinned badge, shown |
| + // only while it is pinned (pressing it unpins), and ⋯, which opens the |
| + // tile's menu. Since 2026-09-29 the moves are in that menu rather than |
| + // sitting on every tile. |
| function tools(fig) { |
| let box = fig.querySelector(':scope > .calls-tile-tools'); |
| if (box || !toolsTemplate) return box; |
| @@ -240,41 +244,144 @@ |
| box.className = 'calls-tile-tools'; |
| box.setAttribute('role', 'group'); |
| box.append(toolsTemplate.content.cloneNode(true)); |
| - if (isShare(fig)) box.querySelectorAll('[data-move]').forEach((b) => b.remove()); |
| box.addEventListener('click', (ev) => { |
| const button = ev.target.closest('button'); |
| if (!button) return; |
| ev.stopPropagation(); |
| - if (button.dataset.act === 'pin') togglePin(fig); |
| - else moveBy(fig, button.dataset.move, button); |
| + if (button.classList.contains('calls-tool-more')) { |
| + if (menuFig === fig) closeMenu(false); |
| + else openMenu(fig, button); |
| + } else if (button.dataset.act === 'pin') togglePin(fig); |
| }); |
| fig.append(box); |
| return box; |
| } |
| - function labelTools(fig, isPinned, place, count) { |
| + function labelTools(fig, isPinned) { |
| const box = tools(fig); |
| if (!box) return; |
| const name = nameOf(fig); |
| box.setAttribute('aria-label', 'Arrange ' + name); |
| const pin = box.querySelector('[data-act="pin"]'); |
| if (pin) { |
| - pin.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + name); |
| - pin.setAttribute('aria-pressed', String(isPinned)); |
| - const text = pin.querySelector('.calls-tool-text'); |
| - // Only on a change: writing text is a child-list mutation, which |
| - // would bring the observer below straight back here. |
| - const word = isPinned ? 'Pinned' : 'Pin'; |
| - if (text && text.textContent !== word) text.textContent = word; |
| + // Only on a change: `hidden` is watched below, and setting it to |
| + // what it was is still a mutation that would bring layout back. |
| + if (pin.hidden !== !isPinned) pin.hidden = !isPinned; |
| + pin.setAttribute('aria-label', 'Unpin ' + name); |
| } |
| - for (const b of box.querySelectorAll('[data-move]')) { |
| - const verb = { earlier: 'Move ' + name + ' earlier', later: 'Move ' + name + ' later', first: 'Move ' + name + ' to first' }[b.dataset.move]; |
| - b.setAttribute('aria-label', verb); |
| - b.title = verb; |
| - b.disabled = b.dataset.move === 'later' ? place >= count - 1 : place <= 0; |
| + const more = box.querySelector('.calls-tool-more'); |
| + if (more) { |
| + const label = 'Options for ' + name; |
| + if (more.getAttribute('aria-label') !== label) { |
| + more.setAttribute('aria-label', label); |
| + more.title = label; |
| + } |
| } |
| } |
| + // ---- the tile menu ---- |
| + |
| + // One menu for every tile, outside #tiles so a small filmstrip tile |
| + // cannot clip it: Pin or Unpin, the three moves for a person, and |
| + // Remove for a member, which is a link to its own confirm page in a |
| + // new tab (room.js puts its address on the tile). |
| + function fillMenu() { |
| + if (!menu || !menuFig) return; |
| + const fig = menuFig; |
| + const name = nameOf(fig); |
| + menu.setAttribute('aria-label', 'Options for ' + name); |
| + const isPinned = pinned === fig.dataset.tile; |
| + const pin = menu.querySelector('[data-act="pin"]'); |
| + const word = isPinned ? 'Unpin' : 'Pin'; |
| + if (pin.textContent !== word) pin.textContent = word; |
| + pin.setAttribute('aria-label', word + ' ' + (isSelf(fig) ? 'yourself' : name)); |
| + const people = figures().filter((f) => !isShare(f)); |
| + const keys = stableKeys(people); |
| + const place = orderShown(order).indexOf(keys.get(fig)); |
| + for (const b of menu.querySelectorAll('[data-move]')) { |
| + b.hidden = isShare(fig); |
| + b.setAttribute('aria-label', { earlier: 'Move ' + name + ' earlier', later: 'Move ' + name + ' later', first: 'Move ' + name + ' to first' }[b.dataset.move]); |
| + b.disabled = b.dataset.move === 'later' ? place >= people.length - 1 : place <= 0; |
| + } |
| + const remove = menu.querySelector('.calls-tile-remove'); |
| + const href = fig.dataset.removeHref || ''; |
| + remove.hidden = !href; |
| + if (href) { |
| + remove.href = href; |
| + remove.setAttribute('aria-label', 'Remove ' + name + ' from the call'); |
| + } else remove.removeAttribute('href'); |
| + } |
| + |
| + function placeMenu() { |
| + if (!menu || !menuFig || menu.hidden) return; |
| + const button = menuFig.querySelector('.calls-tool-more'); |
| + if (!button) return; |
| + const r = room.getBoundingClientRect(); |
| + const b = button.getBoundingClientRect(); |
| + const w = menu.offsetWidth; |
| + const h = menu.offsetHeight; |
| + let top = b.bottom - r.top + 6; |
| + if (top + h > r.height - 8) top = Math.max(8, b.top - r.top - h - 6); |
| + const left = Math.min(Math.max(b.right - r.left - w, 8), r.width - w - 8); |
| + menu.style.setProperty('--calls-menu-x', Math.round(left) + 'px'); |
| + menu.style.setProperty('--calls-menu-y', Math.round(top) + 'px'); |
| + } |
| + |
| + function openMenu(fig, button) { |
| + if (menuFig) closeMenu(false); |
| + menuFig = fig; |
| + fillMenu(); |
| + menu.hidden = false; |
| + button.setAttribute('aria-expanded', 'true'); |
| + fig.classList.add('calls-tile-menu-open'); |
| + placeMenu(); |
| + const first = menu.querySelector('button:not([hidden]):not([disabled]), a:not([hidden])'); |
| + if (first) first.focus({ preventScroll: true }); |
| + } |
| + |
| + function closeMenu(refocus) { |
| + if (!menuFig) return; |
| + const fig = menuFig; |
| + menuFig = null; |
| + menu.hidden = true; |
| + fig.classList.remove('calls-tile-menu-open'); |
| + const button = fig.querySelector('.calls-tool-more'); |
| + if (button) { |
| + button.setAttribute('aria-expanded', 'false'); |
| + if (refocus && fig.isConnected) button.focus({ preventScroll: true }); |
| + } |
| + } |
| + |
| + if (menu) { |
| + menu.addEventListener('click', (ev) => { |
| + const item = ev.target.closest('button, a'); |
| + if (!item || !menuFig) return; |
| + const fig = menuFig; |
| + if (item.classList.contains('calls-tile-remove')) { |
| + closeMenu(false); |
| + return; |
| + } |
| + ev.preventDefault(); |
| + if (item.dataset.act === 'pin') { |
| + closeMenu(true); |
| + togglePin(fig); |
| + } else if (item.dataset.move) moveBy(fig, item.dataset.move, item); |
| + }); |
| + menu.addEventListener('keydown', (ev) => { |
| + if (ev.key === 'Escape') { |
| + ev.preventDefault(); |
| + closeMenu(true); |
| + } |
| + }); |
| + document.addEventListener('pointerdown', (ev) => { |
| + if (!menuFig || menu.contains(ev.target)) return; |
| + const more = menuFig.querySelector('.calls-tool-more'); |
| + if (more && more.contains(ev.target)) return; |
| + closeMenu(false); |
| + }, true); |
| + window.addEventListener('resize', placeMenu); |
| + } |
| + |
| function togglePin(fig) { |
| const key = fig.dataset.tile; |
| if (pinned === key) { |
| @@ -302,11 +409,11 @@ |
| layout(); |
| const place = orderShown(order).indexOf(key); |
| say(nameOf(fig) + ' moved to position ' + (place + 1) + ' of ' + people.length); |
| - // The button went with its tile; keep the keyboard on it, or on the |
| - // move that is still possible. |
| + // The menu stays open on the tile, which has moved: keep the |
| + // keyboard on the move, or on the one that is still possible. |
| if (button) { |
| - const again = button.disabled ? fig.querySelector('[data-move]:not([disabled])') : button; |
| - if (again) again.focus(); |
| + const again = button.disabled ? menu.querySelector('[data-move]:not([disabled])') : button; |
| + if (again) again.focus({ preventScroll: true }); |
| } |
| } |
| @@ -372,7 +479,6 @@ |
| const pip = !!stage && strip.length === 1 && strip[0] === self && !isShare(stage) && !isSelf(stage) && |
| !stage.querySelector('.calls-tile-note:not([hidden]), figcaption button:not([hidden])'); |
| tiles.toggleAttribute('data-pip', pip); |
| - const count = ordered.length; |
| for (const fig of all) { |
| fig.classList.toggle('calls-tile-pip', pip && fig === self); |
| const onStage = fig === stage; |
| @@ -385,9 +491,16 @@ |
| note.textContent = 'On stage:'; |
| fig.prepend(note); |
| } else if (!onStage && note) note.remove(); |
| - labelTools(fig, pinned === fig.dataset.tile, ordered.indexOf(fig), count); |
| + labelTools(fig, pinned === fig.dataset.tile); |
| } |
| fitGrid(); |
| + if (menuFig) { |
| + if (!menuFig.isConnected) closeMenu(false); |
| + else { |
| + fillMenu(); |
| + placeMenu(); |
| + } |
| + } |
| } |
| // fitGrid writes gridFit's answer for the stage as it is now: the |
diff --git a/internal/calls/static/call-window.js b/internal/calls/static/call-window.js| index 4748042..a34337d 100644 |
| --- a/internal/calls/static/call-window.js |
| +++ b/internal/calls/static/call-window.js |
| @@ -119,6 +119,7 @@ |
| const leave = $('leave'); |
| const chatOverlay = $('chat-overlay'); |
| const chatToggle = $('toggle-chat'); |
| + const tileMenu = $('tile-menu'); |
| const phoneQuery = matchMedia('(max-width: 599px), (max-height: 499px) and (pointer: coarse)'); |
| const motionQuery = matchMedia('(prefers-reduced-motion: reduce)'); |
| const phone = () => phoneQuery.matches; |
| @@ -186,7 +187,7 @@ |
| // does nothing else — its click is swallowed. A mouse always moves |
| // first, so its controls are up by the time it presses. |
| const INTERACTIVE = 'button, a, summary, input, select, textarea, label, .calls-popover, .calls-chat-overlay, ' + |
| - '.calls-nav-menu, .calls-sheet, .calls-notices > *, .calls-tile-tools'; |
| + '.calls-nav-menu, .calls-sheet, .calls-notices > *, .calls-tile-tools, .calls-tile-menu'; |
| let swallow = false; |
| win.addEventListener('pointerdown', (ev) => { |
| const target = ev.target instanceof Element ? ev.target : null; |
| @@ -216,7 +217,7 @@ |
| function focusCheck() { |
| const a = document.activeElement; |
| let keyboard = false; |
| - if (a && a !== document.body && (overChrome(a) || inPopover(a) || a.closest('.calls-tile-tools'))) { |
| + if (a && a !== document.body && (overChrome(a) || inPopover(a) || a.closest('.calls-tile-tools, .calls-tile-menu'))) { |
| try { keyboard = a.matches(':focus-visible'); } catch { keyboard = true; } |
| } |
| hold('keyboardFocus', keyboard); |
| @@ -338,11 +339,13 @@ |
| // What holds the controls up besides the pointer and the keyboard. |
| function menusCheck() { |
| const open = popovers.some((p) => !p.panel.hidden) || (chatOverlay && !chatOverlay.hidden) || |
| + (tileMenu && !tileMenu.hidden) || |
| (nav && nav.open) || (sheet && sheet.hasAttribute('data-open')); |
| hold('menuOpen', open); |
| } |
| const watch = (el, attrs) => { if (el) new MutationObserver(menusCheck).observe(el, { attributes: true, attributeFilter: attrs }); }; |
| watch(chatOverlay, ['hidden']); |
| + watch(tileMenu, ['hidden']); |
| watch(nav, ['open']); |
| watch(sheet, ['data-open']); |
| // The call is over: Leave is disabled and the way out is in the notices. |
| @@ -356,6 +359,47 @@ |
| motionCheck(); |
| menusCheck(); |
| + // ---- the title's status, and the relay notice ---- |
| + |
| + // callState is the pill's few words for how the call is going, as the |
| + // Mac's pill has them: the full sentence, and its way out when there |
| + // is one, is the status notice under it. Lifted by name in |
| + // room-window.test.mjs. |
| + function callState(s, status) { |
| + if (!s) return 'Connecting'; |
| + if (s.terminal) return 'Call ended'; |
| + if (/full/i.test(status || '')) return 'The room is full'; |
| + if (!s.admitted) return /waiting/i.test(status || '') ? 'Waiting to be let in' : 'Connecting'; |
| + return 'In the meeting'; |
| + } |
| + const stateLine = $('call-state'); |
| + const statusEl = $('status'); |
| + function drawState() { |
| + if (!stateLine || !window.meet || typeof window.meet.state !== 'function') return; |
| + let st = null; |
| + try { st = window.meet.state(); } catch { st = null; } |
| + const text = callState(st, statusEl ? statusEl.textContent : ''); |
| + if (stateLine.textContent !== text) stateLine.textContent = text; |
| + stateLine.hidden = false; |
| + } |
| + if (statusEl) new MutationObserver(drawState).observe(statusEl, { childList: true, characterData: true, subtree: true }); |
| + setInterval(drawState, 1000); |
| + drawState(); |
| + |
| + // §5.5's relay notice: shown in the first room a tab's session opens, |
| + // until it is put away; not again in that session. |
| + const floor = $('floor-notice'); |
| + if (floor) { |
| + const key = 'calls.floorSeen'; |
| + let seen = false; |
| + try { seen = sessionStorage.getItem(key) === '1'; } catch { /* private mode: show it */ } |
| + if (!seen) { |
| + floor.hidden = false; |
| + try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } |
| + } |
| + $('floor-dismiss')?.addEventListener('click', () => { floor.hidden = true; }); |
| + } |
| + |
| // The notices sit under the title, however tall it grows; it keeps its |
| // place while faded, so they do not jump as the controls come and go. |
| const titleHeight = () => win.style.setProperty('--calls-title-h', Math.ceil(top.offsetHeight) + 'px'); |
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index d79e399..6766953 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -248,6 +248,7 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| .calls-window .calls-ice-warning, |
| .calls-window .calls-share-notice-row, |
| .calls-window .calls-sound-banner, |
| +.calls-window .calls-floor-notice, |
| .calls-window .calls-queue { |
| background: var(--calls-glass); |
| color: #fff; |
| @@ -263,6 +264,7 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| .calls-window .calls-ice-warning, |
| .calls-window .calls-share-notice-row, |
| .calls-window .calls-sound-banner, |
| + .calls-window .calls-floor-notice, |
| .calls-window .calls-queue { background: rgb(18 18 18 / 0.92); } |
| } |
| /* The gate's fallback check turns the blur off to measure the black |
| @@ -377,6 +379,14 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| .calls-nav-menu button:hover, |
| .calls-menu a:hover { background: rgb(255 255 255 / 0.1); } |
| .calls-nav-menu form { margin: 0; } |
| +.calls-nav-standing { |
| + margin: 0 0 6px; |
| + padding: 6px 12px 10px; |
| + border-block-end: 1px solid rgb(255 255 255 / 0.12); |
| + color: rgb(255 255 255 / 0.85); |
| + font-size: 0.8125rem; |
| + line-height: 1.4; |
| +} |
| .calls-nav-who { |
| margin: 6px 0 0; |
| padding: 8px 12px 2px; |
| @@ -494,6 +504,30 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| font-size: 0.8125rem; |
| } |
| +/* §5.5's relay notice, once a session, with its own Dismiss. */ |
| +.calls-floor-notice:not([hidden]) { |
| + display: flex; |
| + align-items: center; |
| + gap: 8px; |
| + padding: 6px 6px 6px 16px; |
| + border-radius: 18px; |
| + font-size: 0.875rem; |
| +} |
| +.calls-floor-notice p { margin: 0; } |
| +.calls-floor-notice button { |
| + display: grid; |
| + place-items: center; |
| + flex: none; |
| + inline-size: 28px; |
| + block-size: 28px; |
| + border: 0; |
| + border-radius: 999px; |
| + background: rgb(255 255 255 / 0.14); |
| + color: #fff; |
| + cursor: pointer; |
| +} |
| +.calls-floor-notice button .icon { inline-size: 16px; block-size: 16px; } |
| + |
| /* The page's own "Click anywhere to turn on sound". */ |
| .calls-sound-banner { |
| display: flex; |
| @@ -892,61 +926,99 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| } |
| -/* Speaker view's arrangement buttons (0.4.0), in each tile's top |
| - corner: shown on hover and whenever the keyboard is in them, and |
| - always on a screen with no hover. */ |
| +/* A tile's own buttons (2026-09-29): ⋯, which opens the tile's menu, |
| + and a Pinned badge while the tile is pinned (pressing it unpins). In |
| + the top corner, and only while the pointer or the keyboard is in the |
| + tile or its menu is open — and never with the controls gone, so the |
| + picture is clean. On a screen with no hover they come with the |
| + controls. The Pinned badge shows with the controls whatever the |
| + pointer does, so a pinned stage says so. */ |
| .calls-tile-tools { |
| position: absolute; |
| - inset-block-start: 6px; |
| - inset-inline-end: 6px; |
| + inset-block-start: 8px; |
| + inset-inline-end: 8px; |
| display: flex; |
| gap: 4px; |
| - opacity: 0; |
| z-index: 2; |
| } |
| -.calls-tile:hover > .calls-tile-tools, |
| -.calls-tile:focus-within > .calls-tile-tools { opacity: 1; } |
| -@media (hover: none) { .calls-tile-tools { opacity: 1; } } |
| +.calls-tile-tools > .calls-tool { |
| + opacity: 0; |
| + transition: opacity var(--calls-fade-in, 0.2s) ease-out; |
| +} |
| +.calls-tile:hover > .calls-tile-tools > .calls-tool, |
| +.calls-tile:focus-within > .calls-tile-tools > .calls-tool, |
| +.calls-tile.calls-tile-menu-open > .calls-tile-tools > .calls-tool, |
| +.calls-tile[data-pinned] > .calls-tile-tools > .calls-tool-pin { opacity: 1; } |
| +@media (hover: none) { |
| + .calls-window[data-chrome='shown'] .calls-tile-tools > .calls-tool { opacity: 1; } |
| +} |
| +.calls-window[data-chrome='hidden'] .calls-tile:not(:focus-within, .calls-tile-menu-open) > .calls-tile-tools > .calls-tool { |
| + opacity: 0; |
| + transition-duration: var(--calls-fade-out, 0.35s); |
| +} |
| +.calls-tool[hidden] { display: none; } |
| .calls-window #tiles[data-layout='speaker'] > [data-stage] > .calls-tile-tools { inset-block-start: 12px; inset-inline-end: 12px; } |
| .calls-tool { |
| display: inline-flex; |
| align-items: center; |
| + justify-content: center; |
| gap: 4px; |
| - min-inline-size: 28px; |
| - min-block-size: 28px; |
| - padding: 2px 6px; |
| + min-inline-size: 30px; |
| + min-block-size: 30px; |
| + padding: 2px 8px; |
| border: 0; |
| - border-radius: 14px; |
| - background: #000000b3; |
| + border-radius: 999px; |
| + background: var(--calls-glass, rgb(0 0 0 / 0.62)); |
| + -webkit-backdrop-filter: blur(18px) saturate(1.6); |
| + backdrop-filter: blur(18px) saturate(1.6); |
| color: #fff; |
| font: inherit; |
| font-size: var(--rst-fs-xs); |
| cursor: pointer; |
| } |
| +.calls-tool-more { padding: 2px; } |
| .calls-tool .icon { inline-size: 16px; block-size: 16px; } |
| .calls-tool:focus-visible { outline: 2px solid #fff; outline-offset: 1px; } |
| -.calls-tool[disabled] { opacity: 0.4; cursor: default; } |
| -/* A pinned stage says so whether or not the pointer is over it: the pin |
| - stays up, reading "Pinned", and pressing it unpins. */ |
| -.calls-tile[data-pinned] > .calls-tile-tools { opacity: 1; } |
| -.calls-tile[data-pinned] > .calls-tile-tools .calls-tool:not(.calls-tool-pin) { display: none; } |
| +.calls-tool-more[aria-expanded='true'] { background: #f0f0f0; color: #1a1a1a; } |
| .calls-tile[data-pinned] .calls-tool-pin { background: #fff; color: #000; } |
| -.calls-tile:hover[data-pinned] > .calls-tile-tools .calls-tool:not(.calls-tool-pin), |
| -.calls-tile[data-pinned]:focus-within > .calls-tile-tools .calls-tool:not(.calls-tool-pin) { display: inline-flex; } |
| -/* A tile's arrangement buttons go with the controls, so the picture is |
| - clean when they have gone; a pinned stage keeps its Pinned. */ |
| -.calls-window[data-chrome='hidden'] .calls-tile:not([data-pinned]) > .calls-tile-tools { opacity: 0; } |
| -/* The floating self view keeps its arrangement buttons out of the way |
| - of its own small picture until the pointer or the keyboard is in it. */ |
| -.calls-window #tiles > .calls-tile-pip > .calls-tile-tools { opacity: 0; } |
| -.calls-window #tiles > .calls-tile-pip:hover > .calls-tile-tools, |
| -.calls-window #tiles > .calls-tile-pip:focus-within > .calls-tile-tools { opacity: 1; } |
| -@media (hover: none) { |
| - .calls-window #tiles > .calls-tile-pip:not(:focus-within) > .calls-tile-tools { opacity: 0; pointer-events: none; } |
| + |
| +/* The tile menu: one panel for every tile, over the picture beside the |
| + ⋯ that opened it (call-layout.js sets its place). */ |
| +.calls-tile-menu { |
| + position: absolute; |
| + z-index: 8; |
| + inset-block-start: var(--calls-menu-y, 0); |
| + inset-inline-start: var(--calls-menu-x, 0); |
| + display: flex; |
| + flex-direction: column; |
| + min-inline-size: 13rem; |
| + padding: 6px; |
| + border-radius: 14px; |
| + background: var(--calls-panel); |
| + color: #fff; |
| + box-shadow: 0 8px 28px rgb(0 0 0 / 0.4); |
| } |
| -/* Grid and the stage need no Pin word on every tile; the filmstrip's |
| - buttons stay icons too. */ |
| -.calls-tile:not([data-pinned]) .calls-tool-text { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); } |
| +.calls-tile-menu[hidden], |
| +.calls-tile-menu [hidden] { display: none; } |
| +.calls-menu button { |
| + display: flex; |
| + align-items: center; |
| + min-block-size: 40px; |
| + padding: 0 12px; |
| + border: 0; |
| + border-radius: 9px; |
| + background: none; |
| + color: #fff; |
| + font: inherit; |
| + font-size: 0.9375rem; |
| + text-align: start; |
| + cursor: pointer; |
| + inline-size: 100%; |
| +} |
| +.calls-menu button:hover:not(:disabled) { background: rgb(255 255 255 / 0.1); } |
| +.calls-menu button:disabled { opacity: 0.45; cursor: default; } |
| +.calls-tile-menu .calls-tile-remove { margin-block-start: 4px; border-block-start: 1px solid rgb(255 255 255 / 0.12); border-radius: 0 0 9px 9px; } |
| + |
| /* The tile being dragged: lifted over the others, with the accent edge, |
| following the pointer (its translate is set as it moves); the others |
| make room as it passes over their places. Reduced motion: no growth |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index c8dbcbd..7fdc6da 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -5353,11 +5353,13 @@ |
| // The removal control comes and goes with our own kind, which the |
| // roster re-states on every tick — a member demoted mid-call loses |
| // it without the tile being rebuilt. |
| + // It is not in the name's label: since 2026-09-29 it is an item in |
| + // the tile's ⋯ menu (call-layout.js), which reads it from here. |
| if (!isSelf && isMember() && !t.remove && PEER_ID.test(key)) { |
| - t.remove = removeLink(key); |
| - t.label.append(t.remove); |
| + t.remove = removeHref(key); |
| + t.figure.dataset.removeHref = t.remove; |
| } else if (t.remove && !isMember()) { |
| - t.remove.remove(); |
| + delete t.figure.dataset.removeHref; |
| t.remove = null; |
| } |
| // The track as well as the stream: a camera the far side rebuilt |
| @@ -5414,16 +5416,11 @@ |
| }); |
| } |
| - function removeLink(id) { |
| - // §3.5: a destructive action gets its own confirm page URL. A new |
| - // tab, because this one is holding a call. |
| - const link = document.createElement('a'); |
| - link.href = '/m/' + encodeURIComponent(sqid) + '/remove/' + id; |
| - link.target = '_blank'; |
| - link.rel = 'noopener'; |
| - link.className = 'calls-tile-remove'; |
| - link.textContent = 'Remove'; |
| - return link; |
| + function removeHref(id) { |
| + // §3.5: a destructive action gets its own confirm page URL, which |
| + // the tile menu opens in a new tab, because this one is holding a |
| + // call. |
| + return '/m/' + encodeURIComponent(sqid) + '/remove/' + id; |
| } |
| // restartIce is the Retry on a tile, and it answers both sentences. |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index fa79f8e..ea18507 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -27,6 +27,7 @@ |
| <details id="call-nav" class="calls-nav"> |
| <summary aria-label="Menu" title="Meetings, help and your account">{{icon "menu"}}</summary> |
| <nav class="calls-nav-menu" aria-label="Calls"> |
| + <p class="calls-nav-standing">You are {{.Name}}, joining as a {{.Standing}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| {{if .IsMember}}<a href="/">Meetings</a>{{end}} |
| {{if .Viewer}}{{if .Viewer.Member}}<a href="/settings">Settings</a>{{end}}{{end}} |
| <a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Help: open source</a> |
| @@ -36,8 +37,13 @@ |
| </details> |
| <div class="calls-top-text"> |
| <h1 id="call-title">{{.Title}}</h1> |
| - <p class="calls-top-line">You are {{.Name}}, joining as a {{.Standing}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| - {{if .Floor}}<p rst-notice class="calls-top-line calls-floor">This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| + {{/* How the call is going, in a few words, as the Mac's pill has |
| + it: call-window.js keeps it. The standing line is in the menu |
| + and the relay notice is one of the notices; with no script |
| + neither has anywhere else to be, so both are here in full. */}} |
| + <p id="call-state" class="calls-top-line" hidden></p> |
| + <noscript><p class="calls-top-line">You are {{.Name}}, joining as a {{.Standing}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p> |
| + {{if .Floor}}<p rst-notice class="calls-top-line calls-floor">This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}</noscript> |
| </div> |
| </header> |
| @@ -72,6 +78,10 @@ |
| <p id="share-recovery" class="calls-share-recovery" role="status" hidden></p> |
| </div> |
| <p id="status" class="calls-status" role="status"></p> |
| + {{/* §5.5's relay notice, once a session: call-window.js shows it on |
| + the first visit of a tab's session and it stays until it is put |
| + away. */}} |
| + {{if .Floor}}<div id="floor-notice" class="calls-floor-notice" hidden><p>This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p><button type="button" id="floor-dismiss" aria-label="Dismiss the relay notice" title="Dismiss">{{icon "x"}}</button></div>{{end}} |
| <div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div> |
| </div> |
| @@ -176,6 +186,19 @@ |
| one script, and one comment in partials/chat.html; chat.js loads |
| before room.js in the head block, and two deferred scripts run in |
| document order, so window.Chat is there when room.js mounts it. */}} |
| + {{/* One tile's menu (2026-09-29), opened from the ⋯ on a tile: |
| + Pin, the three moves — the way to do without a drag what a drag |
| + does, named for the person ("Move Ana earlier") — and, for a |
| + member, Remove, a link to its own confirm page in a new tab. A |
| + screen keeps only Pin. call-layout.js fills and places it. */}} |
| + <div id="tile-menu" class="calls-menu calls-tile-menu" role="group" hidden> |
| + <button type="button" data-act="pin">Pin</button> |
| + <button type="button" data-move="first">Move to first</button> |
| + <button type="button" data-move="earlier">Move earlier</button> |
| + <button type="button" data-move="later">Move later</button> |
| + <a class="calls-tile-remove calls-menu-danger" target="_blank" rel="noopener" hidden>Remove from the call</a> |
| + </div> |
| + |
| <div id="chat-overlay" class="calls-chat-overlay" hidden> |
| <button type="button" id="chat-close" class="calls-chat-close" aria-label="Hide chat" title="Hide chat">{{icon "x"}}</button> |
| {{template "chat-panel" .}} |
| @@ -189,9 +212,9 @@ |
| <template id="tile-muted">{{icon "mic-off"}}{{template "badge" dict "Label" "Muted"}}</template> |
| <template id="tile-camera-off">{{icon "video-off"}}<span>Camera off</span></template> |
| {{/* Each tile's arrangement buttons, which call-layout.js clones and |
| - names ("Pin Ana", "Move Ana earlier"): the way to do without a |
| - drag what a drag does. A screen's tile keeps only Pin. */}} |
| - <template id="tile-tools"><button type="button" class="calls-tool calls-tool-pin" data-act="pin" aria-pressed="false">{{icon "pin"}}<span class="calls-tool-text">Pin</span></button><button type="button" class="calls-tool" data-move="first">{{icon "arrow-left-to-line"}}</button><button type="button" class="calls-tool" data-move="earlier">{{icon "arrow-left"}}</button><button type="button" class="calls-tool" data-move="later">{{icon "arrow-right"}}</button></template> |
| + names: a Pinned badge while the tile is pinned, and ⋯, which opens |
| + #tile-menu for that tile. */}} |
| + <template id="tile-tools"><button type="button" class="calls-tool calls-tool-pin" data-act="pin" aria-pressed="true">{{icon "pin"}}<span class="calls-tool-text">Pinned</span></button><button type="button" class="calls-tool calls-tool-more" aria-expanded="false" aria-controls="tile-menu">{{icon "ellipsis"}}</button></template> |
| </div> |
| </main> |
| {{end}} |
diff --git a/internal/callstest/room_test.go b/internal/callstest/room_test.go| index daf8155..ca0ca34 100644 |
| --- a/internal/callstest/room_test.go |
| +++ b/internal/callstest/room_test.go |
| @@ -744,8 +744,8 @@ func TestRoomIsOneWindowWhoseNavigationWorksWithoutScript(t *testing.T) { |
| t.Fatalf("the title's menu lacks %q:\n%s", want, menu) |
| } |
| } |
| - ns := strings.Index(body, "<noscript>") |
| - nsEnd := strings.Index(body, "</noscript>") |
| + ns := strings.Index(body, `<noscript><div class="calls-scriptless">`) |
| + nsEnd := ns + strings.Index(body[max(ns, 0):], "</noscript>") |
| if ns < win || nsEnd < ns || nsEnd > room { |
| t.Fatalf("the scriptless block belongs in the window, before #room: %d..%d, room %d", ns, nsEnd, room) |
| } |
| @@ -782,3 +782,35 @@ func TestGuestRoomWindowCarriesNoMemberActions(t *testing.T) { |
| t.Fatal("a guest keeps Help and Sign out in the title's menu") |
| } |
| } |
| + |
| +// The title pill (2026-09-29) is the title and a few words, as the |
| +// Mac's is. The standing line is the first line of the title's menu, |
| +// and the relay notice is one of the notices, shown once a session — |
| +// both script-driven places, so with no script the pill carries both |
| +// in full, inside <noscript>. |
| +func TestRoomTitlePillMovesStandingAndRelayNoticeOut(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid).Body.String() |
| + |
| + nav := strings.Index(body, `<nav class="calls-nav-menu"`) |
| + standing := strings.Index(body, `<p class="calls-nav-standing">You are paul, joining as a member.`) |
| + meetings := strings.Index(body, `href="/">Meetings</a>`) |
| + if nav < 0 || standing < nav || meetings < standing { |
| + t.Fatalf("the standing line must open the title's menu, above Meetings: nav %d standing %d meetings %d", nav, standing, meetings) |
| + } |
| + top := body[strings.Index(body, `<div class="calls-top-text">`):strings.Index(body, `</header>`)] |
| + ns := strings.Index(top, "<noscript>") |
| + if ns < 0 || !strings.Contains(top[ns:], "joining as a member") || !strings.Contains(top[ns:], "no relay yet") { |
| + t.Fatalf("with no script the pill must still say the standing and the relay notice:\n%s", top) |
| + } |
| + if strings.Contains(top[:ns], "joining as a member") || strings.Contains(top[:ns], "no relay yet") { |
| + t.Fatalf("with script the pill is the title and its status only:\n%s", top[:ns]) |
| + } |
| + room := strings.Index(body, `<div id="room"`) |
| + floor := strings.Index(body, `<div id="floor-notice" class="calls-floor-notice" hidden>`) |
| + if floor < room || !strings.Contains(body[floor:], `id="floor-dismiss"`) { |
| + t.Fatalf("the relay notice belongs in #room's notices, hidden until script shows it, with a Dismiss: %d", floor) |
| + } |
| +} |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index a5b725b..204a71e 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -699,6 +699,17 @@ async function openChat(page) { |
| await page.locator('#chat-text').waitFor({ state: 'visible', timeout: 5000 }); |
| } |
| +// openTileMenu opens a tile's ⋯ menu (2026-09-29): Pin, the moves and, |
| +// for a member, Remove live there rather than on the tile. |
| +async function openTileMenu(page, key) { |
| + const more = page.locator('#tiles > figure[data-tile="' + key + '"] .calls-tool-more'); |
| + if ((await more.getAttribute('aria-expanded')) !== 'true') { |
| + await page.locator('#tiles > figure[data-tile="' + key + '"]').hover(); |
| + await more.click(); |
| + } |
| + await page.locator('#tile-menu').waitFor({ state: 'visible', timeout: 3000 }); |
| +} |
| + |
| // chooseLayout picks Speaker or Grid from the bar's layout popover, and |
| // closes it again. |
| async function chooseLayout(page, mode) { |
| @@ -3373,6 +3384,9 @@ const run = { |
| return; |
| } |
| + check(await pageA.locator('#tiles figcaption .calls-tile-remove, #tiles figcaption a').count() === 0, |
| + 'the name label carries no Remove: it is in the tile menu'); |
| + await openTileMenu(pageA, pair.b.peer); |
| const href = await pageA.locator('.calls-tile-remove').first().getAttribute('href'); |
| check(href === '/m/' + sqid + '/remove/' + pair.b.peer, |
| "the Remove control names the guest's peer (" + href + ')'); |
| @@ -4655,7 +4669,8 @@ const run = { |
| await chooseLayout(pageA, 'grid'); |
| const before = (await layoutOf(pageA)).tiles; |
| const last = before[before.length - 1]; |
| - const first = pageA.locator('#tiles > figure[data-tile="' + last + '"] [data-move="first"]'); |
| + await openTileMenu(pageA, last); |
| + const first = pageA.locator('#tile-menu [data-move="first"]'); |
| await first.focus(); |
| await pageA.keyboard.press('Enter'); |
| const moved = await until(async () => { |
| @@ -4664,12 +4679,16 @@ const run = { |
| }, 3000); |
| check(moved, 'Move to first puts ' + last + ' first in the DOM (' + JSON.stringify(await layoutOf(pageA)) + ')'); |
| check(/moved to position 1 of 4/.test(await pageA.locator('#layout-live').textContent()), 'and says where it went'); |
| - check(await pageA.evaluate(() => !!document.activeElement.closest('#tiles figure')), 'the keyboard stays with the tile'); |
| + check(await pageA.evaluate(() => !!document.activeElement.closest('#tile-menu')), 'the keyboard stays in the tile\'s menu'); |
| // Move later, by keyboard: one place on. |
| - const later = pageA.locator('#tiles > figure[data-tile="' + last + '"] [data-move="later"]'); |
| + const later = pageA.locator('#tile-menu [data-move="later"]'); |
| await later.focus(); |
| await pageA.keyboard.press('Enter'); |
| check(await until(async () => (await layoutOf(pageA)).tiles[1] === last, 3000), 'Move later moves it one place'); |
| + await pageA.keyboard.press('Escape'); |
| + check(await pageA.locator('#tile-menu').isHidden() && |
| + await pageA.evaluate(() => document.activeElement && document.activeElement.classList.contains('calls-tool-more')), |
| + 'Escape closes the menu and puts the keyboard back on its ⋯'); |
| // A mouse drag: the first tile dropped on the third's place. |
| const order1 = (await layoutOf(pageA)).tiles; |
| @@ -4847,7 +4866,8 @@ const run = { |
| await pageA.emulateMedia({ reducedMotion: 'reduce' }); |
| await chooseLayout(pageA, 'speaker'); |
| // Pin yourself, so B is in the filmstrip. |
| - await pageA.locator('#tiles > figure[data-tile="self"] .calls-tool-pin').click({ force: true }); |
| + await openTileMenu(pageA, 'self'); |
| + await pageA.locator('#tile-menu [data-act="pin"]').click(); |
| check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000), |
| 'A pinned to the stage, B in the filmstrip'); |
| await pageA.mouse.move(1, 1); |
| @@ -5006,6 +5026,31 @@ const run = { |
| check(await pageA.locator('rst-shell-topbar, [rst-shell-topbar], [rst-shell-bar]').count() === 0, |
| 'the page shell is gone: its navigation is in the title\'s menu'); |
| + // ── The title pill is the title and a few words (2026-09-29) ── |
| + const pill = await pageA.evaluate(() => { |
| + const top = document.getElementById('call-top'); |
| + return { text: top.innerText.trim().replace(/\n+/g, '\n'), height: top.getBoundingClientRect().height }; |
| + }); |
| + check(pill.text === 'Board meeting\nIn the meeting', |
| + 'the title pill says the title and "In the meeting", as the Mac\'s does (' + JSON.stringify(pill) + ')'); |
| + await pageA.locator('#call-nav summary').click(); |
| + const standing = (await pageA.locator('.calls-nav-standing').innerText()).trim(); |
| + check(standing === 'You are paul, joining as a member.' && |
| + await pageA.evaluate(() => { |
| + const p = document.querySelector('.calls-nav-standing'); |
| + return !!p.nextElementSibling && p.nextElementSibling.textContent === 'Meetings'; |
| + }), 'the standing line is in the title\'s menu, above Meetings (' + standing + ')'); |
| + await pageA.keyboard.press('Escape'); |
| + check(!(await pageA.locator('#call-nav').evaluate((d) => d.open)), 'Escape closes the menu'); |
| + const floorText = await pageA.locator('#notices #floor-notice').innerText().catch(() => ''); |
| + check(await pageA.locator('#floor-notice').isVisible() && floorText.includes('no relay yet'), |
| + 'the relay notice is a notice of its own under the title, not in it'); |
| + await shot(pageA, '1440-relay-notice'); |
| + await pageA.locator('#floor-dismiss').click(); |
| + check(await pageA.locator('#floor-notice').isHidden() && |
| + await pageA.evaluate(() => sessionStorage.getItem('calls.floorSeen')) === '1', |
| + 'it is dismissed, and not shown again in this session'); |
| + |
| // ── When the controls show ── |
| const w = await box(pageA, '.calls-window'); |
| const cx = w.x + w.width / 2; |
| @@ -5020,6 +5065,8 @@ const run = { |
| await sleep(500); |
| check((await opacity(pageA, '#controls')) < 0.05 && (await opacity(pageA, '#call-top')) < 0.05, |
| 'the bar and the title fade out completely'); |
| + check((await opacity(pageA, '#tiles > [data-stage] .calls-tool-more')) < 0.05, |
| + 'and the tile\'s ⋯ goes with them'); |
| check(await pageA.evaluate(() => getComputedStyle(document.getElementById('stage')).cursor === 'none'), |
| 'and the pointer hides with them over the picture'); |
| await shot(pageA, '1440-speaker-hidden'); |
| @@ -5027,6 +5074,9 @@ const run = { |
| check((await chrome(pageA)).visible, 'moving the pointer brings them back'); |
| await sleep(400); |
| check((await opacity(pageA, '#controls')) > 0.95, 'faded all the way in'); |
| + check((await opacity(pageA, '#tiles > [data-stage] .calls-tool-more')) > 0.95 && |
| + (await opacity(pageA, '#tiles > .calls-tile-self .calls-tool-more')) < 0.05, |
| + 'a tile shows its ⋯ only under the pointer (the stage here, not the self view)'); |
| await shot(pageA, '1440-speaker-shown'); |
| await pageA.mouse.move(Math.max(2, w.x / 2), Math.max(2, w.y / 2)); |
| @@ -5067,6 +5117,37 @@ const run = { |
| 'the pointer resting on the bar holds them up (' + JSON.stringify(barHeld) + ')'); |
| await pageA.mouse.move(cx, cy); |
| + // ── The tile menu: Pin, the moves and Remove, not on the tile ── |
| + const b = pair.b.peer; |
| + check(await pageA.locator('#tiles > figure[data-tile="' + b + '"] figcaption a, #tiles > figure[data-tile="' + b + '"] figcaption .calls-tile-remove').count() === 0, |
| + 'the name label is the name, the mic state and the rim: no Remove in it'); |
| + const moreB = pageA.locator('#tiles > figure[data-tile="' + b + '"] .calls-tool-more'); |
| + await moreB.focus(); |
| + await sleep(300); |
| + check((await moreB.getAttribute('aria-label')) === 'Options for bea' && |
| + (await opacity(pageA, '#tiles > figure[data-tile="' + b + '"] .calls-tool-more')) > 0.95, |
| + 'the keyboard reaches the tile\'s ⋯, named for the person, and shows it'); |
| + await pageA.keyboard.press('Enter'); |
| + const items = await pageA.locator('#tile-menu').evaluate((m) => Array.from(m.querySelectorAll('button, a')) |
| + .filter((el) => !el.hidden).map((el) => el.getAttribute('aria-label') || el.textContent.trim())); |
| + check(JSON.stringify(items) === JSON.stringify(['Pin bea', 'Move bea to first', 'Move bea earlier', 'Move bea later', 'Remove bea from the call']), |
| + 'the tile menu holds Pin, the three moves and Remove (' + JSON.stringify(items) + ')'); |
| + check(await pageA.evaluate(() => !!document.activeElement.closest('#tile-menu')), 'and the keyboard is in it'); |
| + const rm = pageA.locator('#tile-menu .calls-tile-remove'); |
| + check((await rm.getAttribute('href')) === '/m/' + sqid + '/remove/' + b && (await rm.getAttribute('target')) === '_blank', |
| + 'Remove is a link to its own confirm page, in a new tab'); |
| + const menuBox = await box(pageA, '#tile-menu'); |
| + const moreBox = await moreB.boundingBox(); |
| + check(menuBox && menuBox.y >= moreBox.y + moreBox.height && menuBox.x + menuBox.width <= moreBox.x + moreBox.width + 1, |
| + 'the menu opens under its ⋯'); |
| + await shot(pageA, '1440-tile-menu'); |
| + await pageA.keyboard.press('Escape'); |
| + check(await pageA.locator('#tile-menu').isHidden() && |
| + await pageA.evaluate(() => document.activeElement && document.activeElement.classList.contains('calls-tool-more')), |
| + 'Escape closes it and the keyboard goes back to the ⋯'); |
| + await pageA.evaluate(() => document.activeElement.blur()); |
| + await pageA.mouse.move(cx, cy); |
| + |
| // ── Chat, as a sidebar over the picture ── |
| const stageBefore = await box(pageA, '#stage'); |
| await openChat(pageA); |
| @@ -5287,6 +5368,8 @@ const run = { |
| const gp = await geometry(pageP); |
| check(inset(gp, 0, 0) && gp.radius === 0 && !gp.scrolls, |
| 'phone: the window goes edge to edge, square-cornered (' + JSON.stringify(gp) + ')'); |
| + const phonePill = await box(pageP, '#call-top'); |
| + check(phonePill.height <= 64, 'phone: the title pill is two short lines, not a quarter of the screen (' + phonePill.height + ' px)'); |
| const more = await box(pageP, '#more-controls'); |
| const leave = await box(pageP, '#leave'); |
| check(more && leave && more.x < 60 && leave.x + leave.width > 330 && !(await pageP.locator('#mute').isVisible()), |
diff --git a/test/browser/room-window.test.mjs b/test/browser/room-window.test.mjs| index c748eb7..c979b1a 100644 |
| --- a/test/browser/room-window.test.mjs |
| +++ b/test/browser/room-window.test.mjs |
| @@ -151,3 +151,15 @@ test('a dropped self view settles where it was headed', () => { |
| assert.equal(layout.pipCorner({ x: 900, y: 500 }, null, 1000, 600), 'bottom-right'); |
| assert.equal(layout.pipCorner({ x: 450, y: 500 }, { x: 700, y: 100 }, 1000, 600), 'top-right', 'a flick carries it'); |
| }); |
| + |
| +test('the title pill\'s few words for how the call is going', () => { |
| + // eslint-disable-next-line no-new-func |
| + const callState = new Function(lift(windowJS, 'callState') + '; return callState;')(); |
| + assert.equal(callState(null, ''), 'Connecting'); |
| + assert.equal(callState({ admitted: false }, 'Connecting to the call.'), 'Connecting'); |
| + assert.equal(callState({ admitted: false }, 'Waiting for someone to let you in.'), 'Waiting to be let in'); |
| + assert.equal(callState({ admitted: false }, 'The room is full.'), 'The room is full'); |
| + assert.equal(callState({ admitted: true }, ''), 'In the meeting'); |
| + assert.equal(callState({ admitted: true }, 'You are the only one here.'), 'In the meeting'); |
| + assert.equal(callState({ admitted: false, terminal: 'ended' }, 'This meeting has ended.'), 'Call ended'); |
| +}); |