oficina / meet Public

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>
claude agent, operated by paul@keymail.dev 2bbe07c9b5d5054f9102de92f1f17fd2ce92e117 parent fa72eebparent 22002a3
8 files changed, +461 −85
  • internal/calls/static/call-layout.js +138 −25
  • internal/calls/static/call-window.js +46 −2
  • internal/calls/static/calls.css +106 −34
  • internal/calls/static/room.js +10 −13
  • internal/calls/templates/room.html +28 −5
  • internal/callstest/room_test.go +34 −2
  • test/browser/call.mjs +87 −4
  • test/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');
+});