oficina / meet Public

Calls web: the call in one rounded window, as the Mac's full-window call

Paul: "Can the web UI for a call get an update so it more closely
matches the Mac experience, with the whole call UI in a rounded rect
'window' with a bit of padding within the viewport?"

The room replaces the page shell with one window inset 20px from the
viewport (12px in a narrow window, edge to edge on a phone), 18px
corners, the page never scrolling. Inside it, the Mac's full-window
call: the picture fills the window; the title pill top left carries the
title, standing and floor notice, and the page's navigation (Meetings,
Settings, Help, Sign out) in a native <details> menu that works with
no script; the notices (sharing with Stop sharing, "Click anywhere to
turn on sound", status, relay warning, Admit) stay in a column under
it; the glass bar is glyphs in the Mac's order with tooltips, state
capsules and a red Leave, End meeting for everyone in its More menu
as a link to the confirm page; Devices, Layout and More are popovers
over their buttons; chat is a sidebar over the stage, closed until
asked for; Grid fits every tile (the Mac's GridFit); the self view
floats in a call of two, draggable to a corner.

call-window.js ports ControlsVisibility: shown on movement, gone 3 s
after it stops or 1.5 s after the pointer leaves, back on Tab, held by
the pointer on the bar, keyboard focus, an open panel, the call ending,
and always with reduced motion. A phone gets More controls and Leave,
with the rest in a sheet, as the iPhone app has it.

Gate: a new `window` scenario (inset/edge to edge, the controls'
timings, chat as an overlay, popovers, self view, sharing notice,
glass contrast over white, black and busy video with and without blur,
the no-script room in the window, phone portrait and landscape);
room-window.test.mjs holds the machine's constants to CallChrome.swift.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d65f856d97d8b50b5db05244b28a0653b7b08311 parent caf0ffe
12 files changed, +2515 −588
  • Makefile +1 −0
  • internal/calls/icons/icons.go +12 −0
  • internal/calls/static/call-layout.js +177 −8
  • internal/calls/static/call-window.js +384 −0
  • internal/calls/static/calls.css +1034 −484
  • internal/calls/static/room.js +11 −3
  • internal/calls/templates/layout.html +5 −0
  • internal/calls/templates/room.html +144 −75
  • internal/callstest/browser_test.go +1 −0
  • internal/callstest/room_test.go +85 −4
  • test/browser/call.mjs +508 −14
  • test/browser/room-window.test.mjs +153 −0
diff --git a/Makefile b/Makefile
index 84c54ee..496f9af 100644
--- a/Makefile
+++ b/Makefile
@@ -153,6 +153,7 @@ browser:
node --test test/browser/room-screen.test.mjs
node --test test/browser/room-glow.test.mjs
node --test test/browser/room-layout.test.mjs
+ node --test test/browser/room-window.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
diff --git a/internal/calls/icons/icons.go b/internal/calls/icons/icons.go
index 11a700c..8ee3ad4 100644
--- a/internal/calls/icons/icons.go
+++ b/internal/calls/icons/icons.go
@@ -28,6 +28,18 @@ var icons = map[string]template.HTML{
"message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`,
"sliders-horizontal": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg>`,
"log-out": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>`,
+ // The call window's bar (2026-09-28), in the Mac bar's order and
+ // meaning: share a screen (and stop), Speaker or Grid, the meeting
+ // link, chat, more, and Leave — the handset laid down, as the Mac's
+ // phone.down.fill is.
+ "screen-share": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/></svg>`,
+ "screen-share-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8M12 17v4"/><path d="m22 3-5 5"/><path d="m17 3 5 5"/></svg>`,
+ "panel-bottom": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/></svg>`,
+ "layout-grid": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>`,
+ "share": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="m16 6-4-4-4 4"/><path d="M12 2v13"/></svg>`,
+ "messages-square": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>`,
+ "ellipsis": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>`,
+ "phone-down": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path transform="rotate(135 12 12)" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
"alert-triangle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`,
// class carries "lucide-bot" alongside the app's own "icon" so the
diff --git a/internal/calls/static/call-layout.js b/internal/calls/static/call-layout.js
index 948226a..30273f1 100644
--- a/internal/calls/static/call-layout.js
+++ b/internal/calls/static/call-layout.js
@@ -13,6 +13,12 @@
// in the same tab puts it back; it is never sent to anyone. The tiles are
// moved in the DOM itself, so reading order is the order on screen; a
// <video> moved within one task keeps playing.
+//
+// The call window (2026-09-28) adds three things the Mac's full-window
+// call has: Grid fits every tile without scrolling (gridFit, the Mac's
+// GridFit), the self view floats in the corner it is put in when it
+// would be all the filmstrip holds (a call of two), and chat is a
+// sidebar over the picture that starts closed, as the Mac's does.
(() => {
'use strict';
@@ -69,6 +75,64 @@
return others[0] || self || '';
}
+ // gridColumns is the Mac's GridFit.columns: the column count that
+ // makes `count` tiles of `aspect` (width over height) as large as they
+ // can be in width × height, `spacing` apart. A column count only wins
+ // by half a pixel or more, so a tie keeps the fewer columns.
+ function gridColumns(count, width, height, aspect, spacing) {
+ if (count <= 1 || width <= 0 || height <= 0) return 1;
+ let best = 1;
+ let bestWidth = 0;
+ for (let columns = 1; columns <= count; columns++) {
+ const rows = Math.ceil(count / columns);
+ const w = gridTileWidth(columns, rows, width, height, aspect, spacing);
+ if (w > bestWidth + 0.5) {
+ best = columns;
+ bestWidth = w;
+ }
+ }
+ return best;
+ }
+
+ function gridTileWidth(columns, rows, width, height, aspect, spacing) {
+ const across = (width - (columns - 1) * spacing) / columns;
+ const down = (height - (rows - 1) * spacing) / rows;
+ return Math.max(0, Math.min(across, down * aspect));
+ }
+
+ // gridFit is the whole of Grid's geometry in a stage of width ×
+ // height, as MacFullWindowCall's fullWindowGrid has it: 4px of margin
+ // and 4px between 16:9 tiles, every tile as large as fits, and no
+ // smaller than 200px wide — past that it keeps 200px tiles and
+ // scrolls. `lift` is how far a name at the bottom of the stage rises
+ // while the bar shows; the last row's names rise by as much of it as
+ // reaches over them, the grid being centred.
+ function gridFit(count, width, height, lift) {
+ const aspect = 16 / 9;
+ const w = width - 8;
+ const h = height - 8;
+ const n = Math.max(count, 1);
+ const columns = gridColumns(n, w, h, aspect, 4);
+ const fitted = gridTileWidth(columns, Math.ceil(n / columns), w, h, aspect, 4);
+ const tileW = Math.floor(fitted >= 200 ? fitted : 200);
+ const tileH = Math.floor(tileW / aspect);
+ const across = fitted >= 200 ? columns : Math.max(1, Math.floor((w + 4) / (tileW + 4)));
+ const rows = Math.ceil(n / across);
+ const gridHeight = rows * tileH + Math.max(rows - 1, 0) * 4;
+ const below = Math.max(0, (height - 8 - gridHeight) / 2) + 4;
+ return { columns: across, rows, tileW, tileH, lastRow: (rows - 1) * across, lastLift: Math.max(0, lift - below) };
+ }
+
+ // pipCorner is where a dropped self view settles: the quarter of the
+ // stage its centre was headed for when let go (PiPLayout.corner).
+ // `predicted` is where the drag's momentum would carry the centre, so
+ // a flick towards a corner reaches it without being dragged the whole
+ // way.
+ function pipCorner(center, predicted, width, height) {
+ const p = predicted || center;
+ return (p.y < height / 2 ? 'top-' : 'bottom-') + (p.x < width / 2 ? 'left' : 'right');
+ }
+
// ---- the page ----
const room = document.getElementById('room');
@@ -78,15 +142,22 @@
const gridButton = document.getElementById('layout-grid');
const chat = document.getElementById('chat');
const toggle = document.getElementById('toggle-chat');
- const workspace = document.getElementById('call-workspace');
+ const overlay = document.getElementById('chat-overlay');
+ const chatClose = document.getElementById('chat-close');
+ const layoutToggle = document.getElementById('layout-toggle');
+ const stageEl = document.getElementById('stage');
+ const win = room ? room.closest('.calls-window') : null;
const log = document.getElementById('chat-log');
const unreadLabel = document.getElementById('chat-unread');
const unreadLive = document.getElementById('chat-unread-live');
const layoutLive = document.getElementById('layout-live');
const toolsTemplate = document.getElementById('tile-tools');
- if (!room || !tiles || !controls || !chat || !toggle || !workspace) return;
+ if (!room || !tiles || !controls || !chat || !toggle || !overlay) return;
const LAYOUT_KEY = 'calls.videoLayout';
+ // Where the floating self view was last put; kept, as the Mac keeps
+ // selfViewCorner.
+ const CORNER_KEY = 'calls.selfCorner';
const ORDER_KEY = 'calls.order.' + (room.dataset.sqid || '');
const reduced = () => window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -107,7 +178,12 @@
let pinned = '';
let sharesSeen = [];
let unread = 0;
- let expanded = true;
+ // Chat starts closed, as the Mac's does: it is a sidebar over the
+ // picture, and opening it is one click (or a new message's badge).
+ let expanded = false;
+ let corner = /^(top|bottom)-(left|right)$/.test(load('localStorage', CORNER_KEY) || '')
+ ? load('localStorage', CORNER_KEY) : 'bottom-right';
+ let pipDrag = null;
let drag = null;
let suppressClick = false;
// Leaving — Leave, a reload, closing the tab — takes the tiles down
@@ -254,8 +330,10 @@
function layout() {
tiles.dataset.layout = mode;
+ if (tiles.dataset.corner !== corner) tiles.dataset.corner = corner;
speakerButton.setAttribute('aria-pressed', String(mode === 'speaker'));
gridButton.setAttribute('aria-pressed', String(mode === 'grid'));
+ if (layoutToggle) layoutToggle.dataset.mode = mode;
const all = figures();
const shares = all.filter(isShare);
const people = all.filter((f) => !isShare(f));
@@ -296,6 +374,7 @@
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;
fig.toggleAttribute('data-stage', onStage);
fig.toggleAttribute('data-pinned', onStage && pinned === fig.dataset.tile);
@@ -308,7 +387,31 @@
} else if (!onStage && note) note.remove();
labelTools(fig, pinned === fig.dataset.tile, ordered.indexOf(fig), count);
}
+ fitGrid();
+ }
+
+ // fitGrid writes gridFit's answer for the stage as it is now: the
+ // column count and tile size, which tiles are in the last row, and
+ // how far their names rise while the bar shows.
+ function fitGrid() {
+ if (!stageEl) return;
+ if (mode !== 'grid') {
+ for (const fig of figures()) fig.removeAttribute('data-last-row');
+ return;
+ }
+ const all = figures();
+ const reserve = win ? parseFloat(getComputedStyle(win).getPropertyValue('--calls-bar-reserve')) || 80 : 80;
+ const fit = gridFit(all.length, stageEl.clientWidth, stageEl.clientHeight, reserve - 8);
+ const set = (name, value) => {
+ if (tiles.style.getPropertyValue(name) !== value) tiles.style.setProperty(name, value);
+ };
+ set('--calls-cols', String(fit.columns));
+ set('--calls-tile-w', fit.tileW + 'px');
+ set('--calls-tile-h', fit.tileH + 'px');
+ set('--calls-grid-lift', fit.lastLift + 'px');
+ all.forEach((fig, i) => fig.toggleAttribute('data-last-row', i >= fit.lastRow));
}
+ if (stageEl && window.ResizeObserver) new ResizeObserver(() => fitGrid()).observe(stageEl);
// ---- pointer drag ----
@@ -326,9 +429,61 @@
fig.style.translate = (ev.clientX - drag.grabX - r.left) + 'px ' + (ev.clientY - drag.grabY - r.top) + 'px';
}
+ // The floating self view is not reordered by a drag: it is moved, and
+ // settles in the corner it was headed for (MacFullWindowCall's
+ // selfView). Four pixels of movement make it a drag; less is a click.
+ tiles.addEventListener('pointerdown', (ev) => {
+ const fig = ev.target.closest('#tiles > figure.calls-tile-pip');
+ if (!fig || ev.button !== 0 || ev.target.closest('button, a, summary, details, input, select')) return;
+ pipDrag = { fig, id: ev.pointerId, x: ev.clientX, y: ev.clientY, moving: false, last: [] };
+ }, true);
+ tiles.addEventListener('pointermove', (ev) => {
+ if (!pipDrag || ev.pointerId !== pipDrag.id) return;
+ const dx = ev.clientX - pipDrag.x;
+ const dy = ev.clientY - pipDrag.y;
+ if (!pipDrag.moving) {
+ if (Math.hypot(dx, dy) < 4) return;
+ pipDrag.moving = true;
+ pipDrag.fig.classList.add('calls-tile-dragging');
+ try { pipDrag.fig.setPointerCapture(ev.pointerId); } catch { /* released already */ }
+ }
+ pipDrag.last.push({ x: ev.clientX, y: ev.clientY, t: ev.timeStamp });
+ if (pipDrag.last.length > 5) pipDrag.last.shift();
+ pipDrag.fig.style.translate = dx + 'px ' + dy + 'px';
+ ev.stopImmediatePropagation();
+ }, true);
+ function pipDrop(ev) {
+ if (!pipDrag || ev.pointerId !== pipDrag.id) return;
+ const { fig, moving, last } = pipDrag;
+ pipDrag = null;
+ if (!moving) return;
+ ev.stopImmediatePropagation();
+ suppressClick = true;
+ const r = fig.getBoundingClientRect();
+ const s = stageEl.getBoundingClientRect();
+ const center = { x: r.left + r.width / 2 - s.left, y: r.top + r.height / 2 - s.top };
+ let predicted = null;
+ if (last.length >= 2) {
+ const a = last[0];
+ const b = last[last.length - 1];
+ const dt = Math.max(b.t - a.t, 1);
+ // Where a quarter of a second more at the release speed would
+ // have carried it.
+ predicted = { x: center.x + (b.x - a.x) / dt * 250, y: center.y + (b.y - a.y) / dt * 250 };
+ }
+ corner = pipCorner(center, predicted, s.width, s.height);
+ keep('localStorage', CORNER_KEY, corner);
+ fig.classList.remove('calls-tile-dragging');
+ fig.style.translate = '';
+ layout();
+ }
+ tiles.addEventListener('pointerup', pipDrop, true);
+ tiles.addEventListener('pointercancel', pipDrop, true);
+
tiles.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 || ev.pointerType === 'touch') return;
const fig = ev.target.closest('#tiles > figure');
+ if (fig && fig.classList.contains('calls-tile-pip')) return;
if (!fig || isShare(fig) || ev.target.closest('button, a, summary, details, input, select')) return;
const r = fig.getBoundingClientRect();
drag = { fig, id: ev.pointerId, x: ev.clientX, y: ev.clientY, grabX: ev.clientX - r.left, grabY: ev.clientY - r.top, moving: false };
@@ -380,6 +535,7 @@
if (mode !== 'speaker') return;
const fig = ev.target.closest('#tiles > figure');
if (!fig || ev.target.closest('button, a, summary, details, input, select')) return;
+ if (fig.classList.contains('calls-tile-pip')) return;
if (fig.hasAttribute('data-stage') && !fig.hasAttribute('data-pinned')) return;
togglePin(fig);
});
@@ -388,11 +544,13 @@
// Mount readiness belongs to room.js; hiding the visual sidebar never
// changes its hidden attribute, draft, admission or send state.
toggle.hidden = chat.hidden;
- workspace.setAttribute('estilo-call-workspace', expanded && !chat.hidden ? 'sidebar' : '');
+ const open = expanded && !chat.hidden;
+ if (overlay.hidden === open) overlay.hidden = !open;
chat.classList.toggle('calls-chat-collapsed', !expanded);
- toggle.setAttribute('aria-expanded', String(expanded));
+ toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', unread ? `Chat (${unread} new)` : 'Chat');
- unreadLabel.textContent = unread ? ` (${unread})` : '';
+ toggle.dataset.tip = open ? 'Hide chat' : unread ? `Chat (${unread} new)` : 'Show chat';
+ unreadLabel.textContent = unread ? String(unread) : '';
if (unreadLive) {
// Only while collapsed, and only the count. Assigning the same
// string twice would re-announce it, so the guard is on the value
@@ -406,10 +564,20 @@
speakerButton.addEventListener('click', () => { setMode('speaker'); layout(); });
gridButton.addEventListener('click', () => { setMode('grid'); layout(); });
toggle.addEventListener('click', () => {
+ const inside = overlay.contains(document.activeElement);
expanded = !expanded;
if (expanded) unread = 0;
chatState();
- if (expanded) document.getElementById('chat-text')?.focus();
+ if (expanded) document.getElementById('chat-text')?.focus({ preventScroll: true });
+ else if (inside) toggle.focus();
+ });
+ // The sidebar's own close, and Escape inside it: the same as the bar's
+ // button, and the keyboard goes back to that button.
+ chatClose?.addEventListener('click', () => toggle.click());
+ overlay.addEventListener('keydown', (ev) => {
+ if (ev.key !== 'Escape' || !expanded) return;
+ ev.preventDefault();
+ toggle.click();
});
// Our own moves are child-list changes too; layout() finds the order
// already right and moves nothing, so this settles at once.
@@ -441,7 +609,8 @@
// For the gate and for a look from the console: what this tab has
// arranged. Presentation only; nothing here is sent anywhere.
window.callsLayout = {
- state: () => ({ mode, pinned, order: orderShown(order), keys: order.keys.slice(), speaker: tiles.dataset.speaker || '' }),
+ state: () => ({ mode, pinned, order: orderShown(order), keys: order.keys.slice(), speaker: tiles.dataset.speaker || '',
+ corner, chat: expanded && !chat.hidden }),
reduced,
};
layout();
diff --git a/internal/calls/static/call-window.js b/internal/calls/static/call-window.js
new file mode 100644
index 0000000..4748042
--- /dev/null
+++ b/internal/calls/static/call-window.js
@@ -0,0 +1,384 @@
+// call-window.js: the call window's controls (2026-09-28) — when they
+// show, the popovers over the picture, and a phone's controls sheet.
+//
+// Presentation only, like call-layout.js: room.js owns every stream and
+// every control's meaning, and nothing here is sent anywhere.
+//
+// When the controls show is the Mac's ControlsVisibility (CallChrome.swift),
+// one small state machine with the time passed in, so
+// test/browser/room-window.test.mjs can drive it without a browser and
+// hold its constants equal to the Swift ones. The pointer moving shows
+// them, and 3 s without it hides them; leaving the window hides them
+// 1.5 s later (never later than they were due anyway). A tap on the
+// picture toggles them. They stay while something holds them: the
+// pointer over the bar or the title, a popover, the chat, the title's
+// menu or the phone's sheet open, keyboard focus in them, the call over
+// (its way out is in the notices, and the bar with it), and — because a
+// page cannot tell whether a screen reader is running — always, for
+// anyone who has asked for reduced motion. Tab brings them back. Space
+// works with them hidden. Hidden, they are only transparent: a screen
+// reader still has them, and a press on one that is not showing (a
+// finger cannot hover first) shows them and does nothing else.
+(() => {
+ 'use strict';
+
+ // ---- the rule, lifted by name in test/browser/room-window.test.mjs ----
+
+ // A pointer keeps the controls up for 3 s after it last moved
+ // (ControlsVisibility.pointerDelay); a touch screen 3 s after the last
+ // tap (touchDelay); leaving the window hides them 1.5 s later
+ // (pointerExitDelay). The fades are calls.css's: 0.35 s going, 0.2 s
+ // coming back.
+ const CONTROLS_POINTER_DELAY = 3;
+ const CONTROLS_TOUCH_DELAY = 3;
+ const CONTROLS_EXIT_DELAY = 1.5;
+
+ function controlsDelay(input) {
+ return input === 'touch' ? CONTROLS_TOUCH_DELAY : CONTROLS_POINTER_DELAY;
+ }
+
+ // controlsStart: the controls start visible, so a call opens with them
+ // in view, and fade on the usual schedule.
+ function controlsStart(input, now) {
+ return { input, visible: true, holds: {}, hideAt: now + controlsDelay(input), pointerInside: false };
+ }
+
+ function controlsHeld(s) {
+ for (const k in s.holds) if (s.holds[k]) return true;
+ return false;
+ }
+
+ // controlsStep is one input to the machine, and the next state:
+ // move the pointer moved over the window: show, start the wait
+ // exit it left the window: hide EXIT_DELAY later, or when due
+ // tap a tap on the picture: toggle, unless held
+ // activity a key such as Tab, a click: show, start the wait
+ // hold {hold, on}: something that keeps them up started/stopped
+ // tick the clock: hide once hideAt has passed
+ function controlsStep(s, ev, now) {
+ const n = { input: s.input, visible: s.visible, holds: Object.assign({}, s.holds), hideAt: s.hideAt, pointerInside: s.pointerInside };
+ const reveal = () => {
+ n.visible = true;
+ n.hideAt = controlsHeld(n) ? null : now + controlsDelay(n.input);
+ };
+ const hide = () => {
+ n.visible = false;
+ n.hideAt = null;
+ };
+ switch (ev.type) {
+ case 'move':
+ n.pointerInside = true;
+ reveal();
+ break;
+ case 'exit': {
+ n.pointerInside = false;
+ delete n.holds.pointerOverControls;
+ if (!n.visible || controlsHeld(n)) break;
+ const linger = now + CONTROLS_EXIT_DELAY;
+ n.hideAt = Math.min(n.hideAt == null ? linger : n.hideAt, linger);
+ break;
+ }
+ case 'tap':
+ if (n.visible && !controlsHeld(n)) hide();
+ else reveal();
+ break;
+ case 'activity':
+ reveal();
+ break;
+ case 'hold':
+ if (ev.on) {
+ n.holds[ev.hold] = true;
+ n.visible = true;
+ n.hideAt = null;
+ } else {
+ if (!n.holds[ev.hold]) return n;
+ delete n.holds[ev.hold];
+ if (!controlsHeld(n) && n.visible) n.hideAt = now + controlsDelay(n.input);
+ }
+ break;
+ case 'tick':
+ if (!controlsHeld(n) && n.hideAt != null && now >= n.hideAt) hide();
+ break;
+ default:
+ break;
+ }
+ return n;
+ }
+
+ // ---- the page ----
+
+ const $ = (id) => document.getElementById(id);
+ const room = $('room');
+ const win = room ? room.closest('.calls-window') : null;
+ const bar = $('controls');
+ const top = $('call-top');
+ if (!win || !room || !bar || !top) return;
+ const nav = $('call-nav');
+ const sheet = $('call-sheet');
+ const moreControls = $('more-controls');
+ const leave = $('leave');
+ const chatOverlay = $('chat-overlay');
+ const chatToggle = $('toggle-chat');
+ const phoneQuery = matchMedia('(max-width: 599px), (max-height: 499px) and (pointer: coarse)');
+ const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
+ const phone = () => phoneQuery.matches;
+ const now = () => performance.now() / 1000;
+
+ // The bar's popovers. `own` is whether this file opens and closes it;
+ // the device panel is room.js's (it fills the selects on opening), so
+ // it is opened and closed through its own button.
+ const popovers = [
+ { toggle: $('layout-toggle'), panel: $('layout-panel'), own: true },
+ { toggle: $('devices-toggle'), panel: $('room-devices'), own: false },
+ { toggle: $('call-more-toggle'), panel: $('call-more'), own: true },
+ ].filter((p) => p.toggle && p.panel);
+
+ let s = controlsStart('pointer', now());
+ // A fixture's pin (window.callsChrome.pin), as the Mac's
+ // ControlsVisibilityModel has: shown or hidden whatever happens.
+ let pinned = null;
+ let timer = 0;
+
+ function visible() {
+ return pinned === null ? s.visible : pinned;
+ }
+
+ function render() {
+ const want = visible() ? 'shown' : 'hidden';
+ if (win.dataset.chrome !== want) win.dataset.chrome = want;
+ win.toggleAttribute('data-pointer-inside', !visible() && s.pointerInside && pinned === null);
+ }
+
+ function schedule() {
+ clearTimeout(timer);
+ if (s.hideAt == null) return;
+ timer = setTimeout(() => step({ type: 'tick' }), Math.max(0, (s.hideAt - now()) * 1000) + 20);
+ }
+
+ function step(ev) {
+ s = controlsStep(s, ev, now());
+ render();
+ schedule();
+ }
+
+ function hold(name, on) {
+ if (!!s.holds[name] === !!on) return;
+ step({ type: 'hold', hold: name, on: !!on });
+ }
+
+ // Over the bar or the title, the pointer holds them (the Mac's bar and
+ // traffic lights).
+ const overChrome = (el) => !!el && (bar.contains(el) || top.contains(el));
+ const inPopover = (el) => !!el && popovers.some((p) => p.panel.contains(el));
+
+ win.addEventListener('pointermove', (ev) => {
+ if (ev.pointerType === 'touch') return;
+ step({ type: 'move' });
+ hold('pointerOverControls', overChrome(ev.target) || inPopover(ev.target));
+ });
+ win.addEventListener('pointerleave', (ev) => {
+ if (ev.pointerType === 'touch') return;
+ step({ type: 'exit' });
+ });
+
+ // A finger has no hover: a tap on the picture toggles the controls,
+ // and a tap on a control that is not showing shows the controls and
+ // 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';
+ let swallow = false;
+ win.addEventListener('pointerdown', (ev) => {
+ const target = ev.target instanceof Element ? ev.target : null;
+ if (!target) return;
+ if (ev.pointerType !== 'touch') {
+ step({ type: 'activity' });
+ return;
+ }
+ if (!visible() && target.closest('.calls-fades')) {
+ swallow = true;
+ step({ type: 'activity' });
+ return;
+ }
+ step({ type: target.closest(INTERACTIVE) ? 'activity' : 'tap' });
+ }, true);
+ win.addEventListener('click', (ev) => {
+ if (!swallow) return;
+ swallow = false;
+ ev.preventDefault();
+ ev.stopPropagation();
+ }, true);
+
+ // The keyboard: Tab is navigation starting, so it brings them back;
+ // focus that the keyboard put in the bar, the title, a popover or a
+ // tile's arrangement buttons holds them. A mouse click focuses a button too, but not :focus-visible,
+ // and a clicked button should not keep the bar up for ever.
+ function focusCheck() {
+ const a = document.activeElement;
+ let keyboard = false;
+ if (a && a !== document.body && (overChrome(a) || inPopover(a) || a.closest('.calls-tile-tools'))) {
+ try { keyboard = a.matches(':focus-visible'); } catch { keyboard = true; }
+ }
+ hold('keyboardFocus', keyboard);
+ }
+ document.addEventListener('focusin', focusCheck);
+ document.addEventListener('focusout', () => setTimeout(focusCheck, 0));
+
+ // ---- popovers, the title's menu and the phone's sheet ----
+
+ function placePopover(p) {
+ if (p.panel.hidden) return;
+ if (phone()) {
+ p.panel.style.removeProperty('--calls-pop-x');
+ return;
+ }
+ const w = win.getBoundingClientRect();
+ const b = p.toggle.getBoundingClientRect();
+ const width = p.panel.offsetWidth;
+ const x = Math.min(Math.max(b.left + b.width / 2 - w.left - width / 2, 12), w.width - width - 12);
+ p.panel.style.setProperty('--calls-pop-x', Math.round(Math.max(12, x)) + 'px');
+ }
+
+ function setPopover(p, open) {
+ if (p.panel.hidden === !open) return;
+ if (p.own) {
+ p.panel.hidden = !open;
+ p.toggle.setAttribute('aria-expanded', String(open));
+ } else if (!p.toggle.disabled) {
+ p.toggle.click();
+ } else {
+ p.panel.hidden = !open;
+ p.toggle.setAttribute('aria-expanded', String(open));
+ }
+ }
+
+ function setSheet(open) {
+ if (!sheet || !moreControls) return;
+ if (sheet.hasAttribute('data-open') === open) return;
+ sheet.toggleAttribute('data-open', open);
+ moreControls.setAttribute('aria-expanded', String(open));
+ }
+
+ for (const p of popovers) {
+ if (p.own) {
+ p.toggle.addEventListener('click', () => {
+ const open = p.panel.hidden;
+ setPopover(p, open);
+ if (open) {
+ const first = p.panel.querySelector('[aria-pressed="true"], a, button, select, input');
+ if (first) first.focus();
+ }
+ });
+ }
+ // Whoever opened it, one popover at a time, placed over its button,
+ // and on a phone in place of the sheet.
+ new MutationObserver(() => {
+ if (!p.panel.hidden) {
+ for (const other of popovers) if (other !== p) setPopover(other, false);
+ if (phone()) setSheet(false);
+ placePopover(p);
+ }
+ menusCheck();
+ }).observe(p.panel, { attributes: true, attributeFilter: ['hidden'] });
+ }
+
+ if (moreControls && sheet) {
+ moreControls.addEventListener('click', () => {
+ const open = !sheet.hasAttribute('data-open');
+ if (open) for (const p of popovers) setPopover(p, false);
+ setSheet(open);
+ });
+ }
+ // Chat from the sheet: the sheet goes, and the chat is what is left.
+ chatToggle?.addEventListener('click', () => { if (phone()) setSheet(false); });
+ phoneQuery.addEventListener?.('change', () => {
+ setSheet(false);
+ for (const p of popovers) placePopover(p);
+ });
+ window.addEventListener('resize', () => { for (const p of popovers) placePopover(p); });
+
+ // A press anywhere else closes what is open: a popover, the title's
+ // menu, the sheet. Chat is a panel, not a popover, and stays.
+ document.addEventListener('pointerdown', (ev) => {
+ const t = ev.target instanceof Node ? ev.target : null;
+ if (!t) return;
+ for (const p of popovers) {
+ if (!p.panel.hidden && !p.panel.contains(t) && !p.toggle.contains(t)) setPopover(p, false);
+ }
+ if (nav && nav.open && !nav.contains(t)) nav.open = false;
+ if (sheet && sheet.hasAttribute('data-open') && !sheet.contains(t) && !(moreControls && moreControls.contains(t)) &&
+ !popovers.some((p) => p.panel.contains(t))) setSheet(false);
+ }, true);
+
+ // Escape closes the innermost open thing and puts the keyboard back on
+ // what opened it.
+ document.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Tab') step({ type: 'activity' });
+ if (ev.key !== 'Escape' || ev.defaultPrevented) return;
+ const open = popovers.find((p) => !p.panel.hidden);
+ if (open) {
+ ev.preventDefault();
+ setPopover(open, false);
+ open.toggle.focus();
+ return;
+ }
+ if (nav && nav.open) {
+ ev.preventDefault();
+ nav.open = false;
+ nav.querySelector('summary')?.focus();
+ return;
+ }
+ if (sheet && sheet.hasAttribute('data-open')) {
+ ev.preventDefault();
+ setSheet(false);
+ moreControls?.focus();
+ }
+ });
+
+ // What holds the controls up besides the pointer and the keyboard.
+ function menusCheck() {
+ const open = popovers.some((p) => !p.panel.hidden) || (chatOverlay && !chatOverlay.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(nav, ['open']);
+ watch(sheet, ['data-open']);
+ // The call is over: Leave is disabled and the way out is in the notices.
+ if (leave) {
+ const alertCheck = () => hold('alert', leave.disabled);
+ new MutationObserver(alertCheck).observe(leave, { attributes: true, attributeFilter: ['disabled'] });
+ alertCheck();
+ }
+ const motionCheck = () => hold('reducedMotion', motionQuery.matches);
+ motionQuery.addEventListener?.('change', motionCheck);
+ motionCheck();
+ menusCheck();
+
+ // 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');
+ if (window.ResizeObserver) new ResizeObserver(titleHeight).observe(top);
+ titleHeight();
+
+ render();
+ schedule();
+
+ // For the gate and for a look from the console, and for snapshots:
+ // pin(true|false) holds the controls shown or hidden, pin(null) lets
+ // them go again.
+ window.callsChrome = {
+ state: () => ({
+ visible: visible(),
+ holds: Object.keys(s.holds).filter((k) => s.holds[k]),
+ hideIn: s.hideAt == null ? null : Math.max(0, s.hideAt - now()),
+ pointerInside: s.pointerInside,
+ pinned,
+ }),
+ pin(v) {
+ pinned = v === true || v === false ? v : null;
+ render();
+ },
+ };
+})();
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index f487799..5a54523 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -1,97 +1,21 @@
/* calls.css: the room and the holding screen's scripted parts. Small on
purpose (spec §9's three hand-written scripts get their own styling
- as they grow); everything here reads from rst-* tokens so it follows
- the theme rather than fixing its own colours. */
+ as they grow); the holding screen reads from rst-* tokens so it
+ follows the theme rather than fixing its own colours. The room is the
+ exception, and says why below: it is a window of video, and what is
+ over video is one dark system whatever the theme (0.3.5's "Over the
+ picture" on the Mac). */
-/* The hidden attribute is how both scripts show and hide whole regions,
+/* The hidden attribute is how the scripts show and hide whole regions,
and the UA's display: none for it loses to any author rule that sets
- display on the same element. Exactly one element in these two pages is
- both hidden and given a display of its own — the holding screen's
- pickers — so exactly one rule restores it, rather than a blanket
- [hidden] override with !important, which is the framework gallery's
- own precedent. Anything else authoring display on an element it also
- hides needs its own line here, and should say so. */
+ display on the same element. Rather than a blanket [hidden] override
+ with !important, which is the framework gallery's own precedent, each
+ element that is both hidden and given a display of its own restores
+ it with a line of its own, and says so. */
.calls-pickers[hidden] {
display: none;
}
-.calls-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
- gap: var(--rst-sp-3);
- margin-block: var(--rst-sp-4);
-}
-
-.calls-tile {
- position: relative;
- aspect-ratio: 4 / 3;
- background: var(--rst-surface-2);
- border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius);
- overflow: hidden;
-}
-
-.calls-tile video {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-/* The tile caption: the name, and, when the pair has failed or the
- browser refused to play the track, the sentence and the control
- beside it — plus the removal link a member gets. One flex row, so
- none of that stacks oddly on a narrow tile. */
-.calls-tile-name {
- position: absolute;
- inset-block-end: var(--rst-sp-2);
- inset-inline-start: var(--rst-sp-2);
- padding: var(--rst-sp-1) var(--rst-sp-2);
- background: var(--rst-surface);
- border-radius: var(--rst-radius-sm);
- color: var(--rst-text);
- font-size: var(--rst-fs-sm);
- display: flex;
- align-items: center;
- gap: var(--rst-sp-2);
- max-inline-size: calc(100% - 2 * var(--rst-sp-2));
-}
-
-.calls-controls {
- display: flex;
- flex-wrap: wrap;
- gap: var(--rst-sp-2);
- margin-block-end: var(--rst-sp-4);
-}
-
-.calls-queue {
- border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius);
- padding: var(--rst-sp-3);
- margin-block-end: var(--rst-sp-4);
-}
-
-.calls-queue h2 {
- margin: 0 0 var(--rst-sp-2);
- font-size: var(--rst-fs-base);
- color: var(--rst-text-muted);
-}
-
-.calls-queue ul {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: var(--rst-sp-2);
-}
-
-.calls-queue li {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--rst-sp-2);
-}
-
.calls-preview {
width: 100%;
max-width: 24rem;
@@ -143,122 +67,25 @@
max-inline-size: 42rem;
}
-.calls-tile-name > span:first-child {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.calls-tile-name button,
-.calls-tile-name a {
- font: inherit;
- color: var(--rst-text);
- background: none;
- border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius-sm);
- padding: 0 var(--rst-sp-1);
- cursor: pointer;
-}
-
-/* Spec §15.19: a muted peer's badge sits beside their name — the
- slashed microphone and the vendored badge's word, so the state is a
- shape and a word and never a colour. It is a flex item of the caption
- row, so it never covers the name. */
-.calls-tile-muted {
- display: inline-flex;
- align-items: center;
- gap: var(--rst-sp-1);
- flex: none;
-}
-
-.calls-tile-muted .icon {
- inline-size: 1em;
- block-size: 1em;
-}
-
-.calls-tile-muted[hidden],
-.calls-tile-off[hidden] {
- display: none;
-}
-
-/* A camera that is off sends black frames, or holds the last one. The
- cover says what that is instead of leaving a frozen face or a black
- rectangle to be read as a fault. It lies over the video rather than
- hiding it: the element is also what plays the peer's audio. */
-.calls-tile-off {
- position: absolute;
- inset: 0;
+/* The holding screen's toggles keep one accessible name (aria-label)
+ and move only aria-pressed, so the state is carried three ways and
+ never by colour: the word in .calls-toggle-state, the attribute a
+ screen reader reads, and a ring around the control from estilo's
+ callui.css ([estilo-call-actions] [aria-pressed='true']). */
+.calls-controls {
display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
+ flex-wrap: wrap;
gap: var(--rst-sp-2);
- background: var(--rst-surface-2);
- color: var(--rst-text-muted);
- font-size: var(--rst-fs-sm);
-}
-
-.calls-tile-off .icon {
- inline-size: 2rem;
- block-size: 2rem;
-}
-
-/* The caption stays above the cover. */
-.calls-tile-camera-off .calls-tile-name {
- z-index: 1;
-}
-
-/* Never colour alone: a failed connection says so in words, and the
- weight is only a second cue on top of the sentence. */
-.calls-tile-note {
- color: var(--rst-text);
- font-weight: 600;
-}
-
-.calls-preview,
-.calls-tile-self:not(.calls-tile-share) video {
- /* Mirror only our camera display, never remote video or shared screens. */
- transform: scaleX(-1);
-}
-
-/* A shared screen (spec §5.4) is not a face: the part of it that matters
- is usually at the edges, so the whole screen is fitted into the tile
- rather than cropped to fill it. The letterboxing this leaves is the
- tile's own surface, so a wide screen in a 4:3 tile still reads as one
- object and not as a video with holes in it. */
-.calls-tile-share video {
- object-fit: contain;
- background: var(--rst-surface-2);
+ margin: 0;
}
-
-/* The toggles keep one accessible name (aria-label) and move only
- aria-pressed, so the state is carried three ways and never by colour:
- the word in .calls-toggle-state, the attribute a screen reader reads,
- and a ring around the control.
- The ring is not drawn here. It comes from estilo's callui.css, as
- [estilo-call-actions] [aria-pressed='true'], which is why this rule
- keeps only the weight. That is a dependency on another repository and
- on the attribute staying on the element: a control that loses
- estilo-call-actions loses the ring, and then the weight below is the
- only thing left that is not the word or the attribute. */
.calls-controls button[aria-pressed='true'] {
font-weight: 700;
}
-
-.calls-toggle-state {
- font-variant: all-small-caps;
- letter-spacing: 0.04em;
- opacity: 0.85;
-}
-
+.calls-toggle-state { font-size: 0.8em; }
.calls-toggle-state::before {
content: ' ';
}
-.calls-queue li button {
- flex: none;
-}
-
.calls-status a {
margin-inline-start: var(--rst-sp-1);
}
@@ -286,34 +113,6 @@
margin-block: var(--rst-sp-2);
}
-/* Match the native stage geometry while retaining every live video element. */
-.calls-grid {
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
- margin: 0;
-}
-.calls-grid[data-layout='speaker'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
-.calls-grid[data-layout='speaker'] [data-stage] { grid-column: 1 / -1; grid-row: 1; order: -1; }
-.calls-grid[data-layout='speaker'] .calls-tile:not([data-stage]) { aspect-ratio: 16 / 10; }
-.calls-tile {
- margin: 0;
- min-inline-size: 0;
- aspect-ratio: 16 / 9;
- border-radius: 16px;
- background: #000;
-}
-.calls-tile-name { flex-wrap: wrap; border-radius: 14px; }
-.calls-tile-name button, .calls-tile-name a {
- min-block-size: 24px;
- min-inline-size: 24px;
-}
-.calls-controls { margin: 0; }
-.calls-toggle-state { font-variant: normal; letter-spacing: normal; opacity: 1; font-size: 0.8em; }
-
-.calls-controls #toggle-chat[hidden] { display: none; }
-.calls-stage { min-inline-size: 0; }
-#call-workspace .chat { margin: 0; border-radius: 16px; }
-#call-workspace .chat-log { min-block-size: 10rem; max-block-size: 24rem; }
-#call-workspace .calls-chat-collapsed { display: none; }
.calls-device-layout { display: grid; gap: var(--rst-sp-4); align-items: start; }
/* devices.js hides #preview when the browser cannot reach a camera at
all, and the wrap around it is a black panel with a border radius and
@@ -326,6 +125,7 @@
}
.calls-preview-wrap { border-radius: 20px; overflow: hidden; background: #000; }
.calls-preview { display: block; max-width: none; aspect-ratio: 16 / 9; border: 0; border-radius: 0; }
+.calls-preview[hidden] { display: none; }
.calls-pickers { margin-block-start: 0; }
.calls-pickers > div { flex: 1 1 12rem; min-inline-size: 0; }
.calls-pickers select { max-inline-size: 100%; }
@@ -335,133 +135,12 @@
sentence sets in a ribbon. More specific than the line above rather
than ordered after it, so it survives the two being reordered. */
.calls-pickers > .calls-raw-row { flex: 1 1 100%; }
-/* The room's device panel takes a row of its own under the controls
- that opened it. Spanning both columns keeps it out of the sidebar,
- where it would argue with chat over a 20rem column; grid-row 3 puts
- it under the controls (row 2) rather than letting auto-placement
- drop it beside the stage. */
-#call-workspace #room-devices { grid-column: 1 / -1; }
-@media (min-width: 64rem) {
- #call-workspace #room-devices { grid-row: 3; }
-}
-.calls-queue li { flex-wrap: wrap; overflow-wrap: anywhere; }
@media (min-width: 64rem) {
.calls-device-layout { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); }
.calls-device-layout:has(> .calls-preview-wrap > #preview[hidden]) {
grid-template-columns: minmax(0, 1fr);
}
}
-@media (max-width: 35rem) {
- .calls-grid[data-layout='speaker'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .calls-controls #leave { margin-inline-start: 0; }
-}
-.calls-grid[data-layout='speaker'][data-pip] .calls-tile-self {
- grid-row: 1;
- grid-column: 3;
- align-self: end;
- margin: 12px;
- box-shadow: 0 3px 16px #0006;
- z-index: 1;
-}
-.calls-controls form { margin: 0; }
-.calls-controls #toggle-chat { margin-inline-start: auto; }
-.calls-controls #leave { margin-inline-start: 0; }
-@media (max-width: 35rem) {
- .calls-grid[data-layout='speaker'][data-pip] .calls-tile-self {
- grid-column: 2;
- max-inline-size: 60%;
- justify-self: end;
- margin: 6px;
- }
- .calls-controls #toggle-chat { margin-inline-start: 0; }
-}
-
-.calls-preview[hidden] { display: none; }
-#call-workspace .calls-controls { grid-column: 1 / -1; }
-.calls-grid[data-layout='speaker'][data-pip] [data-stage] .calls-tile-name { max-inline-size: 60%; }
-@media (min-width: 64rem) {
- #call-workspace .calls-controls { grid-row: 2; }
- #call-workspace[estilo-call-workspace='sidebar'] .chat { grid-column: 2; grid-row: 1; }
-}
-@media (max-width: 63.99rem) {
- #call-workspace .chat-log { min-block-size: 4rem; max-block-size: 14rem; }
-}
-/* The room's control bar, and only the room's. The lobby uses
- .calls-controls too, and under these rules its "Join the call" — a
- primary lg button, the one thing on that screen a person is meant to
- press — was squeezed into a third of the width at --rst-fs-xs, the
- same weight as the two toggles beside it. #controls exists only in the
- room, so it is the narrowest handle that says "the bar under a call". */
-@media (max-width: 35rem) {
- #controls.calls-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
- #controls.calls-controls button {
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 4px;
- inline-size: 100%;
- min-block-size: 76px;
- padding: 8px 4px;
- font-size: var(--rst-fs-xs);
- white-space: normal;
- }
- #controls.calls-controls form { display: flex; }
- #controls.calls-controls .calls-toggle-state { font-size: inherit; }
-}
-
-/* What a tile says about its audio when the glow cannot: no sound,
- sound blocked, a microphone picking nothing up. Words, with the
- weight as a second cue, in the caption's own pill. The ⓘ is a native
- disclosure, so it is a keyboard control with no script of its own,
- and what it opens takes a line of the caption to itself. */
-.calls-tile-hear {
- display: inline-flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--rst-sp-1);
- font-weight: 600;
-}
-.calls-tile-hear[hidden],
-.calls-tile-notice[hidden] { display: none; }
-.calls-info summary {
- cursor: pointer;
- list-style: none;
- min-inline-size: 24px;
- min-block-size: 24px;
- display: inline-grid;
- place-items: center;
- font-weight: 400;
-}
-.calls-info summary::-webkit-details-marker { display: none; }
-.calls-info[open] { flex-basis: 100%; }
-.calls-info p {
- margin: var(--rst-sp-1) 0 0;
- font-weight: 400;
- max-inline-size: 30rem;
-}
-/* A peer's own report about hearing us: their browser blocking sound,
- or no sound reaching them from us. A line of its own in the caption,
- because it is about the call and not about the name beside it. */
-.calls-tile-notice {
- flex-basis: 100%;
- font-weight: 600;
-}
-
-/* The page's own "Click anywhere to turn on sound", above the call. */
-.calls-sound-banner {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--rst-sp-3);
- padding: var(--rst-sp-3);
- margin-block-end: var(--rst-sp-3);
- border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius);
- background: var(--rst-surface-2);
- color: var(--rst-text);
-}
-.calls-sound-banner[hidden] { display: none; }
-.calls-sound-banner p { margin: 0; font-weight: 600; }
/* The holding screen's audio test (spec §15.20): a disclosure under the
device choices, one row per test — the button, its meter, and the
@@ -497,31 +176,670 @@
font-weight: 600;
}
-/* 0.3.7: one glass language over the picture, as the native apps have
- it, and no hairline edges anywhere. A surface is black at 62 % over a
- blurred, saturated backdrop; the black does the work, so white on it
- is 6.2:1 even over a white wall and where backdrop-filter is missing,
- and there a near-opaque black stands in for the blur. */
-.calls-tile-name,
-#controls.calls-controls {
- background: rgb(0 0 0 / 0.62);
+/* ─── The call window (2026-09-28) ────────────────────────────────────
+
+ The whole call is one rounded window, inset from the viewport by a
+ modest margin — 20px on a desktop, 12px in a narrow window, none on a
+ phone, where it goes edge to edge as the iPhone app does — on the
+ page's own quiet background, and the page never scrolls. Inside it
+ is the Mac's full-window call: the picture fills the window, and the
+ title, the notices, the bar, the panels and the popovers are over it.
+
+ Everything over the picture is the one glass of 0.3.7: black at 62 %
+ over a blurred, saturated backdrop, and no hairline edges. The black
+ does the work, so white on it is 6.2:1 even over a white wall and
+ where backdrop-filter is missing — there a near-opaque black stands in
+ for the blur (the @supports below). The window sets color-scheme:
+ dark, so every rst-* token inside it (the chat panel, the device
+ selects) takes its dark half: what is under it is always video. */
+html:has(> body > .calls-window),
+body:has(> .calls-window) {
+ overflow: hidden;
+ block-size: 100%;
+ /* tokens.css reserves a scrollbar's width on every page; a page that
+ cannot scroll has none, and the window would sit 15px off centre. */
+ scrollbar-gutter: auto;
+}
+body:has(> .calls-window) { background: var(--rst-bg); }
+.calls-window {
+ --calls-margin: 20px;
+ --calls-radius: 18px;
+ /* The bar with its margin under it: what the self view, the
+ filmstrip and a name at the bottom rise by while it shows (44 + 20
+ + 16, the Mac's barReserve). */
+ --calls-bar-reserve: 80px;
+ --calls-lift: 0px;
+ --calls-title-h: 52px;
+ --calls-glass: rgb(0 0 0 / 0.62);
+ --calls-panel: #1f1f1f;
+ --calls-fade-out: 0.35s;
+ --calls-fade-in: 0.2s;
+ position: fixed;
+ inset: var(--calls-margin);
+ margin: 0;
+ /* clip, not hidden: a hidden box can still be scrolled, and focusing
+ something in a panel that is sliding in from off the edge would
+ scroll the whole call sideways. */
+ overflow: clip;
+ border-radius: var(--calls-radius);
+ background: #0b0b0c;
+ color: #fff;
+ color-scheme: dark;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.12), 0 16px 48px rgb(0 0 0 / 0.24);
+ isolation: isolate;
+ font-size: var(--rst-fs-base, 1rem);
+}
+@media (max-width: 900px) {
+ .calls-window { --calls-margin: 12px; --calls-radius: 16px; }
+}
+/* A phone, upright or on its side: edge to edge. */
+@media (max-width: 599px), (max-height: 499px) and (pointer: coarse) {
+ .calls-window { --calls-margin: 0px; --calls-radius: 0px; box-shadow: none; }
+}
+.calls-window[data-chrome='shown'] { --calls-lift: calc(var(--calls-bar-reserve) - 8px); }
+
+.calls-glass,
+.calls-window .calls-tile-name,
+.calls-window #controls,
+.calls-window .calls-notice,
+.calls-window .calls-status:not(:empty),
+.calls-window .calls-ice-warning,
+.calls-window .calls-share-notice-row,
+.calls-window .calls-sound-banner,
+.calls-window .calls-queue {
+ background: var(--calls-glass);
color: #fff;
-webkit-backdrop-filter: blur(18px) saturate(1.6);
backdrop-filter: blur(18px) saturate(1.6);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
- .calls-tile-name,
- #controls.calls-controls { background: rgb(18 18 18 / 0.92); }
+ .calls-glass,
+ .calls-window .calls-tile-name,
+ .calls-window #controls,
+ .calls-window .calls-notice,
+ .calls-window .calls-status:not(:empty),
+ .calls-window .calls-ice-warning,
+ .calls-window .calls-share-notice-row,
+ .calls-window .calls-sound-banner,
+ .calls-window .calls-queue { background: rgb(18 18 18 / 0.92); }
+}
+/* The gate's fallback check turns the blur off to measure the black
+ alone, which is what a browser without backdrop-filter draws under
+ the @supports above. */
+.calls-window[data-no-glass] * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
+
+.calls-window :focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+.calls-window a { color: #fff; }
+
+/* What comes and goes with the controls: the title and the bar, and
+ the quiet status line. Opacity only, never a slide, eased out: 0.35s
+ going, 0.2s coming back (ControlsVisibility's fades). They keep their
+ place and their pointer while faded: a press on one that is not
+ showing shows it and does nothing else (call-window.js), and a screen
+ reader always has them. With reduced motion they never go at all. */
+.calls-fades {
+ transition: opacity var(--calls-fade-in) ease-out;
+}
+.calls-window[data-chrome='hidden'] .calls-fades {
+ opacity: 0;
+ transition-duration: var(--calls-fade-out);
}
-.calls-tile-name { border-radius: 999px; }
-.calls-tile-name:has(.calls-tile-hear:not([hidden]), .calls-tile-notice:not([hidden])) {
+.calls-window[data-chrome='hidden'][data-pointer-inside],
+.calls-window[data-chrome='hidden'][data-pointer-inside] * { cursor: none !important; }
+
+/* ── The title, with the page's own navigation in its menu ── */
+.calls-top {
+ position: absolute;
+ z-index: 4;
+ inset-block-start: 12px;
+ inset-inline-start: 12px;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ max-inline-size: min(420px, calc(100% - 24px));
+ padding: 6px 14px 7px 6px;
border-radius: 14px;
}
-.calls-tile-name button,
-.calls-tile-name a {
+.calls-top-text { min-inline-size: 0; padding-block-start: 2px; }
+.calls-top h1 {
+ margin: 0;
+ font-size: 0.9375rem;
+ font-weight: 650;
+ line-height: 1.3;
+ letter-spacing: 0;
+ color: #fff;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.calls-top-line {
+ margin: 1px 0 0;
+ font-size: 0.8125rem;
+ line-height: 1.35;
+ color: rgb(255 255 255 / 0.85);
+}
+.calls-top .calls-floor {
+ background: none;
+ padding: 0;
+ border-radius: 0;
+ color: rgb(255 255 255 / 0.85);
+ font-size: 0.8125rem;
+}
+.calls-nav { position: relative; flex: none; }
+.calls-nav > summary {
+ display: grid;
+ place-items: center;
+ inline-size: 32px;
+ block-size: 32px;
+ border-radius: 999px;
+ cursor: pointer;
+ list-style: none;
color: #fff;
+}
+.calls-nav > summary::-webkit-details-marker { display: none; }
+.calls-nav > summary:hover { background: rgb(255 255 255 / 0.1); }
+.calls-nav[open] > summary { background: #f0f0f0; color: #1a1a1a; }
+.calls-nav > summary .icon { inline-size: 18px; block-size: 18px; }
+.calls-nav-menu {
+ position: absolute;
+ z-index: 8;
+ inset-block-start: calc(100% + 8px);
+ inset-inline-start: 0;
+ display: flex;
+ flex-direction: column;
+ min-inline-size: 15rem;
+ padding: 6px;
+ border-radius: 14px;
+ background: var(--calls-panel);
+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
+}
+.calls-nav-menu a,
+.calls-nav-menu button,
+.calls-menu a {
+ display: flex;
+ align-items: center;
+ min-block-size: 40px;
+ padding: 0 12px;
border: 0;
- background: rgb(255 255 255 / 0.14);
+ border-radius: 9px;
+ background: none;
+ color: #fff;
+ font: inherit;
+ font-size: 0.9375rem;
+ text-align: start;
+ text-decoration: none;
+ cursor: pointer;
+ inline-size: 100%;
+}
+.calls-nav-menu a:hover,
+.calls-nav-menu button:hover,
+.calls-menu a:hover { background: rgb(255 255 255 / 0.1); }
+.calls-nav-menu form { margin: 0; }
+.calls-nav-who {
+ margin: 6px 0 0;
+ padding: 8px 12px 2px;
+ border-block-start: 1px solid rgb(255 255 255 / 0.12);
+ color: rgb(255 255 255 / 0.75);
+ font-size: 0.8125rem;
+ overflow-wrap: anywhere;
+}
+
+/* ── With no script: the page's sentence where the picture would be ── */
+.calls-scriptless {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: calc(var(--calls-title-h) + 40px) 24px 24px;
+ text-align: center;
+}
+.calls-scriptless p { margin: 0; max-inline-size: 34rem; line-height: 1.5; }
+.calls-scriptless-links { display: flex; gap: 16px; justify-content: center; }
+.calls-scriptless form { margin: 0; }
+
+/* ── The room: everything scripted, over the whole window ── */
+.calls-room {
+ position: absolute;
+ inset: 0;
+}
+.calls-room[hidden] { display: none; }
+
+/* ── Notices: under the title, and never faded ── */
+.calls-notices {
+ position: absolute;
+ z-index: 5;
+ inset-block-start: calc(12px + var(--calls-title-h) + 8px);
+ inset-inline: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ pointer-events: none;
+}
+.calls-notices > * { pointer-events: auto; max-inline-size: min(40rem, 100%); }
+.calls-window .calls-status {
+ margin: 0;
+ min-block-size: 0;
+ padding: 8px 16px;
+ border-radius: 18px;
+ color: #fff;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-align: center;
+}
+.calls-window .calls-status:empty { display: none; }
+/* "You are the only one here" is how the call is going, not a problem:
+ it goes with the controls (a notice that is not important, on the
+ Mac, fades with them). */
+.calls-window .calls-status[data-quiet] { transition: opacity var(--calls-fade-in) ease-out; }
+.calls-window[data-chrome='hidden'] .calls-status[data-quiet] { opacity: 0; transition-duration: var(--calls-fade-out); }
+.calls-window .calls-status a { color: #fff; font-weight: 600; margin-inline-start: 8px; }
+.calls-window .calls-ice-warning {
+ margin: 0;
+ padding: 8px 16px;
+ border-radius: 18px;
+ font-size: 0.875rem;
+ text-align: center;
+}
+.calls-window .calls-device-note { color: #fff; }
+
+/* The sharing notice (2026-09-28): a dark pill with its own Stop
+ sharing button, and the recovery line under it. */
+.calls-share-notice:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+.calls-share-notice-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 6px 6px 6px 14px;
+ border-radius: 999px;
+ color: #ffd99a;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+.calls-share-notice-row svg { inline-size: 1.15em; block-size: 1.15em; flex: none; }
+.calls-share-stop,
+.calls-sound-banner button,
+.calls-queue li button {
+ min-block-size: 28px;
+ padding: 2px 12px;
+ border: 0;
+ border-radius: 999px;
+ background: #fff;
+ color: #111;
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: none;
+}
+.calls-share-stop:hover,
+.calls-sound-banner button:hover,
+.calls-queue li button:hover { background: #e8e8e8; }
+.calls-share-recovery {
+ margin: 0;
+ padding: 4px 12px;
+ border-radius: 999px;
+ background: var(--calls-glass);
+ font-size: 0.8125rem;
+}
+
+/* The page's own "Click anywhere to turn on sound". */
+.calls-sound-banner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 8px 12px;
+ padding: 6px 6px 6px 16px;
+ border-radius: 999px;
+}
+.calls-sound-banner[hidden] { display: none; }
+.calls-sound-banner p { margin: 0; font-size: 0.875rem; font-weight: 600; }
+
+/* Each person waiting, with Admit. */
+.calls-queue {
+ padding: 8px 8px 8px 16px;
+ border-radius: 18px;
+}
+.calls-queue h2 {
+ margin: 0 0 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: rgb(255 255 255 / 0.85);
+ text-transform: none;
+}
+.calls-queue ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.calls-queue li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px 16px;
+ font-size: 0.875rem;
+ overflow-wrap: anywhere;
+}
+.calls-queue li button { flex: none; }
+
+/* ── The stage ── */
+.calls-stage {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ container-type: size;
+}
+.calls-grid { margin: 0; }
+
+.calls-tile {
+ position: relative;
+ margin: 0;
+ min-inline-size: 0;
+ overflow: hidden;
+ aspect-ratio: 16 / 9;
+ border: 0;
+ border-radius: 8px;
+ background: #212121;
+ container-type: inline-size;
+}
+.calls-tile video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.calls-tile-self:not(.calls-tile-share) video,
+.calls-preview {
+ /* Mirror only our camera display, never remote video or shared screens. */
+ transform: scaleX(-1);
+}
+/* A shared screen (spec §5.4) is not a face: the part of it that matters
+ is usually at the edges, so the whole screen is fitted into the tile
+ rather than cropped to fill it. */
+.calls-tile-share video {
+ object-fit: contain;
+ background: #000;
+}
+
+/* Speaker view (0.4.0): the stage fills the window above the filmstrip,
+ and the filmstrip runs along the bottom — tiles a sixth of the window
+ high, 72 to 120px, centred while they fit and scrolling sideways once
+ they do not — rising clear of the bar while the controls show, so
+ with them hidden nothing lies over the stage. The stage is placed
+ against .calls-stage rather than #tiles, so it neither scrolls with
+ the filmstrip nor is clipped by it; the tiles stay children of #tiles,
+ whose order is the reading order call-layout.js keeps. */
+.calls-window #tiles[data-layout='speaker'] {
+ --calls-strip-h: clamp(72px, calc(100cqh / 6), 120px);
+ --calls-band: calc(var(--calls-strip-h) + 16px);
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ justify-content: safe center;
+ gap: 8px;
+ min-block-size: 0;
+ padding: 8px 12px;
+ margin-block-end: var(--calls-lift);
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ transition: margin-block-end var(--calls-fade-in) ease-out;
+}
+.calls-window #tiles[data-layout='speaker']:not(:has(> figure:not([data-stage], .calls-tile-pip))) {
+ --calls-band: 0px;
+ padding: 0;
+}
+.calls-window #tiles[data-layout='speaker'] > figure {
+ flex: none;
+ block-size: var(--calls-strip-h);
+ inline-size: auto;
+ aspect-ratio: 16 / 9;
+}
+.calls-window #tiles[data-layout='speaker'] > figure:not([data-stage]) { cursor: pointer; }
+.calls-window #tiles[data-layout='speaker'] > [data-stage] {
+ position: absolute;
+ inset: 0 0 var(--calls-band) 0;
+ block-size: auto;
+ inline-size: auto;
+ aspect-ratio: auto;
+ border-radius: 0;
+ background: #0b0b0c;
+}
+.calls-window #tiles[data-layout='speaker'] > [data-stage] .calls-tile-name {
+ inset-block-end: 12px;
+ inset-inline-start: 12px;
+}
+/* A call of two: no filmstrip, the other person fills the window and
+ you float in a corner, clear of the bar while it shows. */
+.calls-window[data-chrome='shown'] #tiles[data-layout='speaker']:not(:has(> figure:not([data-stage], .calls-tile-pip))) > [data-stage] .calls-tile-name {
+ translate: 0 calc(-1 * var(--calls-lift));
+}
+
+/* The self view, floating (Stage.selfFloats): about a fifth of the
+ window's width, 150 to 260px at 16:9, 12px in from the corner it was
+ put in (call-layout.js's data-corner), kept clear of the bar while it
+ shows, of the title in the top left, and of an open chat. Dragged, it
+ follows the pointer and settles in the corner it was headed for. */
+.calls-window #tiles > .calls-tile-pip {
+ --calls-pip-top: calc(12px + var(--calls-title-h) * var(--calls-title-on, 0) + 4px * var(--calls-title-on, 0));
+ position: absolute;
+ z-index: 2;
+ inline-size: clamp(150px, 20cqw, 260px);
+ block-size: auto;
+ aspect-ratio: 16 / 9;
+ border-radius: 10px;
+ box-shadow: 0 3px 16px rgb(0 0 0 / 0.35);
+ inset: auto 12px calc(12px + max(0px, var(--calls-lift) - 4px)) auto;
+ cursor: grab;
+ transition: inset 0.2s ease-out, translate 0s;
+}
+.calls-window[data-chrome='shown'] #tiles > .calls-tile-pip { --calls-title-on: 1; }
+.calls-window #tiles[data-corner='top-left'] > .calls-tile-pip { inset: var(--calls-pip-top) auto auto 12px; }
+.calls-window #tiles[data-corner='top-right'] > .calls-tile-pip { inset: 12px 12px auto auto; }
+.calls-window #tiles[data-corner='bottom-left'] > .calls-tile-pip { inset: auto auto calc(12px + max(0px, var(--calls-lift) - 4px)) 12px; }
+.calls-window:has(.calls-chat-overlay:not([hidden])) #tiles:not([data-corner='top-left'], [data-corner='bottom-left']) > .calls-tile-pip {
+ inset-inline-end: calc(12px + min(320px, 100cqw - 24px) + 12px);
+}
+.calls-window #tiles > .calls-tile-pip.calls-tile-dragging { transition: none; z-index: 4; cursor: grabbing; }
+.calls-window #tiles > .calls-tile-pip .calls-tile-name { inset-block-end: 6px; inset-inline-start: 6px; }
+
+/* Grid: every tile as large as it can be without scrolling (GridFit,
+ which call-layout.js runs and writes as --calls-cols and the tile's
+ size), until they would be under 200px wide, when it scrolls. The
+ last row's names rise clear of the bar while it shows, by as much of
+ the bar as reaches over them (--calls-grid-lift). */
+.calls-window #tiles[data-layout='grid'] {
+ flex: 1;
+ display: grid;
+ grid-template-columns: repeat(var(--calls-cols, 2), var(--calls-tile-w, 320px));
+ grid-auto-rows: var(--calls-tile-h, 180px);
+ gap: 4px;
+ place-content: center;
+ padding: 4px;
+ overflow: auto;
+ scrollbar-width: none;
+}
+.calls-window #tiles[data-layout='grid'] > figure {
+ inline-size: var(--calls-tile-w, 320px);
+ block-size: var(--calls-tile-h, 180px);
+ aspect-ratio: auto;
+ border-radius: 6px;
+}
+.calls-window[data-chrome='shown'] #tiles[data-layout='grid'] > [data-last-row] .calls-tile-name {
+ translate: 0 calc(-1 * var(--calls-grid-lift, 0px));
+}
+
+/* The tile caption: the name, and, when the pair has failed or the
+ browser refused to play the track, the sentence and the control
+ beside it — plus the removal link a member gets. A glass capsule of
+ its own, with the muted mark inside it (shape and word). */
+.calls-tile-name {
+ position: absolute;
+ inset-block-end: 8px;
+ inset-inline-start: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ max-inline-size: calc(100% - 16px);
+ padding: 3px 10px;
+ border-radius: 999px;
+ color: #fff;
+ font-size: var(--rst-fs-sm);
+ line-height: 1.35;
+}
+.calls-tile-name:has(.calls-tile-hear:not([hidden]), .calls-tile-notice:not([hidden])) {
+ border-radius: 14px;
+}
+.calls-tile-name > span:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.calls-tile-name button,
+.calls-tile-name a {
+ min-block-size: 24px;
+ min-inline-size: 24px;
+ padding: 0 6px;
+ border: 0;
+ border-radius: 8px;
+ background: rgb(255 255 255 / 0.14);
+ color: #fff;
+ font: inherit;
+ cursor: pointer;
+}
+/* Never colour alone: a failed connection says so in words, and the
+ weight is only a second cue on top of the sentence. */
+.calls-tile-note { color: #fff; font-weight: 600; }
+.calls-tile-name [rst-badge] { color: #fff; border-color: rgb(255 255 255 / 0.5); }
+
+/* Spec §15.19: a muted peer's badge sits beside their name — the
+ slashed microphone and the vendored badge's word, so the state is a
+ shape and a word and never a colour. */
+.calls-tile-muted {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex: none;
+}
+.calls-tile-muted .icon { inline-size: 1em; block-size: 1em; }
+.calls-tile-muted[hidden],
+.calls-tile-off[hidden] { display: none; }
+
+/* A camera that is off sends black frames, or holds the last one. The
+ cover says what that is instead of leaving a frozen face or a black
+ rectangle to be read as a fault. It lies over the video rather than
+ hiding it: the element is also what plays the peer's audio. Its
+ symbol and words are centred in the space above the caption. */
+.calls-tile-off {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding-block-end: 2.75rem;
+ background: #212121;
+ color: rgb(255 255 255 / 0.8);
+ font-size: var(--rst-fs-sm);
+}
+.calls-tile-off .icon { inline-size: 2rem; block-size: 2rem; }
+.calls-tile-camera-off .calls-tile-name { z-index: 1; }
+
+/* What a tile says about its audio when the rim cannot: no sound,
+ sound blocked, a microphone picking nothing up. Words, with the
+ weight as a second cue, in the caption's own pill. The ⓘ is a native
+ disclosure, so it is a keyboard control with no script of its own,
+ and what it opens takes a line of the caption to itself. */
+.calls-tile-hear {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+ font-weight: 600;
+}
+.calls-tile-hear[hidden],
+.calls-tile-notice[hidden] { display: none; }
+.calls-info summary {
+ cursor: pointer;
+ list-style: none;
+ min-inline-size: 24px;
+ min-block-size: 24px;
+ display: inline-grid;
+ place-items: center;
+ font-weight: 400;
+}
+.calls-info summary::-webkit-details-marker { display: none; }
+.calls-info[open] { flex-basis: 100%; }
+.calls-info p {
+ margin: 4px 0 0;
+ font-weight: 400;
+ max-inline-size: 30rem;
+}
+/* A peer's own report about hearing us: a line of its own in the
+ caption, because it is about the call and not about the name. */
+.calls-tile-notice {
+ flex-basis: 100%;
+ font-weight: 600;
+}
+
+/* A filmstrip tile, or any small one (0.4.1). A tile too small for the
+ camera-off words keeps the symbol alone, then a smaller one. The
+ caption stays one line: the name gives up its end to an ellipsis, the
+ muted mark keeps its slashed microphone and drops the word (still
+ read out: it is only visually hidden), and there is room past the
+ caption's end for the warm rim's breath. */
+@container (max-width: 20rem) {
+ .calls-tile-name {
+ flex-wrap: nowrap;
+ box-sizing: border-box;
+ max-inline-size: calc(100% - 22px);
+ inset-block-end: 6px;
+ inset-inline-start: 6px;
+ padding: 2px 8px;
+ font-size: var(--rst-fs-xs);
+ }
+ .calls-tile-name > span:first-child { flex: 0 1 auto; min-inline-size: 0; }
+ .calls-tile-name > :not(:first-child) { flex: none; }
+ .calls-tile-muted > :not(svg) {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+}
+@container (max-width: 15rem) {
+ .calls-tile-off > :not(svg) {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+}
+@container (max-width: 9rem) {
+ .calls-tile-off .icon { inline-size: 1.25rem; block-size: 1.25rem; }
}
/* The speaking light on the name, which replaced the ring round the
@@ -571,58 +889,10 @@
}
}
-/* The room's control bar: one glass pane the buttons sit on, and the
- buttons borderless on it — white glyph and word, a light wash under
- the pointer, and a filled capsule only for a state: microphone or
- camera off in amber (the slashed word says it too), sharing or a
- panel open in near-white with dark text, Leave in red. The state is
- also the word in .calls-toggle-state and aria-pressed, so never
- colour alone. estilo's ring for [aria-pressed] is replaced by the
- capsule. */
-#controls.calls-controls {
- padding: 8px;
- border-radius: 28px;
- gap: 4px;
-}
-#controls.calls-controls button {
- background: transparent;
- border: 0;
- box-shadow: none;
- color: #fff;
- border-radius: 999px;
-}
-#controls.calls-controls button:hover { background: rgb(255 255 255 / 0.1); }
-#controls.calls-controls button:active { background: rgb(255 255 255 / 0.18); }
-#controls.calls-controls button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
-#controls.calls-controls #mute[aria-pressed='true'],
-#controls.calls-controls #camera[aria-pressed='true'] {
- background: #a64d05;
- color: #fff;
-}
-#controls.calls-controls #share[aria-pressed='true'],
-#controls.calls-controls [aria-expanded='true'] {
- background: #f0f0f0;
- color: #1a1a1a;
-}
-#controls.calls-controls #leave,
-#controls.calls-controls #leave:hover {
- background: #d62924;
- color: #fff;
-}
-#controls.calls-controls #leave:active { background: #b3201c; }
-
-.calls-tile-name .calls-tile-note { color: #fff; }
-/* No hairline round a tile either: the picture's own edge is the edge. */
-.calls-tile { border: 0; }
-/* Speaker view's filmstrip and the viewer's order (0.4.0). Everything
- not on the stage is in the rows under it, in this viewer's order;
- call-layout.js moves the tiles themselves, so what is read is what is
- seen. Each tile carries its arrangement buttons (Pin, Move to first,
- earlier, later) in its top corner: shown on hover and whenever the
- keyboard is in them, and always on a screen with no hover. */
-.calls-grid > figure { cursor: default; }
-.calls-grid[data-layout='speaker'] > figure:not([data-stage]) { cursor: pointer; }
+/* 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. */
.calls-tile-tools {
position: absolute;
inset-block-start: 6px;
@@ -635,6 +905,7 @@
.calls-tile:hover > .calls-tile-tools,
.calls-tile:focus-within > .calls-tile-tools { opacity: 1; }
@media (hover: none) { .calls-tile-tools { opacity: 1; } }
+.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;
@@ -660,6 +931,17 @@
.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; }
+}
/* 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%); }
@@ -676,94 +958,362 @@
.calls-tile-lifted { scale: 1.04; }
}
-/* 0.4.1: the bar's glyphs sit on the bar's centre line by their ink,
- not by their boxes. A Lucide glyph is drawn to its 24-unit square,
- and where its weight falls inside it is its own: the microphone's
- capsule and arc are its weight, and they are above its stem, so its
- ink sat 0.9 px low. Each glyph that needs it gets one line here,
- measured by the browser gate's glyphs scenario, which holds every
- glyph to within half a pixel of the centre line — the same rule, the
- same table and the same test as the native CallGlyph. */
-#controls.calls-controls button .icon {
+/* ── The bar ──
+
+ One glass capsule along the bottom, at most 760px wide, the buttons
+ borderless on it: a white glyph, a light wash under the pointer, and
+ a filled capsule only for a state — microphone or camera off in amber
+ with the slashed glyph, sharing or a panel open in near-white with a
+ dark glyph, Leave in red (CallOverlay's colours: amber 5.7:1, red
+ 5.0:1 and the near-white 15:1 for their glyphs). The state is also
+ the slashed glyph, the word in .calls-toggle-state and aria-pressed,
+ so never colour alone. Each button is named for a screen reader and
+ has a tooltip, above it, on hover and on keyboard focus. */
+.calls-window #controls {
+ position: absolute;
+ z-index: 6;
+ inset-block-end: 16px;
+ inset-inline-start: 50%;
+ translate: -50% 0;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 10px;
+ inline-size: min(760px, calc(100% - 32px));
+ box-sizing: border-box;
+ padding: 10px;
+ margin: 0;
+ border-radius: 999px;
+ box-shadow: 0 4px 24px rgb(0 0 0 / 0.25);
+}
+.calls-sheet { display: contents; }
+.calls-bar-spacer { flex: 1 1 24px; min-inline-size: 24px; }
+.calls-bar-phone,
+.calls-window #controls .calls-bar-phone { display: none; }
+.calls-window #controls form { margin: 0; display: flex; }
+.calls-window #controls button {
+ position: relative;
+ display: inline-grid;
+ place-items: center;
flex: none;
- translate: 0 var(--calls-glyph-dy, 0px);
-}
-#controls.calls-controls #mute { --calls-glyph-dy: -0.9px; }
-
-/* A filmstrip tile, or any small one (0.4.1). The camera-off cover's
- symbol and words are centred in the space above the caption, never
- under it, and a tile too small for the words keeps the symbol alone,
- then a smaller one. The caption stays one line: the name gives up its
- end to an ellipsis, the muted mark keeps its slashed microphone and
- drops the word (still read out: it is only visually hidden), and
- there is room past the caption's end for the warm rim's breath. */
-.calls-tile { container-type: inline-size; }
-.calls-tile-off { padding-block-end: 2.75rem; }
-.calls-tile-name [rst-badge] { color: #fff; border-color: rgb(255 255 255 / 0.5); }
-@container (max-width: 20rem) {
- .calls-tile-name {
- flex-wrap: nowrap;
- box-sizing: border-box;
- max-inline-size: calc(100% - 2 * var(--rst-sp-2) - 6px);
- }
- .calls-tile-name > span:first-child { flex: 0 1 auto; min-inline-size: 0; }
- .calls-tile-name > :not(:first-child) { flex: none; }
- .calls-tile-muted > :not(svg) {
- position: absolute;
- inline-size: 1px;
- block-size: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
- }
+ inline-size: 44px;
+ block-size: 44px;
+ min-inline-size: 44px;
+ min-block-size: 44px;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ box-shadow: none;
+ color: #fff;
+ font: inherit;
+ font-weight: 500;
+ cursor: pointer;
}
-@container (max-width: 15rem) {
- .calls-tile-off > :not(svg) {
- position: absolute;
- inline-size: 1px;
- block-size: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
- }
+.calls-window #controls button[hidden] { display: none; }
+.calls-window #controls button .icon { inline-size: 20px; block-size: 20px; grid-area: 1 / 1; }
+.calls-window #controls .calls-g { display: contents; }
+.calls-window #controls [aria-pressed='true'] > .calls-g-rest,
+.calls-window #controls :not([aria-pressed='true']) > .calls-g-pressed,
+.calls-window #controls #layout-toggle[data-mode='grid'] > .calls-g-speaker,
+.calls-window #controls #layout-toggle:not([data-mode='grid']) > .calls-g-grid { display: none; }
+/* The words are for a screen reader on the bar, and shown in a phone's
+ controls sheet. */
+.calls-bar-label {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
}
-@container (max-width: 9rem) {
- .calls-tile-off .icon { inline-size: 1.25rem; block-size: 1.25rem; }
+.calls-window #controls button:hover { background: rgb(255 255 255 / 0.1); }
+.calls-window #controls button:active { background: rgb(255 255 255 / 0.18); }
+.calls-window #controls button:disabled { opacity: 0.45; cursor: default; background: transparent; }
+.calls-window #controls #mute[aria-pressed='true'],
+.calls-window #controls #camera[aria-pressed='true'] {
+ background: #a64d05;
+ color: #fff;
+}
+.calls-window #controls #share[aria-pressed='true'],
+.calls-window #controls button[aria-expanded='true'] {
+ background: #f0f0f0;
+ color: #1a1a1a;
+}
+.calls-window #controls #leave {
+ inline-size: 62px;
+ background: #d62924;
+ color: #fff;
+}
+.calls-window #controls #leave:hover { background: #c42420; }
+.calls-window #controls #leave:active { background: #b3201c; }
+.calls-window #controls button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+/* The unread count on Chat: white on violet, 6.0:1. */
+.calls-badge:empty { display: none; }
+.calls-badge {
+ position: absolute;
+ inset-block-start: -3px;
+ inset-inline-end: -3px;
+ min-inline-size: 18px;
+ block-size: 18px;
+ padding: 0 5px;
+ box-sizing: border-box;
+ border-radius: 999px;
+ background: #7d3bd9;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 18px;
+ text-align: center;
}
-/* The sharing notice (2026-09-28): a dark pill above the stage with its
- own Stop sharing button, and the recovery line under it. No edge: it
- is told from the page by its tone, as the native notices are. */
-.calls-share-notice:not([hidden]) {
+/* 0.4.1: the bar's glyphs sit on the bar's centre line by their ink,
+ not by their boxes. A Lucide glyph is drawn to its 24-unit square,
+ and where its weight falls inside it is its own. Each glyph that
+ needs it gets one line here, measured by the browser gate's glyphs
+ scenario, which holds every glyph to within half a pixel of the
+ centre line — the same rule, the same table and the same test as the
+ native CallGlyph. */
+.calls-window #controls button .icon { translate: 0 var(--calls-glyph-dy, 0px); }
+.calls-window #controls #mute > .calls-g-rest > .icon { --calls-glyph-dy: -0.9px; }
+.calls-window #controls form button .icon { --calls-glyph-dy: -1.6px; }
+.calls-window #controls #toggle-chat .icon { --calls-glyph-dy: 0.75px; }
+.calls-window #controls #leave .icon { --calls-glyph-dy: 1.1px; }
+
+/* The tooltip: the button's own words, above it, after a moment under
+ the pointer and at once for the keyboard. */
+.calls-window #controls [data-tip]::after {
+ content: attr(data-tip);
+ position: absolute;
+ inset-block-end: calc(100% + 14px);
+ inset-inline-start: 50%;
+ translate: -50% 0;
+ padding: 4px 10px;
+ border-radius: 8px;
+ background: rgb(20 20 20 / 0.94);
+ color: #fff;
+ font-size: 0.75rem;
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.12s ease-out;
+}
+.calls-window #controls [data-tip-on][aria-pressed='true']::after { content: attr(data-tip-on); }
+.calls-window #controls [data-tip]:hover::after { opacity: 1; transition-delay: 0.5s; }
+.calls-window #controls [data-tip]:focus-visible::after { opacity: 1; transition-delay: 0s; }
+.calls-window #controls [aria-expanded='true']::after,
+.calls-window[data-chrome='hidden'] #controls [data-tip]::after { opacity: 0; transition-delay: 0s; }
+
+/* ── The popovers: layout, devices and More ──
+
+ Over the picture above the button that opened it (call-window.js
+ sets --calls-pop-x), read rather than glanced at: opaque and dark,
+ with the dark theme's full-contrast labels (the Mac's panelFill). A
+ popover holds the controls up while it is open. */
+.calls-popover {
+ position: absolute;
+ z-index: 7;
+ inset-block-end: calc(var(--calls-bar-reserve) + 8px);
+ inset-inline-start: var(--calls-pop-x, 16px);
+ box-sizing: border-box;
+ max-inline-size: calc(100% - 24px);
+ max-block-size: calc(100% - var(--calls-bar-reserve) - 28px);
+ overflow: auto;
+ padding: 12px 14px;
+ border-radius: 16px;
+ background: var(--calls-panel);
+ color: #fff;
+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
+}
+.calls-popover[hidden] { display: none; }
+.calls-popover-title { margin: 0 0 8px; font-size: 0.9375rem; font-weight: 650; }
+.calls-popover-help { margin: 10px 0 0; max-inline-size: 16rem; font-size: 0.8125rem; color: rgb(255 255 255 / 0.8); }
+.calls-popover [estilo-call-segmented] { background: rgb(255 255 255 / 0.08); border-color: transparent; }
+.calls-popover [estilo-call-segmented] button { color: #fff; min-inline-size: 88px; }
+.calls-popover [estilo-call-segmented] [aria-pressed='true'] { background: #f0f0f0; color: #1a1a1a; border-color: transparent; }
+.calls-window .calls-devices {
+ inline-size: 320px;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ gap: 12px;
+ margin: 0;
+}
+.calls-window .calls-devices:not([hidden]) { display: flex; }
+.calls-window .calls-devices > div { flex: none; }
+.calls-window .calls-devices .calls-popover-title { margin: 0; }
+.calls-window .calls-devices .calls-raw-help { color: rgb(255 255 255 / 0.8); }
+.calls-menu { padding: 6px; min-inline-size: 14rem; }
+.calls-menu a.calls-menu-danger { color: #ff9c96; font-weight: 600; }
+
+/* ── Chat: a sidebar sliding over the picture's right-hand side ── */
+.calls-chat-overlay {
+ position: absolute;
+ z-index: 5;
+ inset-block: 12px calc(var(--calls-bar-reserve) + 8px);
+ inset-inline-end: 12px;
+ inline-size: min(320px, calc(100% - 24px));
display: flex;
flex-direction: column;
- align-items: center;
- gap: var(--rst-sp-1);
- margin-block: 0 var(--rst-sp-2);
+ border-radius: 16px;
+ background: var(--rst-surface);
+ color: var(--rst-text);
+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.35);
+ transition: translate 0.2s ease-out, opacity 0.2s ease-out, display 0.2s allow-discrete;
}
-.calls-share-notice-row {
- display: inline-flex;
- align-items: center;
- gap: var(--rst-sp-2);
+.calls-chat-overlay[hidden] { display: none; translate: calc(100% + 12px) 0; opacity: 0; }
+@starting-style {
+ .calls-chat-overlay:not([hidden]) { translate: calc(100% + 12px) 0; opacity: 0; }
+}
+.calls-chat-overlay .chat {
+ flex: 1;
+ min-block-size: 0;
margin: 0;
- padding: 6px 6px 6px 14px;
- border-radius: 999px;
- background: rgba(20, 20, 20, 0.88);
- color: #ffd27a;
- font-weight: 600;
+ border: 0;
+ border-radius: 16px;
+ padding: 14px 14px 12px;
}
-.calls-share-notice-row svg { inline-size: 1.1em; block-size: 1.1em; }
-.calls-share-stop {
- min-block-size: 28px;
- padding: 2px 12px;
+.calls-chat-overlay .chat-title { color: var(--rst-text); font-size: 0.9375rem; font-weight: 650; }
+.calls-chat-overlay .chat-log { flex: 1; min-block-size: 4rem; max-block-size: none; }
+.calls-chat-overlay .calls-chat-collapsed { display: none; }
+.calls-chat-close {
+ position: absolute;
+ inset-block-start: 8px;
+ inset-inline-end: 8px;
+ display: grid;
+ place-items: center;
+ inline-size: 32px;
+ block-size: 32px;
border: 0;
border-radius: 999px;
- background: #fff;
- color: #111;
- font: inherit;
- font-weight: 600;
+ background: transparent;
+ color: var(--rst-text);
cursor: pointer;
}
-.calls-share-stop:hover { background: #e8e8e8; }
-.calls-share-stop:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
-.calls-share-recovery { margin: 0; font-size: 0.9em; color: var(--rst-text); }
+.calls-chat-close:hover { background: rgb(255 255 255 / 0.1); }
+.calls-chat-close .icon { inline-size: 18px; block-size: 18px; }
+
+/* A name rising clear of the bar moves with the bar's fade; the warm
+ rim's own two transitions are kept beside it, or this would replace
+ them. */
+@media (prefers-reduced-motion: no-preference) {
+ .calls-window .calls-tile-name {
+ transition:
+ --lvl var(--lvl-t, 300ms) ease-out,
+ --on var(--on-t, 0ms) step-end,
+ translate var(--calls-fade-in) ease-out;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .calls-fades,
+ .calls-window #tiles[data-layout='speaker'],
+ .calls-window #tiles > .calls-tile-pip { transition: none; }
+ /* Panels fade rather than slide. */
+ .calls-chat-overlay { transition: opacity 0.1s linear, display 0.1s allow-discrete; }
+ .calls-chat-overlay[hidden] { translate: none; }
+ @starting-style {
+ .calls-chat-overlay:not([hidden]) { translate: none; opacity: 0; }
+ }
+}
+
+/* ── A phone: More controls on the left, Leave on the right, and the
+ rest in a sheet above them, as the iPhone app has it ── */
+@media (max-width: 599px), (max-height: 499px) and (pointer: coarse) {
+ .calls-window { --calls-bar-reserve: 84px; }
+ .calls-window #controls {
+ inset-inline: 16px;
+ inset-block-end: 16px;
+ translate: none;
+ inline-size: auto;
+ justify-content: space-between;
+ padding: 0;
+ background: none;
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ box-shadow: none;
+ pointer-events: none;
+ }
+ .calls-window #controls > button {
+ pointer-events: auto;
+ inline-size: 56px;
+ block-size: 56px;
+ background: var(--calls-glass);
+ -webkit-backdrop-filter: blur(18px) saturate(1.6);
+ backdrop-filter: blur(18px) saturate(1.6);
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.25);
+ }
+ .calls-window #controls > button .icon { inline-size: 24px; block-size: 24px; }
+ .calls-window #controls > #leave { inline-size: 56px; background: #d62924; }
+ .calls-window #controls .calls-bar-phone { display: inline-grid; }
+ .calls-window #controls [data-tip]::after { display: none; }
+ .calls-bar-spacer { display: none; }
+ .calls-sheet {
+ display: none;
+ pointer-events: auto;
+ }
+ .calls-sheet[data-open] {
+ position: absolute;
+ inset-block-end: calc(100% + 12px);
+ inset-inline: 0;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ padding: 12px;
+ border-radius: 22px;
+ background: rgb(28 28 28 / 0.96);
+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
+ }
+ .calls-window #controls .calls-sheet[data-open] > button,
+ .calls-window #controls .calls-sheet[data-open] > form > button {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 6px;
+ inline-size: 100%;
+ block-size: auto;
+ min-block-size: 76px;
+ padding: 8px 4px;
+ border-radius: 16px;
+ background: rgb(255 255 255 / 0.08);
+ font-size: var(--rst-fs-xs);
+ line-height: 1.25;
+ text-align: center;
+ }
+ .calls-sheet[data-open] > form { display: flex; }
+ .calls-window #controls .calls-sheet[data-open] > button[hidden] { display: none; }
+ .calls-window #controls .calls-sheet[data-open] .icon { inline-size: 22px; block-size: 22px; flex: none; }
+ .calls-sheet[data-open] .calls-bar-label {
+ position: static;
+ inline-size: auto;
+ block-size: auto;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+ }
+ .calls-sheet[data-open] .calls-badge { inset-block-start: 6px; inset-inline-end: 10px; }
+ .calls-popover {
+ inset-inline: 12px;
+ inline-size: auto;
+ max-inline-size: none;
+ }
+ .calls-window .calls-devices { inline-size: auto; }
+ .calls-chat-overlay {
+ inset-inline: 8px;
+ inset-block-start: 8px;
+ inline-size: auto;
+ }
+ .calls-top { padding-inline-end: 12px; }
+ .calls-window #tiles > .calls-tile-pip {
+ inline-size: clamp(88px, 28cqw, 150px);
+ aspect-ratio: 3 / 4;
+ }
+ .calls-window #tiles[data-layout='speaker'] > figure:not([data-stage]) { aspect-ratio: 3 / 4; }
+}
+@media (max-height: 499px) and (pointer: coarse) {
+ .calls-window #tiles > .calls-tile-pip { inline-size: clamp(88px, 20cqw, 150px); aspect-ratio: 4 / 3; }
+ .calls-window #tiles[data-layout='speaker'] > figure:not([data-stage]) { aspect-ratio: 4 / 3; }
+ .calls-sheet[data-open] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+ .calls-window #controls .calls-sheet[data-open] > button,
+ .calls-window #controls .calls-sheet[data-open] > form > button { min-block-size: 64px; }
+}
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index b785cc8..c8dbcbd 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -5018,7 +5018,11 @@
// The click reaches the page-wide listener below as well; the button
// is here for a keyboard, which needs something to land on.
box.append(text, button);
- root.prepend(box);
+ // In the window's notices, under the sharing notice: it stays on
+ // screen whatever the controls do.
+ const notices = $('notices');
+ if (notices) notices.insertBefore(box, $('status') || null);
+ else root.prepend(box);
soundBanner = { box, text, button };
return soundBanner;
}
@@ -5169,9 +5173,13 @@
// Rendering (§3.4's room, §4.3's queue)
// ---------------------------------------------------------------
- function say(text) {
+ function say(text, quiet) {
statusEl.replaceChildren();
statusEl.textContent = text;
+ // A quiet line is how the call is going rather than something wrong
+ // with it, and fades with the controls (calls.css); everything else
+ // said here stays on screen until it is over.
+ statusEl.toggleAttribute('data-quiet', !!quiet);
}
function renderStatus() {
@@ -5191,7 +5199,7 @@
}
const others = pcs.size;
if (!others) {
- say('You are the only one here.');
+ say('You are the only one here.', true);
return;
}
say('');
diff --git a/internal/calls/templates/layout.html b/internal/calls/templates/layout.html
index 7c6e75f..6a03bbf 100644
--- a/internal/calls/templates/layout.html
+++ b/internal/calls/templates/layout.html
@@ -18,6 +18,10 @@
{{block "head" .}}{{end}}
</head>
<body>
+{{/* The page shell. The call room replaces it (room.html's "body"):
+ a call is one rounded window in the viewport, and its navigation —
+ Meetings, Settings, Help, Sign out — moves into the title's menu. */}}
+{{block "body" .}}
<div rst-shell-topbar>
<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a>
<header rst-shell-bar>
@@ -46,6 +50,7 @@
</div>
</main>
</div>
+{{end}}
</body>
</html>
{{end}}
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index 8b638c2..fa79f8e 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -4,84 +4,150 @@
<script defer src="{{asset "static/chat.js"}}"></script>
<script defer src="{{asset "static/room.js"}}"></script>
<script defer src="{{asset "static/call-layout.js"}}"></script>
+<script defer src="{{asset "static/call-window.js"}}"></script>
{{end}}
-{{define "content"}}
-{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
-<div estilo-call-header>
-{{template "page-header" dict "Title" .Title}}
-<div id="video-layout" estilo-call-segmented role="group" aria-label="Video layout" hidden>
-<button type="button" id="layout-speaker" aria-pressed="true">Speaker</button>
-<button type="button" id="layout-grid" aria-pressed="false">Grid</button>
-</div>
-</div>
-{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
-<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
-{{if .Floor}}<p rst-notice>This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
+{{/* The call window (2026-09-28). Paul: "Can the web UI for a call get
+ an update so it more closely matches the Mac experience, with the
+ whole call UI in a rounded rect 'window' with a bit of padding
+ within the viewport?" So the room replaces the page shell: one
+ rounded window, inset from the viewport's edges (edge to edge on a
+ phone), with the picture filling it and everything else over the
+ picture, as the Mac's full-window call has it (MacFullWindowCall).
-{{/* The Share form appears twice on purpose, and never at the same
- time. The control-row copy below is inside #room, which room.js
- un-hides, so it is the scripted path and matches the native call
- bar. This copy is the scriptless one: <noscript> is parsed as
- markup only when scripting is off, so exactly one of the two is
- ever real. Without it the sentence above is false, which is how
- it stood between c80a506 and a6c2ac8. */}}
-<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p>{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}}</noscript>
-<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden>
- {{/* While this tab shares its screen: said in words, with the way out
- beside it (Paul, 2026-09-28: "I would expect this notification to
- have a 'Stop sharing' button inline"), and, under it, whose view of
- the screen is being recovered (spec §15.21). In the flow above the
- stage, so it never covers a tile and never fades. */}}
- <div id="share-notice" class="calls-share-notice" hidden>
- <p class="calls-share-notice-row">{{icon "monitor"}}<span>You are sharing your screen</span><button type="button" id="share-stop" class="calls-share-stop" aria-label="Stop sharing your screen">Stop sharing</button></p>
- <p id="share-recovery" class="calls-share-recovery" role="status" hidden></p>
- </div>
- <div id="call-workspace" estilo-call-workspace="sidebar">
- <div class="calls-stage">
- <div id="tiles" class="calls-grid"></div>
+ What the page shell carried — Meetings, Settings, Help, the
+ account and Sign out — is in the title's menu: a native disclosure,
+ so it opens with no script at all. The title, the standing line and
+ the floor notice are server-rendered and outside #room, so a
+ scriptless browser has them inside the window; it has the
+ paragraph saying a call needs JavaScript, and the page's links,
+ where the picture would be. */}}
+{{define "body"}}
+<main id="main" class="calls-window" aria-labelledby="call-title">
+<header id="call-top" class="calls-top calls-glass calls-fades">
+ <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">
+ {{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>
+ {{if .Viewer}}<p class="calls-nav-who">{{.Viewer.Email}}</p>
+ <form method="post" action="/signout"><button type="submit">Sign out</button></form>{{end}}
+ </nav>
+ </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}}
</div>
- <div id="controls" class="calls-controls" estilo-call-actions>
- <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="mute-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
- <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="camera-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
- <button type="button" id="share" rst-btn aria-pressed="false">{{icon "monitor"}}Share screen<span id="share-state" class="calls-toggle-state" aria-hidden="true">Off</span></button>
- <button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>{{icon "sliders-horizontal"}}Devices</button>
- {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}}
- <button type="button" id="toggle-chat" rst-btn aria-expanded="true" aria-controls="chat" hidden>{{icon "message-circle"}}Chat<span id="chat-unread" aria-hidden="true"></span></button>
- <button type="button" id="leave" rst-btn="danger">{{icon "log-out"}}Leave</button>
+</header>
+
+{{/* The Share form appears twice on purpose, and never at the same
+ time. The bar's copy below is inside #room, which room.js un-hides,
+ so it is the scripted path and matches the native call bar. This
+ copy is the scriptless one: <noscript> is parsed as markup only when
+ scripting is off, so exactly one of the two is ever real. Without it
+ the sentence below is false, which is how it stood between c80a506
+ and a6c2ac8. "End the meeting" is here for the same reason: with
+ script it is in the bar's More menu, inside #room. */}}
+<noscript><div class="calls-scriptless">
+ <p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p>
+ <p class="calls-scriptless-links">{{if .IsMember}}<a href="/">Meetings</a> {{end}}<a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Help</a></p>
+ {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>
+ <p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
+</div></noscript>
+
+<div id="room" class="calls-room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden>
+ {{/* The notices: what stays on screen whatever the controls do, in a
+ column under the title (CallNotices on the Mac). The sharing
+ notice with its own Stop sharing (Paul, 2026-09-28: "I would
+ expect this notification to have a 'Stop sharing' button
+ inline"), and under it whose view of the screen is being
+ recovered (spec §15.21); "Click anywhere to turn on sound", which
+ room.js puts here; the call's own status line, with its way out
+ when the call is over; the relay warning room.js puts after it;
+ and each person waiting, with Admit. */}}
+ <div id="notices" class="calls-notices">
+ <div id="share-notice" class="calls-share-notice" hidden>
+ <p class="calls-share-notice-row">{{icon "screen-share"}}<span>You are sharing your screen</span><button type="button" id="share-stop" class="calls-share-stop" aria-label="Stop sharing your screen">Stop sharing</button></p>
+ <p id="share-recovery" class="calls-share-recovery" role="status" hidden></p>
+ </div>
+ <p id="status" class="calls-status" role="status"></p>
+ <div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
</div>
+
{{/* Unread chat, for anyone who cannot see the badge on the button.
- The panel's own log is aria-live, but collapsing chat sets
- display:none on it, which takes the whole region out of the
- accessibility tree — so a message arriving while chat is closed
- announced nothing at all. The badge is aria-hidden and a changed
- aria-label does not announce either, so this was silent three ways.
- This region is outside the panel and survives the collapse.
- It carries the count and never the message: someone who closed
- chat has said they do not want it read out, and the count is
- exactly what the badge beside the button shows. call-layout.js
- writes it only while collapsed — with the panel open its own log
- announces arrivals, and saying it twice is worse than once. */}}
+ The panel's own log is aria-live, but closing chat hides it, which
+ takes the whole region out of the accessibility tree — so a
+ message arriving while chat is closed announced nothing at all.
+ The badge is aria-hidden and a changed aria-label does not
+ announce either, so this was silent three ways. This region is
+ outside the panel and survives the closing. It carries the count
+ and never the message: someone who closed chat has said they do
+ not want it read out, and the count is exactly what the badge
+ beside the button shows. call-layout.js writes it only while
+ closed — with the panel open its own log announces arrivals, and
+ saying it twice is worse than once. */}}
<p id="chat-unread-live" class="calls-sr-only" role="status" aria-live="polite"></p>
{{/* Speaker view and the viewer's order (0.4.0): what call-layout.js
says when the person pins, unpins or moves someone. An automatic
change of speaker is never said: in a lively call it would talk
over the talking, and the stage tile says "On stage" itself. */}}
<p id="layout-live" class="calls-sr-only" role="status" aria-live="polite"></p>
+ {{/* Spec §15.19. Someone muting or turning their camera off, said
+ politely and once. A region of its own for the reason
+ #device-note has one: renderStatus rewrites #status on every
+ render, and would take the sentence away before it was read.
+ Present from the first paint, empty, so a reader is already
+ watching it when room.js first writes to it. */}}
+ <p id="media-live" class="calls-sr-only" role="status" aria-live="polite"></p>
+ <div id="stage" class="calls-stage">
+ <div id="tiles" class="calls-grid"></div>
+ </div>
+
+ {{/* The bar, in the Mac bar's order: microphone, camera, share,
+ devices, layout, share link, then chat, More and Leave at the
+ right. Glyphs, each with its name for a screen reader and a
+ tooltip; the word under each is shown in a phone's controls
+ sheet, where the bar is only More controls and Leave, as the
+ iPhone app has it. */}}
+ <div id="controls" class="calls-controls calls-fades" role="group" aria-label="Call controls">
+ <button type="button" id="more-controls" class="calls-bar-phone" aria-expanded="false" aria-controls="call-sheet" aria-label="More controls">{{icon "ellipsis"}}</button>
+ <div id="call-sheet" class="calls-sheet">
+ <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false" data-tip="Mute microphone" data-tip-on="Unmute microphone"><span class="calls-g calls-g-rest">{{icon "mic"}}</span><span class="calls-g calls-g-pressed">{{icon "mic-off"}}</span><span class="calls-bar-label">Microphone<span id="mute-state" class="calls-toggle-state" aria-hidden="true">On</span></span></button>
+ <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false" data-tip="Turn camera off" data-tip-on="Turn camera on"><span class="calls-g calls-g-rest">{{icon "video"}}</span><span class="calls-g calls-g-pressed">{{icon "video-off"}}</span><span class="calls-bar-label">Camera<span id="camera-state" class="calls-toggle-state" aria-hidden="true">On</span></span></button>
+ <button type="button" id="share" rst-btn aria-pressed="false" aria-label="Share screen" data-tip="Share your screen" data-tip-on="Stop sharing your screen"><span class="calls-g calls-g-rest">{{icon "screen-share"}}</span><span class="calls-g calls-g-pressed">{{icon "screen-share-off"}}</span><span class="calls-bar-label">Share screen<span id="share-state" class="calls-toggle-state" aria-hidden="true">Off</span></span></button>
+ <button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden aria-label="Devices" data-tip="Camera, microphone and speaker">{{icon "sliders-horizontal"}}<span class="calls-bar-label">Devices</span></button>
+ <button type="button" id="layout-toggle" rst-btn aria-expanded="false" aria-controls="layout-panel" aria-label="Video layout" data-tip="Video layout: Speaker or Grid" data-mode="speaker"><span class="calls-g calls-g-speaker">{{icon "panel-bottom"}}</span><span class="calls-g calls-g-grid">{{icon "layout-grid"}}</span><span class="calls-bar-label">Layout</span></button>
+ {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link" data-tip="Share meeting link">{{icon "share"}}<span class="calls-bar-label">Share link</span></button></form>{{end}}
+ <span class="calls-bar-spacer" aria-hidden="true"></span>
+ <button type="button" id="toggle-chat" rst-btn aria-expanded="false" aria-controls="chat-overlay" hidden data-tip="Chat">{{icon "messages-square"}}<span class="calls-bar-label">Chat</span><span id="chat-unread" class="calls-badge" aria-hidden="true"></span></button>
+ {{if .IsMember}}<button type="button" id="call-more-toggle" rst-btn aria-expanded="false" aria-controls="call-more" aria-label="More" data-tip="More">{{icon "ellipsis"}}<span class="calls-bar-label">More</span></button>{{end}}
+ </div>
+ <button type="button" id="leave" rst-btn="danger" data-tip="Leave meeting">{{icon "phone-down"}}<span class="calls-bar-label">Leave</span></button>
+ </div>
+
+ {{/* The bar's popovers, each over the picture above the button that
+ opened it: the layout, the devices, and More. call-window.js
+ places them and closes them (Escape, a click elsewhere). */}}
+ <div id="layout-panel" class="calls-popover" role="group" aria-label="Video layout" hidden>
+ <p class="calls-popover-title">Video layout</p>
+ <div id="video-layout" estilo-call-segmented role="group" aria-label="Video layout" hidden>
+ <button type="button" id="layout-speaker" aria-pressed="true">Speaker</button>
+ <button type="button" id="layout-grid" aria-pressed="false">Grid</button>
+ </div>
+ <p class="calls-popover-help">Speaker puts whoever is talking on the stage. Pin anyone from their tile.</p>
+ </div>
{{/* The device panel, closed until asked for. A call's own screen is
- the video and the controls; three selects permanently under them
+ the video and the controls; three selects permanently over them
would be furniture the whole call pays for so that someone can
change a microphone once. room.js reveals the button — the panel
is useless in a browser that cannot enumerate devices — and fills
the selects from the same enumeration the holding screen uses.
The ids are devices.js's own on purpose: the two fill routines
are mirrors, and a reader comparing them should not also have to
- translate names.
-
- It sits after the controls in the workspace, which is where
- calls.css gives it a row of its own under them: a disclosure
- belongs beneath the control that opened it, and putting it in
- the sidebar column would have it argue with chat for the space. */}}
- <div id="room-devices" class="calls-pickers" hidden>
+ translate names. */}}
+ <div id="room-devices" class="calls-pickers calls-popover calls-devices" role="group" aria-label="Devices" hidden>
+ <p class="calls-popover-title">Devices</p>
<div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div>
<div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div>
<div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
@@ -97,21 +163,24 @@
to live somewhere that is not about to be overwritten. */}}
<p id="device-note" class="calls-device-note" role="status" hidden></p>
</div>
- {{/* The chat panel (spec §6): one partial, one script, 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. */}}
- {{template "chat-panel" .}}
+ {{/* Ending the meeting for everyone is destructive, so it is a link to
+ its own confirm page (AGENTS.md), opened in a new tab so that
+ following it does not leave the call. */}}
+ {{if .IsMember}}<div id="call-more" class="calls-popover calls-menu" role="group" aria-label="More" hidden>
+ <a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener" class="calls-menu-danger">End meeting for everyone</a>
+ </div>{{end}}
+
+ {{/* Chat, as a sidebar sliding over the picture's right-hand side
+ (the Mac's chat sidebar): closed until asked for, and closing it
+ keeps the draft. The panel itself (spec §6) is one partial and
+ 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. */}}
+ <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" .}}
</div>
- <div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
- <p id="status" class="calls-status" role="status"></p>
- {{/* Spec §15.19. Someone muting or turning their camera off, said
- politely and once. A region of its own for the reason
- #device-note has one: renderStatus rewrites #status on every
- render, and would take the sentence away before it was read.
- Present from the first paint, empty, so a reader is already
- watching it when room.js first writes to it. */}}
- <p id="media-live" class="calls-sr-only" role="status" aria-live="polite"></p>
+
{{/* The two tile marks room.js clones onto each tile, rendered here so
they are the vendored badge and this app's own icons rather than a
copy of either in script. The badge's word is the state, for a
@@ -124,5 +193,5 @@
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>
</div>
-{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
+</main>
{{end}}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index 26e16c3..7a85ffd 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -83,6 +83,7 @@ var scenarios = []string{
"guestdirect",
"layout",
"glyphs",
+ "window",
"devices",
"audiotest",
"call",
diff --git a/internal/callstest/room_test.go b/internal/callstest/room_test.go
index 33c23ac..daf8155 100644
--- a/internal/callstest/room_test.go
+++ b/internal/callstest/room_test.go
@@ -548,14 +548,18 @@ func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) {
t.Fatalf("the chat panel needs its data-chat mount point: %q", body[panel:panel+end])
}
+ // Since the call window (2026-09-28) the admission queue is one of
+ // the notices under the title, ahead of the stage, and chat is a
+ // sidebar after the bar; both are still inside #room, which is what
+ // makes them script-only.
room := strings.Index(body, `<div id="room"`)
controls := strings.Index(body, `id="controls"`)
queue := strings.Index(body, `id="queue"`)
if room < 0 || controls < 0 || queue < 0 {
t.Fatalf("room page shape changed: room %d controls %d queue %d", room, controls, queue)
}
- if room > controls || controls > panel || panel > queue {
- t.Fatalf("the panel belongs inside #room after the controls and before the queue: room %d controls %d chat %d queue %d", room, controls, panel, queue)
+ if room > queue || room > controls || controls > panel {
+ t.Fatalf("the queue and the panel belong inside #room, the panel after the controls: room %d controls %d chat %d queue %d", room, controls, panel, queue)
}
// The form's own shape: one labelled control, the browser's own
@@ -640,8 +644,8 @@ func TestRoomRendersItsDevicePanelClosed(t *testing.T) {
c.signIn("paul@example.com")
body := c.get("/m/" + sqid).Body.String()
for _, want := range []string{
- `<button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>`,
- `<div id="room-devices" class="calls-pickers" hidden>`,
+ `<button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden aria-label="Devices"`,
+ `<div id="room-devices" class="calls-pickers calls-popover calls-devices" role="group" aria-label="Devices" hidden>`,
`id="pick-camera"`,
`id="pick-mic"`,
`id="pick-speaker"`,
@@ -701,3 +705,80 @@ func TestRoomAnnouncesUnreadChatOutsideTheCollapsingPanel(t *testing.T) {
t.Fatal("the unread live region renders inside #chat, where collapsing hides it")
}
}
+
+// The call window (2026-09-28): the room replaces the page shell with
+// one rounded window, so what the shell carried has to be somewhere a
+// scriptless browser can still reach. The title, the standing line and
+// the floor notice are server-rendered in the window's title, outside
+// #room; the navigation — Meetings, Settings, Help and Sign out — is in
+// the title's menu, a native <details>, so it opens with no script; and
+// "End the meeting", which with script is in the bar's More menu inside
+// #room, is in the <noscript> block beside the Share form, as a link to
+// its own confirm page.
+func TestRoomIsOneWindowWhoseNavigationWorksWithoutScript(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid).Body.String()
+
+ win := strings.Index(body, `<main id="main" class="calls-window"`)
+ room := strings.Index(body, `<div id="room"`)
+ if win < 0 || room < win {
+ t.Fatalf("the room is not inside the call window: window %d, #room %d\n%s", win, room, body)
+ }
+ if strings.Contains(body, "rst-shell-topbar") {
+ t.Fatal("the room still renders the page shell's header")
+ }
+ title := strings.Index(body, `<h1 id="call-title">Board meeting</h1>`)
+ standing := strings.Index(body, "joining as a member")
+ if title < win || title > room || standing < win || standing > room {
+ t.Fatalf("the title and the standing line must be server-rendered in the window, outside #room: title %d standing %d room %d", title, standing, room)
+ }
+ nav := strings.Index(body, `<details id="call-nav"`)
+ if nav < win || nav > room {
+ t.Fatalf("the title's menu must be outside #room, so it works with no script: %d", nav)
+ }
+ menu := body[nav:room]
+ for _, want := range []string{`href="/">Meetings</a>`, `href="/settings">Settings</a>`, `href="/help/open-source"`, `<form method="post" action="/signout"><button type="submit">Sign out</button></form>`} {
+ if !strings.Contains(menu, want) {
+ t.Fatalf("the title's menu lacks %q:\n%s", want, menu)
+ }
+ }
+ ns := strings.Index(body, "<noscript>")
+ nsEnd := strings.Index(body, "</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)
+ }
+ if !strings.Contains(body[ns:nsEnd], `href="/meetings/`+sqid+`/end"`) {
+ t.Fatalf("with no script, End the meeting must still be reachable:\n%s", body[ns:nsEnd])
+ }
+ // With script, End meeting for everyone is in the bar's More menu:
+ // still a link to its own confirm page, never a button that ends it.
+ more := strings.Index(body, `<div id="call-more"`)
+ if more < room || !strings.Contains(body[more:], `<a href="/meetings/`+sqid+`/end" target="_blank"`) {
+ t.Fatalf("the bar's More menu must link End meeting for everyone to its confirm page")
+ }
+}
+
+// A guest's room has no member actions anywhere: no Meetings list, no
+// Settings, no Share link and no way to end the meeting, in the menu,
+// the bar or the scriptless block.
+func TestGuestRoomWindowCarriesNoMemberActions(t *testing.T) {
+ x, sqid, token := memberWithMeeting(t)
+ guest := x.browser(t)
+ guest.get("/j/" + token)
+ guest.signIn("guest@example.com")
+ rec := guest.get("/m/" + sqid)
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK || !strings.Contains(body, "joining as a guest") {
+ t.Fatalf("guest room: %d\n%s", rec.Code, body)
+ }
+ for _, not := range []string{`href="/">Meetings</a>`, `href="/settings"`, `/end"`, `action="/m/` + sqid + `/share"`, `id="call-more"`} {
+ if strings.Contains(body, not) {
+ t.Fatalf("a guest's room carries %q", not)
+ }
+ }
+ if !strings.Contains(body, `action="/signout"`) || !strings.Contains(body, `href="/help/open-source"`) {
+ t.Fatal("a guest keeps Help and Sign out in the title's menu")
+ }
+}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 1a10f88..8b2ecf9 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -332,8 +332,8 @@ const consoleLines = new Map(); // label -> string[]
// `via` is the browser to open the context in, for the one scenario
// (autoplay) that needs a Chromium launched under a different policy.
-async function newContext(label, { cookie = null, javaScriptEnabled = true, via = browser } = {}) {
- const ctx = await via.newContext({ javaScriptEnabled });
+async function newContext(label, { cookie = null, javaScriptEnabled = true, via = browser, options = {} } = {}) {
+ const ctx = await via.newContext({ javaScriptEnabled, ...options });
if (cookie) {
await ctx.addCookies([{ name: cookieName, value: cookie, url: origin }]);
}
@@ -680,6 +680,37 @@ async function leaveRoom(page) {
}
}
+// The call window (2026-09-28) keeps chat, the layout and the devices
+// behind the bar's buttons, so a scenario opens them the way a person
+// does. chatMounted is whether room.js has made the panel usable — the
+// hidden attribute it owns — which is not whether the sidebar is open.
+function chatMounted(page) {
+ return page.evaluate(() => {
+ const c = document.getElementById('chat');
+ return !!c && !c.hidden;
+ });
+}
+
+async function openChat(page) {
+ await until(() => chatMounted(page), 10000);
+ if ((await page.locator('#toggle-chat').getAttribute('aria-expanded')) !== 'true') {
+ await page.locator('#toggle-chat').click();
+ }
+ await page.locator('#chat-text').waitFor({ state: 'visible', timeout: 5000 });
+}
+
+// chooseLayout picks Speaker or Grid from the bar's layout popover, and
+// closes it again.
+async function chooseLayout(page, mode) {
+ // On a phone the layout button is in the controls sheet.
+ if (!(await page.locator('#layout-toggle').isVisible()) && await page.locator('#more-controls').isVisible()) {
+ await page.locator('#more-controls').click();
+ }
+ if (await page.locator('#layout-panel').isHidden()) await page.locator('#layout-toggle').click();
+ await page.locator('#layout-' + mode).click();
+ if (!(await page.locator('#layout-panel').isHidden())) await page.keyboard.press('Escape');
+}
+
// cuttableStream is the instrument the three resume gates share: A's
// *attaching* stream GET is aborted while `cut.on` is true, and only
// that one. The probe forms go through untouched, because the whole
@@ -1327,7 +1358,36 @@ function decodePNG(buf) {
const i = y * stride + x * bpp;
return 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
};
- return { width, height, lum };
+ const rgb = (x, y) => {
+ const i = y * stride + x * bpp;
+ return [px[i], px[i + 1], px[i + 2]];
+ };
+ return { width, height, lum, rgb };
+}
+
+// contrastOnWhite is WCAG's contrast ratio of white text on a colour.
+function contrastOnWhite([r, g, b]) {
+ const lin = (c) => {
+ const v = c / 255;
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
+ };
+ const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
+ return 1.05 / (L + 0.05);
+}
+
+// sampleColour is the average colour of a small square of the page as
+// drawn, in page CSS pixels.
+async function sampleColour(page, x, y, size = 4) {
+ const img = decodePNG(await page.screenshot({ clip: { x: x - size / 2, y: y - size / 2, width: size, height: size } }));
+ const sum = [0, 0, 0];
+ for (let j = 0; j < img.height; j++) {
+ for (let i = 0; i < img.width; i++) {
+ const c = img.rgb(i, j);
+ sum[0] += c[0]; sum[1] += c[1]; sum[2] += c[2];
+ }
+ }
+ const n = img.width * img.height;
+ return sum.map((v) => Math.round(v / n));
}
// inkCentroid screenshots `box` (page CSS pixels, at the page's own
@@ -1823,7 +1883,8 @@ const run = {
'Space on the focused Mute button presses it once — twice would leave it unmuted');
// Space in the chat composer is typing.
- check(await until(async () => !(await pageA.locator('#chat').isHidden()), 10000), 'A’s chat panel is up');
+ check(await until(() => chatMounted(pageA), 10000), 'A’s chat panel is up');
+ await openChat(pageA);
await pageA.click('#chat-text');
await pageA.keyboard.type('two words');
const typed = await pageA.inputValue('#chat-text');
@@ -3122,8 +3183,11 @@ const run = {
return;
}
- check(await until(async () => !(await pageA.locator('#chat').isHidden()), 10000),
+ check(await until(() => chatMounted(pageA), 10000),
"an admitted member's chat panel is on the page — " + (await diagnose(pageA, 'member A')));
+ check(await pageA.locator('#chat-overlay').isHidden(), 'and closed until asked for, as on the Mac');
+ await openChat(pageA);
+ await openChat(pageB);
check(await pageA.locator('#chat-text').isEditable(), "A's composer is live");
check(await pageB.locator('#chat-send').isEnabled(), "B's Send button is live");
@@ -3136,7 +3200,7 @@ const run = {
check(await waitStatus(pageG, 'Waiting for someone to let you in.', 10000),
'the guest is told it is waiting — status: ' + (await statusText(pageG)));
check((await state(pageG)).admitted === false, 'the guest is waiting, not admitted');
- check(await pageG.locator('#chat').isHidden(),
+ check(!(await chatMounted(pageG)) && await pageG.locator('#chat').isHidden(),
'a guest that has only knocked is shown no chat panel');
const nameA = await selfName(pageA);
@@ -3200,7 +3264,7 @@ const run = {
const silent = await stayed(pageG, 2000, (s) => s.chat.received === 0 && s.admitted === false);
check(silent === null,
'the waiting guest was sent no chat at all — ' + JSON.stringify(silent));
- check(await pageG.locator('#chat').isHidden(),
+ check(!(await chatMounted(pageG)) && await pageG.locator('#chat').isHidden(),
"the waiting guest's chat panel is still not there to put anything in");
check((await chatLog(pageG)).length === 0,
"the waiting guest's log is empty (" + JSON.stringify(await chatLog(pageG)) + ')');
@@ -3230,7 +3294,7 @@ const run = {
return s.admitted && s.rosters >= 1 ? s : false;
}, 25000);
check(admitted, 'the guest was admitted and has its roster — ' + (await diagnose(pageG, 'guest')));
- check(await until(async () => !(await pageG.locator('#chat').isHidden()), 10000),
+ check(await until(() => chatMounted(pageG), 10000),
"the admitted guest's chat panel is on the page — " + (await diagnose(pageG, 'guest')));
const AGAIN = 'and now the guest is here';
@@ -4415,6 +4479,13 @@ const run = {
check(await pageA.locator('#sound-banner').isVisible() && bannerText.startsWith('Click anywhere to turn on sound'),
'the banner says what to do, in words (' + bannerText + ')');
check(await pageA.locator('#sound-banner button').isVisible(), 'with a button a keyboard can reach');
+ check(await pageA.evaluate(() => !!document.querySelector('.calls-window #notices > #sound-banner')),
+ 'in the call window\'s notices, which stay whatever the controls do');
+ const winOut = process.env.CALLS_WINDOW_SNAPSHOTS;
+ if (winOut) {
+ mkdirSync(winOut, { recursive: true });
+ await pageA.screenshot({ path: winOut + '/web-autoplay-banner.png' });
+ }
const refusedTile = await until(async () => {
const s = await state(pageA);
return s.hearing[b] && s.hearing[b].shown === 'blocked' ? s.hearing[b] : false;
@@ -4581,7 +4652,7 @@ const run = {
// The viewer's order: "Move to first" on the last person changes the
// DOM order, and the keyboard stays on a move button.
- await pageA.locator('#layout-grid').click();
+ 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"]');
@@ -4657,9 +4728,11 @@ const run = {
const pageB = await openRoom(ctxB);
const pair = await assertPair(pageA, pageB, 'layout A', 'layout B');
if (!pair) throw new Error('layout call did not connect');
+ await openChat(pageA);
await pageA.locator('#chat-text').fill('Draft kept while chat is closed');
await pageA.locator('#toggle-chat').click();
- check(await pageA.locator('#chat').isHidden(), 'chat collapses');
+ check(await until(() => pageA.locator('#chat').isHidden(), 2000), 'chat collapses');
+ await openChat(pageB);
await pageB.locator('#chat-text').fill('New message while hidden');
await pageB.locator('#chat-send').click();
check(await until(async () => (await pageA.locator('#chat-unread').innerText()).includes('1'), 5000),
@@ -4682,6 +4755,11 @@ const run = {
}), 'the unread live region is outside the panel that gets collapsed');
await pageA.locator('#toggle-chat').focus();
await pageA.keyboard.press('Enter');
+ await pageA.keyboard.press('Escape');
+ check(await until(() => pageA.locator('#chat-overlay').isHidden(), 2000), 'Escape closes chat');
+ check(await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'toggle-chat'),
+ 'and the keyboard goes back to the Chat button');
+ await pageA.keyboard.press('Enter');
check(await pageA.locator('#chat-text').inputValue() === 'Draft kept while chat is closed',
'keyboard reopening preserves the draft');
check(await pageA.locator('#chat-log').innerText().then(x => x.includes('New message while hidden')),
@@ -4693,7 +4771,7 @@ const run = {
for (const colorScheme of ['light', 'dark']) {
await pageA.emulateMedia({colorScheme, reducedMotion: 'reduce'});
for (const mode of ['speaker', 'grid']) {
- await pageA.locator('#layout-' + mode).click();
+ await chooseLayout(pageA, mode);
check(await pageA.locator('#layout-' + mode).getAttribute('aria-pressed') === 'true', mode + ' selection is exposed');
check(await pageA.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
`${width}px ${colorScheme} ${mode} has no horizontal page overflow`);
@@ -4704,7 +4782,7 @@ const run = {
}
}
}
- await pageA.locator('#layout-speaker').click();
+ await chooseLayout(pageA, 'speaker');
await pageA.locator('[data-stage] .calls-tile-note').evaluate(note => { note.textContent = 'Connection recovery'; note.hidden = false; });
check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
'recovery captions disable the self overlay');
@@ -4713,9 +4791,14 @@ const run = {
await pageA.locator('.calls-tile-share').waitFor({state: 'visible'});
check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
'screen share never gets a self-preview overlay');
+ // With the controls hidden: while they show, the filmstrip rises
+ // clear of the bar and over the stage's lower edge, as the Mac's does.
+ await pageA.evaluate(() => window.callsChrome.pin(false));
+ await sleep(400);
const shareBounds = await pageA.locator('.calls-tile-share').boundingBox();
const selfBounds = await pageA.locator('.calls-tile-self').boundingBox();
check(selfBounds.y >= shareBounds.y + shareBounds.height - 1, 'self preview is outside shared screen content');
+ await pageA.evaluate(() => window.callsChrome.pin(null));
if (out) await pageA.screenshot({path: out + '/share-320.png', fullPage: true});
await pageB.locator('#share').click();
await assertPair(pageA, pageB, 'layout A after changes', 'layout B after changes');
@@ -4762,7 +4845,7 @@ const run = {
check(await until(async () => await tileB.locator('.calls-tile-muted').isVisible() &&
await tileB.locator('.calls-tile-off').isVisible(), 10000), 'B shows muted and camera off on A');
await pageA.emulateMedia({ reducedMotion: 'reduce' });
- await pageA.locator('#layout-speaker').click();
+ 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 });
check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000),
@@ -4777,7 +4860,10 @@ const run = {
// The centre line of the row a button is in: the bar's rows
// stretch their buttons to one height, so it is the button's own
// centre (and the bar's, while the bar is one row).
- const glyphs = await pageA.locator('#controls button:visible svg.icon').evaluateAll((svgs) => svgs.map((svg) => {
+ // The glyph each button is showing: a toggle carries both of its
+ // glyphs (the microphone and the slashed one) and shows one.
+ const glyphs = await pageA.locator('#controls button:visible svg.icon').evaluateAll((svgs) => svgs
+ .filter((svg) => svg.getBoundingClientRect().width > 0).map((svg) => {
const r = svg.getBoundingClientRect();
const row = svg.closest('button').getBoundingClientRect();
return { id: svg.closest('button').id || 'share-link', x: r.x, y: r.y, width: r.width, height: r.height,
@@ -4833,6 +4919,414 @@ const run = {
await ctxB.close();
},
+ // The call window (2026-09-28). Paul asked for the web call to be
+ // more like the Mac's: the whole call in a rounded window inset from
+ // the viewport. So: the window is inset with rounded corners on a
+ // desktop and edge to edge on a phone; the page does not scroll; the
+ // controls come with the pointer, go 3 s after it stops or 1.5 s
+ // after it leaves, come back with movement and with Tab, and stay
+ // while the keyboard, the pointer on the bar or an open panel holds
+ // them; chat is a sidebar over the picture, not a column beside it;
+ // the popovers sit over their buttons; the self view floats in a call
+ // of two and can be moved to another corner; the sharing notice and
+ // its Stop sharing stay with the controls hidden; the glass keeps
+ // white at 4.5:1 or better over white, black and busy video, with and
+ // without its blur; and with no script the room still reads sensibly
+ // inside the window. CALLS_WINDOW_SNAPSHOTS=<dir> keeps pictures.
+ async window() {
+ const out = process.env.CALLS_WINDOW_SNAPSHOTS;
+ if (out) mkdirSync(out, { recursive: true });
+ const shot = async (page, name) => {
+ if (out) await page.screenshot({ path: out + '/web-' + name + '.png' });
+ };
+ const geometry = (page) => page.evaluate(() => {
+ const w = document.querySelector('.calls-window');
+ const r = w.getBoundingClientRect();
+ const se = document.scrollingElement;
+ return {
+ left: r.left, top: r.top, right: innerWidth - r.right, bottom: innerHeight - r.bottom,
+ radius: parseFloat(getComputedStyle(w).borderTopLeftRadius),
+ scrolls: se.scrollHeight > innerHeight + 1 || se.scrollWidth > innerWidth + 1,
+ overflow: getComputedStyle(document.body).overflow,
+ };
+ });
+ const inset = (g, lo, hi) => [g.left, g.top, g.right, g.bottom].every((v) => v >= lo - 0.5 && v <= hi + 0.5);
+ const chrome = (page) => page.evaluate(() => window.callsChrome.state());
+ const opacity = (page, sel) => page.evaluate((q) => parseFloat(getComputedStyle(document.querySelector(q)).opacity), sel);
+ const box = (page, sel) => page.locator(sel).first().boundingBox();
+
+ // ── A no-script browser: the room inside the window ──
+ const bare = await newContext('window scriptless', { cookie: cookieA, javaScriptEnabled: false });
+ const still = await bare.newPage();
+ await still.setViewportSize({ width: 1440, height: 900 });
+ await still.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
+ const gs = await geometry(still);
+ check(inset(gs, 16, 24) && gs.radius >= 16 && !gs.scrolls,
+ 'no script: the room is still the inset, rounded window, with no page scroll (' + JSON.stringify(gs) + ')');
+ const bareText = await still.locator('.calls-window').innerText();
+ check(bareText.includes('The call needs JavaScript') && bareText.includes('Back to the holding screen') &&
+ bareText.includes('joining as a member'),
+ 'no script: the title, the standing, the sentence and the way back are inside the window');
+ await still.locator('#call-nav summary').click();
+ const menu = await still.locator('.calls-nav-menu').innerText();
+ check(/Meetings/.test(menu) && /Help/.test(menu) && /Sign out/.test(menu),
+ 'no script: the title\'s menu opens, with Meetings, Help and Sign out (' + JSON.stringify(menu) + ')');
+ check(await still.locator('.calls-scriptless a[href$="/end"]').count() === 1 &&
+ await still.locator('.calls-scriptless form[action$="/share"]').count() === 1,
+ 'no script: Share link and End the meeting are reachable');
+ check(await still.locator('#room').isHidden(), 'no script: nothing pretends to be a call');
+ await shot(still, 'nojs-1440');
+ await bare.close();
+
+ const ctxA = await newContext('window A', { cookie: cookieA });
+ const ctxB = await newContext('window B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ await pageA.setViewportSize({ width: 1440, height: 900 });
+ await pageA.evaluate(() => {
+ localStorage.removeItem('calls.videoLayout');
+ localStorage.removeItem('calls.selfCorner');
+ });
+ const pageB = await openRoom(ctxB);
+ const pair = await assertPair(pageA, pageB, 'window A', 'window B');
+ if (!pair) return;
+
+ // ── The window ──
+ const g = await geometry(pageA);
+ check(inset(g, 16, 24), '1440 px: the window is inset 16–24 px on every side (' + JSON.stringify(g) + ')');
+ check(g.radius >= 16 && g.radius <= 20, '1440 px: with a 16–20 px corner radius (' + g.radius + ')');
+ check(!g.scrolls && g.overflow === 'hidden', '1440 px: and the page does not scroll');
+ const within = await pageA.evaluate(() => {
+ const w = document.querySelector('.calls-window').getBoundingClientRect();
+ return ['#call-top', '#controls', '#tiles > [data-stage]', '#tiles > .calls-tile-self'].map((q) => {
+ const r = document.querySelector(q).getBoundingClientRect();
+ return { q, ok: r.left >= w.left - 0.5 && r.right <= w.right + 0.5 && r.top >= w.top - 0.5 && r.bottom <= w.bottom + 0.5 };
+ });
+ });
+ check(within.every((i) => i.ok), 'the title, the bar, the stage and the self view are inside it (' + JSON.stringify(within) + ')');
+ 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');
+
+ // ── When the controls show ──
+ const w = await box(pageA, '.calls-window');
+ const cx = w.x + w.width / 2;
+ const cy = w.y + w.height / 3;
+ await pageA.mouse.move(cx, cy);
+ await pageA.mouse.move(cx + 10, cy + 10);
+ check((await chrome(pageA)).visible, 'the controls show when the pointer moves');
+ await sleep(2000);
+ check((await chrome(pageA)).visible, 'and are still up 2 s later');
+ const hid = await until(async () => !(await chrome(pageA)).visible, 3000);
+ check(hid, 'they hide 3 s after the pointer stops (' + JSON.stringify(await chrome(pageA)) + ')');
+ 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 pageA.evaluate(() => getComputedStyle(document.getElementById('stage')).cursor === 'none'),
+ 'and the pointer hides with them over the picture');
+ await shot(pageA, '1440-speaker-hidden');
+ await pageA.mouse.move(cx + 40, cy);
+ 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');
+ await shot(pageA, '1440-speaker-shown');
+
+ await pageA.mouse.move(Math.max(2, w.x / 2), Math.max(2, w.y / 2));
+ const leftAt = Date.now();
+ const goneAt = await until(async () => (!(await chrome(pageA)).visible ? Date.now() : false), 3500);
+ check(goneAt && goneAt - leftAt >= 1100 && goneAt - leftAt <= 2400,
+ 'leaving the window hides them 1.5 s later (' + (goneAt && goneAt - leftAt) + ' ms)');
+
+ await pageA.keyboard.press('Tab');
+ check((await chrome(pageA)).visible, 'Tab brings them back');
+ let reached = false;
+ for (let i = 0; i < 16 && !reached; i++) {
+ reached = await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'mute');
+ if (!reached) await pageA.keyboard.press('Tab');
+ }
+ check(reached, 'Tab reaches the microphone button');
+ await sleep(3600);
+ const keyHeld = await chrome(pageA);
+ check(keyHeld.visible && keyHeld.holds.includes('keyboardFocus'),
+ 'keyboard focus in the bar holds them up (' + JSON.stringify(keyHeld) + ')');
+ const tip = await pageA.evaluate(() => {
+ const st = getComputedStyle(document.getElementById('mute'), '::after');
+ return { content: st.content, opacity: parseFloat(st.opacity) };
+ });
+ check(tip.content === '"Mute microphone"' && tip.opacity > 0.9,
+ 'the focused button shows its tooltip (' + JSON.stringify(tip) + ')');
+ check(await pageA.evaluate(() => ['mute', 'camera', 'share', 'devices-toggle', 'layout-toggle', 'toggle-chat', 'leave'].every((id) => {
+ const label = document.querySelector('#' + id + ' .calls-bar-label');
+ return !!label && getComputedStyle(label).clipPath !== 'none';
+ })), 'the bar is glyphs: the words are for a screen reader');
+ await pageA.evaluate(() => document.activeElement.blur());
+
+ const barBox = await box(pageA, '.calls-bar-spacer');
+ await pageA.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2);
+ await sleep(3600);
+ const barHeld = await chrome(pageA);
+ check(barHeld.visible && barHeld.holds.includes('pointerOverControls'),
+ 'the pointer resting on the bar holds them up (' + JSON.stringify(barHeld) + ')');
+ await pageA.mouse.move(cx, cy);
+
+ // ── Chat, as a sidebar over the picture ──
+ const stageBefore = await box(pageA, '#stage');
+ await openChat(pageA);
+ await sleep(400);
+ const chatBox = await box(pageA, '#chat-overlay');
+ const stageAfter = await box(pageA, '#stage');
+ check(Math.abs(stageAfter.width - stageBefore.width) < 1 && Math.abs(stageAfter.height - stageBefore.height) < 1,
+ 'chat leaves the picture its full size (' + JSON.stringify({ stageBefore, stageAfter }) + ')');
+ check(chatBox.x > stageAfter.x + stageAfter.width / 2 && chatBox.x + chatBox.width <= stageAfter.x + stageAfter.width &&
+ chatBox.y >= stageAfter.y, 'chat opens as a sidebar over the right of the stage (' + JSON.stringify(chatBox) + ')');
+ await sleep(3600);
+ const chatHeld = await chrome(pageA);
+ check(chatHeld.visible && chatHeld.holds.includes('menuOpen'), 'an open chat holds the controls up');
+ await shot(pageA, '1440-chat');
+ await pageA.locator('#chat-close').click();
+ check(await until(() => pageA.locator('#chat-overlay').isHidden(), 2000), 'the sidebar closes');
+
+ // ── The popovers ──
+ await pageA.locator('#devices-toggle').click();
+ const dev = await box(pageA, '#room-devices');
+ const devButton = await box(pageA, '#devices-toggle');
+ const bar = await box(pageA, '#controls');
+ check(dev && dev.y + dev.height <= bar.y && devButton.x + devButton.width / 2 >= dev.x &&
+ devButton.x + devButton.width / 2 <= dev.x + dev.width,
+ 'Devices opens above its own button, over the picture (' + JSON.stringify({ dev, devButton }) + ')');
+ await sleep(300);
+ await shot(pageA, '1440-devices');
+ await pageA.keyboard.press('Escape');
+ check(await pageA.locator('#room-devices').isHidden(), 'Escape closes it');
+ await pageA.locator('#layout-toggle').click();
+ const lay = await box(pageA, '#layout-panel');
+ const layButton = await box(pageA, '#layout-toggle');
+ check(lay && lay.y + lay.height <= bar.y && layButton.x + layButton.width / 2 >= lay.x &&
+ layButton.x + layButton.width / 2 <= lay.x + lay.width, 'Layout opens above its own button');
+ await shot(pageA, '1440-layout');
+ await pageA.mouse.click(cx, cy);
+ check(await pageA.locator('#layout-panel').isHidden(), 'a click elsewhere closes it');
+ await pageA.locator('#call-more-toggle').click();
+ const end = pageA.locator('#call-more a');
+ check((await end.innerText()).trim() === 'End meeting for everyone' &&
+ (await end.getAttribute('href')) === '/meetings/' + sqid + '/end' && (await end.getAttribute('target')) === '_blank',
+ 'More holds End meeting for everyone, a link to its own confirm page');
+ await shot(pageA, '1440-more');
+ await pageA.keyboard.press('Escape');
+
+ // ── Speaker and Grid, the controls shown and hidden ──
+ for (const [width, height] of [[1440, 900], [1280, 800]]) {
+ await pageA.setViewportSize({ width, height });
+ const gg = await geometry(pageA);
+ check(inset(gg, 16, 24) && !gg.scrolls, width + ' px: inset window, no scroll (' + JSON.stringify(gg) + ')');
+ for (const mode of ['speaker', 'grid']) {
+ await chooseLayout(pageA, mode);
+ await pageA.mouse.move(cx, cy);
+ for (const shown of [true, false]) {
+ await pageA.evaluate((v) => window.callsChrome.pin(v), shown);
+ await sleep(500);
+ if (mode === 'grid') {
+ const fits = await pageA.evaluate(() => {
+ const t = document.getElementById('tiles');
+ const st = document.getElementById('stage').getBoundingClientRect();
+ return t.scrollHeight <= t.clientHeight + 1 && Array.from(t.children).every((f) => {
+ const r = f.getBoundingClientRect();
+ return r.left >= st.left && r.right <= st.right && r.top >= st.top && r.bottom <= st.bottom;
+ });
+ });
+ check(fits, width + ' px: Grid fits every tile without scrolling');
+ }
+ if (width === 1440 && mode === 'speaker') continue;
+ await shot(pageA, width + '-' + mode + '-' + (shown ? 'shown' : 'hidden'));
+ }
+ }
+ }
+ await pageA.evaluate(() => window.callsChrome.pin(null));
+ await pageA.setViewportSize({ width: 1440, height: 900 });
+ await chooseLayout(pageA, 'speaker');
+
+ // ── The self view, floating in a call of two ──
+ const pip = pageA.locator('#tiles > .calls-tile-self');
+ check(await pip.evaluate((f) => f.classList.contains('calls-tile-pip')), 'in a call of two you float in a corner');
+ await pageA.mouse.move(cx, cy);
+ const p0 = await pip.boundingBox();
+ const w0 = await box(pageA, '.calls-window');
+ const b0 = await box(pageA, '#controls');
+ check(p0.x + p0.width > w0.x + w0.width - 20 && p0.y + p0.height <= b0.y,
+ 'bottom right by default, clear of the bar while it shows (' + JSON.stringify({ p0, b0 }) + ')');
+ await pageA.mouse.move(p0.x + p0.width / 2, p0.y + p0.height / 2);
+ await pageA.mouse.down();
+ for (let i = 1; i <= 12; i++) {
+ await pageA.mouse.move(p0.x + p0.width / 2 - (p0.x - w0.x - 60) * i / 12, p0.y + p0.height / 2 - (p0.y - w0.y - 150) * i / 12);
+ await sleep(16);
+ }
+ await pageA.mouse.up();
+ const corner = await until(async () => {
+ const c = await pageA.evaluate(() => window.callsLayout.state().corner);
+ return c === 'top-left' ? c : false;
+ }, 2000);
+ check(corner, 'dragged, it settles in the corner it was headed for');
+ await sleep(400);
+ const p1 = await pip.boundingBox();
+ const t1 = await box(pageA, '#call-top');
+ check(p1.x < w0.x + 20 && p1.y >= t1.y + t1.height, 'top left, below the title (' + JSON.stringify({ p1, t1 }) + ')');
+ await shot(pageA, '1440-selfview-moved');
+ await pageA.evaluate(() => localStorage.removeItem('calls.selfCorner'));
+
+ // ── Sharing: the notice and its Stop sharing stay ──
+ await pageA.locator('#share').click();
+ const sharing = await until(async () => ((await state(pageA)).sharing ? true : false), 10000);
+ check(sharing, 'A shares its screen');
+ await pageA.evaluate(() => window.callsChrome.pin(false));
+ await sleep(600);
+ const notice = await pageA.evaluate(() => {
+ const n = document.querySelector('#share-notice');
+ const r = n.getBoundingClientRect();
+ let o = 1;
+ for (let el = n; el; el = el.parentElement) o *= parseFloat(getComputedStyle(el).opacity);
+ return { shown: !n.hidden && r.height > 0, opacity: o, stop: !!document.getElementById('share-stop') };
+ });
+ check(notice.shown && notice.opacity > 0.99 && notice.stop,
+ 'with the controls hidden the sharing notice and its Stop sharing stay (' + JSON.stringify(notice) + ')');
+ await shot(pageA, '1440-sharing-hidden');
+ await pageA.evaluate(() => window.callsChrome.pin(true));
+ await sleep(400);
+ await shot(pageA, '1440-sharing-shown');
+ await pageA.locator('#share-stop').click();
+ check(await until(async () => !(await state(pageA)).sharing, 5000), 'Stop sharing stops it');
+
+ // ── The glass: white on it at least 4.5:1 over anything ──
+ await pageA.evaluate(() => window.callsChrome.pin(true));
+ const backdrop = (kind) => pageA.evaluate((k) => {
+ document.getElementById('gate-backdrop')?.remove();
+ if (!k) return;
+ const el = document.createElement(k === 'busy' ? 'canvas' : 'div');
+ el.id = 'gate-backdrop';
+ Object.assign(el.style, { position: 'absolute', inset: '0', zIndex: '4', width: '100%', height: '100%' });
+ if (k === 'busy') {
+ el.width = 480;
+ el.height = 300;
+ const c = el.getContext('2d');
+ let seed = 7;
+ const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
+ for (let y = 0; y < 300; y += 10) {
+ for (let x = 0; x < 480; x += 10) {
+ c.fillStyle = 'rgb(' + Math.floor(rnd() * 256) + ',' + Math.floor(rnd() * 256) + ',' + Math.floor(rnd() * 256) + ')';
+ c.fillRect(x, y, 10, 10);
+ }
+ }
+ } else el.style.background = k;
+ document.querySelector('.calls-window').append(el);
+ }, kind);
+ const surfaces = [
+ ['the bar', async () => { const b = await box(pageA, '.calls-bar-spacer'); return [b.x + b.width / 2, b.y + b.height / 2]; }],
+ // Below the menu button, where the title has no words.
+ ['the title', async () => { const b = await box(pageA, '#call-top'); return [b.x + 19, b.y + b.height - 5]; }],
+ ];
+ const ratios = {};
+ for (const [label, glass] of [['glass', false], ['no blur', true]]) {
+ await pageA.evaluate((off) => document.querySelector('.calls-window').toggleAttribute('data-no-glass', off), glass);
+ for (const kind of ['white', 'black', 'busy']) {
+ await backdrop(kind);
+ await sleep(250);
+ for (const [what, at] of surfaces) {
+ const [x, y] = await at();
+ const colour = await sampleColour(pageA, x, y);
+ const ratio = contrastOnWhite(colour);
+ ratios[label + ' ' + kind + ' ' + what] = Math.round(ratio * 100) / 100;
+ check(ratio >= 4.5, label + ', over ' + kind + ': white on ' + what + ' is ' + ratio.toFixed(2) + ':1 (' + colour + ')');
+ }
+ if (kind === 'white' && !glass) await shot(pageA, '1440-glass-over-white');
+ }
+ }
+ await backdrop('white');
+ for (const id of ['leave']) {
+ const b = await box(pageA, '#' + id);
+ const colour = await sampleColour(pageA, b.x + 7, b.y + b.height / 2, 2);
+ const ratio = contrastOnWhite(colour);
+ ratios[id] = Math.round(ratio * 100) / 100;
+ check(ratio >= 4.5, 'white on ' + id + '\'s capsule is ' + ratio.toFixed(2) + ':1 (' + colour + ')');
+ }
+ await pageA.locator('#mute').click();
+ await sleep(250);
+ {
+ const b = await box(pageA, '#mute');
+ const colour = await sampleColour(pageA, b.x + 5, b.y + b.height / 2, 2);
+ const ratio = contrastOnWhite(colour);
+ ratios.muted = Math.round(ratio * 100) / 100;
+ check(ratio >= 4.5, 'white on the muted capsule is ' + ratio.toFixed(2) + ':1 (' + colour + ')');
+ }
+ await pageA.locator('#mute').click();
+ await backdrop(null);
+ await pageA.evaluate(() => document.querySelector('.calls-window').removeAttribute('data-no-glass'));
+ if (out) writeFileSync(out + '/web-contrast.json', JSON.stringify(ratios, null, 2));
+
+ // ── Narrower: a laptop's narrow window keeps a smaller margin, and a
+ // window too narrow for the bar gets the phone's ──
+ await pageA.evaluate(() => window.callsChrome.pin(true));
+ await pageA.setViewportSize({ width: 900, height: 700 });
+ const gn = await geometry(pageA);
+ check(inset(gn, 8, 16) && !gn.scrolls, '900 px: a smaller margin (' + JSON.stringify(gn) + ')');
+ await shot(pageA, '900-speaker');
+ await pageA.setViewportSize({ width: 560, height: 760 });
+ check(await pageA.locator('#more-controls').isVisible() && await pageA.locator('#leave').isVisible() &&
+ !(await pageA.locator('#mute').isVisible()), '560 px: the bar is More controls and Leave');
+ await shot(pageA, '560-narrow');
+ await pageA.evaluate(() => window.callsChrome.pin(null));
+
+ check(errorsFor('window A') === '', 'no console errors in A (' + errorsFor('window A') + ')');
+ await leaveRoom(pageA);
+ await ctxA.close();
+
+ // ── A phone: edge to edge, More controls and Leave, and the sheet ──
+ const phone = await newContext('window phone', {
+ cookie: cookieA,
+ options: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
+ });
+ const pageP = await openRoom(phone);
+ const phonePair = await assertPair(pageP, pageB, 'phone', 'window B');
+ if (phonePair) {
+ 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 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()),
+ 'phone: More controls on the left, Leave on the right, nothing else on the bar');
+ await pageP.evaluate(() => window.callsChrome.pin(true));
+ await shot(pageP, 'phone-portrait');
+ await pageP.evaluate(() => window.callsChrome.pin(null));
+ await pageP.tap('#more-controls');
+ check(await pageP.locator('#call-sheet[data-open]').isVisible() && await pageP.locator('#mute').isVisible(),
+ 'phone: More controls opens the sheet, with the microphone in it');
+ check(/Microphone/.test(await pageP.locator('#mute').innerText()), 'phone: the sheet\'s buttons say what they are');
+ await sleep(3600);
+ check((await chrome(pageP)).visible, 'phone: an open sheet holds the controls up');
+ await shot(pageP, 'phone-sheet');
+ await pageP.tap('#mute');
+ check((await pageP.locator('#mute').getAttribute('aria-pressed')) === 'true', 'phone: a tap in the sheet mutes');
+ await pageP.tap('#mute');
+ const s0 = await box(pageP, '#stage');
+ await pageP.touchscreen.tap(s0.x + s0.width / 2, s0.y + s0.height / 2);
+ check(!(await pageP.locator('#call-sheet[data-open]').count()), 'phone: a tap elsewhere closes the sheet');
+ await pageP.touchscreen.tap(s0.x + s0.width / 2, s0.y + s0.height / 2);
+ const toggled = (await chrome(pageP)).visible;
+ await pageP.touchscreen.tap(s0.x + s0.width / 2, s0.y + s0.height / 2);
+ check((await chrome(pageP)).visible !== toggled, 'phone: a tap on the picture toggles the controls');
+ await pageP.setViewportSize({ width: 844, height: 390 });
+ await sleep(300);
+ const gl = await geometry(pageP);
+ check(inset(gl, 0, 0) && !gl.scrolls, 'phone on its side: edge to edge (' + JSON.stringify(gl) + ')');
+ await pageP.evaluate(() => window.callsChrome.pin(true));
+ await sleep(300);
+ await shot(pageP, 'phone-landscape');
+ await pageP.evaluate(() => window.callsChrome.pin(null));
+ check(errorsFor('window phone') === '', 'no console errors on the phone (' + errorsFor('window phone') + ')');
+ await leaveRoom(pageP);
+ }
+ await leaveRoom(pageB);
+ await phone.close();
+ await ctxB.close();
+ },
+
async scriptless() {
const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false });
const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.';
diff --git a/test/browser/room-window.test.mjs b/test/browser/room-window.test.mjs
new file mode 100644
index 0000000..c748eb7
--- /dev/null
+++ b/test/browser/room-window.test.mjs
@@ -0,0 +1,153 @@
+// room-window.test.mjs — when the call window's controls show
+// (2026-09-28), gated without a browser.
+//
+// node --test test/browser/room-window.test.mjs
+//
+// The same trick as room-layout.test.mjs: the rule is lifted out of the
+// shipped call-window.js by name, and its constants are held equal to
+// the Mac's ControlsVisibility (CallChrome.swift), so the web and the
+// Mac cannot drift apart in silence. So is Grid's fit (call-layout.js's
+// gridColumns, the Mac's GridFit.columns) and where a dropped self view
+// settles (pipCorner, PiPLayout.corner).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8');
+const windowJS = read('../../internal/calls/static/call-window.js');
+const layoutJS = read('../../internal/calls/static/call-layout.js');
+const swift = read('../../native/apple/Sources/CallsKit/CallChrome.swift');
+const css = read('../../internal/calls/static/calls.css');
+
+function lift(source, name) {
+ const start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not there any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name);
+}
+function jsConst(name) {
+ const m = windowJS.match(new RegExp('const ' + name + ' = ([0-9.]+);'));
+ assert.ok(m, name + ' is not a numeric const in call-window.js any more');
+ return Number(m[1]);
+}
+function swiftConst(name) {
+ const m = swift.match(new RegExp('static let ' + name + ': TimeInterval = ([0-9.]+)'));
+ assert.ok(m, name + ' is not in CallChrome.swift any more');
+ return Number(m[1]);
+}
+
+const CONSTS = ['CONTROLS_POINTER_DELAY', 'CONTROLS_TOUCH_DELAY', 'CONTROLS_EXIT_DELAY']
+ .map((n) => 'const ' + n + ' = ' + jsConst(n) + ';').join('\n');
+// eslint-disable-next-line no-new-func
+const m = new Function(CONSTS + '\n' + ['controlsDelay', 'controlsStart', 'controlsHeld', 'controlsStep']
+ .map((n) => lift(windowJS, n)).join('\n') + '; return { controlsStart, controlsStep, controlsHeld };')();
+// eslint-disable-next-line no-new-func
+const layout = new Function(['gridColumns', 'gridTileWidth', 'gridFit', 'pipCorner']
+ .map((n) => lift(layoutJS, n)).join('\n') + '; return { gridColumns, gridFit, pipCorner };')();
+
+const run = (s, events) => events.reduce((st, [t, ev]) => m.controlsStep(st, ev, t), s);
+
+test('the delays are the Mac\'s', () => {
+ assert.equal(jsConst('CONTROLS_POINTER_DELAY'), swiftConst('pointerDelay'));
+ assert.equal(jsConst('CONTROLS_TOUCH_DELAY'), swiftConst('touchDelay'));
+ assert.equal(jsConst('CONTROLS_EXIT_DELAY'), swiftConst('pointerExitDelay'));
+});
+
+test('the fades are the Mac\'s: 0.35 s going, 0.2 s coming back', () => {
+ const out = css.match(/--calls-fade-out: ([0-9.]+)s;/);
+ const back = css.match(/--calls-fade-in: ([0-9.]+)s;/);
+ assert.ok(out && back, 'calls.css has no fade durations');
+ assert.equal(Number(out[1]), swiftConst('fadeOutDuration'));
+ assert.equal(Number(back[1]), swiftConst('fadeInDuration'));
+});
+
+test('they start visible and go 3 s later', () => {
+ const s = m.controlsStart('pointer', 100);
+ assert.equal(s.visible, true);
+ assert.equal(s.hideAt, 103);
+ assert.equal(m.controlsStep(s, { type: 'tick' }, 102.9).visible, true);
+ assert.equal(m.controlsStep(s, { type: 'tick' }, 103).visible, false);
+});
+
+test('moving the pointer shows them and starts the wait again', () => {
+ const s = run(m.controlsStart('pointer', 0), [[4, { type: 'tick' }], [5, { type: 'move' }]]);
+ assert.equal(s.visible, true);
+ assert.equal(s.hideAt, 8);
+ assert.equal(s.pointerInside, true);
+ assert.equal(run(s, [[7, { type: 'move' }], [9, { type: 'tick' }]]).visible, true);
+ assert.equal(run(s, [[7, { type: 'move' }], [10, { type: 'tick' }]]).visible, false);
+});
+
+test('leaving the window hides them 1.5 s later, never later than they were due', () => {
+ let s = run(m.controlsStart('pointer', 0), [[0.5, { type: 'move' }], [1, { type: 'exit' }]]);
+ assert.equal(s.hideAt, 2.5);
+ assert.equal(s.pointerInside, false);
+ s = run(m.controlsStart('pointer', 0), [[0, { type: 'move' }], [2.5, { type: 'exit' }]]);
+ assert.equal(s.hideAt, 3, 'due at 3 already, so leaving at 2.5 does not put it off to 4');
+});
+
+test('a hold keeps them up; letting go starts the usual wait', () => {
+ let s = run(m.controlsStart('pointer', 0), [[1, { type: 'hold', hold: 'menuOpen', on: true }]]);
+ assert.equal(s.hideAt, null);
+ assert.equal(run(s, [[60, { type: 'tick' }]]).visible, true);
+ s = run(s, [[60, { type: 'hold', hold: 'menuOpen', on: false }]]);
+ assert.equal(s.hideAt, 63);
+ // A hold shows hidden controls.
+ s = run(m.controlsStart('pointer', 0), [[5, { type: 'tick' }]]);
+ assert.equal(s.visible, false);
+ assert.equal(run(s, [[6, { type: 'hold', hold: 'keyboardFocus', on: true }]]).visible, true);
+});
+
+test('leaving the window lets go of the pointer on the bar, but not of other holds', () => {
+ let s = run(m.controlsStart('pointer', 0), [[0, { type: 'move' }], [0, { type: 'hold', hold: 'pointerOverControls', on: true }], [1, { type: 'exit' }]]);
+ assert.equal(m.controlsHeld(s), false);
+ assert.equal(s.hideAt, 2.5);
+ s = run(m.controlsStart('pointer', 0), [[0, { type: 'hold', hold: 'menuOpen', on: true }], [1, { type: 'exit' }]]);
+ assert.equal(s.hideAt, null);
+});
+
+test('a tap toggles, unless they are held', () => {
+ let s = m.controlsStart('touch', 0);
+ s = m.controlsStep(s, { type: 'tap' }, 1);
+ assert.equal(s.visible, false);
+ s = m.controlsStep(s, { type: 'tap' }, 2);
+ assert.equal(s.visible, true);
+ assert.equal(s.hideAt, 5);
+ s = run(s, [[2, { type: 'hold', hold: 'menuOpen', on: true }], [3, { type: 'tap' }]]);
+ assert.equal(s.visible, true, 'a held tap only starts the wait again');
+});
+
+test('activity (Tab, a click) shows them', () => {
+ const s = run(m.controlsStart('pointer', 0), [[4, { type: 'tick' }], [5, { type: 'activity' }]]);
+ assert.equal(s.visible, true);
+ assert.equal(s.hideAt, 8);
+});
+
+test('Grid fits as the Mac\'s GridFit does', () => {
+ // Two 16:9 tiles in a laptop's window: one above the other is larger.
+ assert.equal(layout.gridColumns(2, 1264, 784, 16 / 9, 4), 1);
+ // In a wide, short one, side by side.
+ assert.equal(layout.gridColumns(2, 1600, 500, 16 / 9, 4), 2);
+ assert.equal(layout.gridColumns(4, 1400, 860, 16 / 9, 4), 2);
+ assert.equal(layout.gridColumns(1, 1400, 860, 16 / 9, 4), 1);
+ const small = layout.gridFit(8, 400, 300, 72);
+ assert.equal(small.tileW, 200, 'no tile under 200 wide: it scrolls instead');
+ const fit = layout.gridFit(4, 1400, 860, 72);
+ assert.ok(fit.tileW * fit.columns + (fit.columns - 1) * 4 <= 1392);
+ assert.ok(fit.tileH * fit.rows + (fit.rows - 1) * 4 <= 852);
+ assert.equal(fit.lastRow, 2);
+});
+
+test('a dropped self view settles where it was headed', () => {
+ assert.equal(layout.pipCorner({ x: 100, y: 100 }, null, 1000, 600), 'top-left');
+ 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');
+});