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>
12 files changed,
+2515
−588
Makefile+1 −0internal/calls/icons/icons.go+12 −0internal/calls/static/call-layout.js+177 −8internal/calls/static/call-window.js+384 −0internal/calls/static/calls.css+1034 −484internal/calls/static/room.js+11 −3internal/calls/templates/layout.html+5 −0internal/calls/templates/room.html+144 −75internal/callstest/browser_test.go+1 −0internal/callstest/room_test.go+85 −4test/browser/call.mjs+508 −14test/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'); |
| +}); |