oficina / meet Public

The room lays its tiles out before the next frame; a browser chrome scenario

call-layout.js laid out a task after the tiles changed, so a frame
could paint a lone self tile at filmstrip size before it filled the
stage: CLS 0.43-0.80 on some joins (0.4609 in the new scenario on
origin/main). It now lays out at the first of that task and the next
animation frame. The new 'chrome' scenario measures the holding screen
(0.0291 on main), the join and eight room loads at 1440, and reads the
room menu's words; signedout now expects the way back in to return to
the meeting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev fd53ffaf402144b0edc287abc998534872d02e38 parent 114a90a
4 files changed, +99 −6
  • internal/calls/static/call-layout.js +20 −5
  • internal/calls/static/calls-shell.css +3 −0
  • internal/callstest/browser_test.go +1 −0
  • test/browser/call.mjs +75 −1
diff --git a/internal/calls/static/call-layout.js b/internal/calls/static/call-layout.js
index 4966c29..ca1411c 100644
--- a/internal/calls/static/call-layout.js
+++ b/internal/calls/static/call-layout.js
@@ -698,13 +698,28 @@
// A task later rather than at once: room.js takes the tiles down inside
// its own pagehide listener, and the observer would run between that
// and ours; by the time a timer fires, `leaving` has been set.
- let pending = 0;
+ //
+ // And never later than the next frame: a task alone let the browser
+ // paint the tiles once in their unplaced places first — a lone self
+ // tile drawn at filmstrip size, then the whole stage — which was a
+ // layout shift of 0.43 to 0.80 on joining (shared chrome R2, "the room
+ // layout reserved up front"). Whichever of the timer and the frame
+ // comes first lays out; both still come after the pagehide listeners.
+ let pending = false;
+ let pendingTimer = 0;
+ let pendingFrame = 0;
+ const settle = () => {
+ if (!pending) return;
+ pending = false;
+ clearTimeout(pendingTimer);
+ cancelAnimationFrame(pendingFrame);
+ if (!drag || !drag.moving) layout();
+ };
new MutationObserver(() => {
if (pending || (drag && drag.moving)) return;
- pending = setTimeout(() => {
- pending = 0;
- if (!drag || !drag.moving) layout();
- }, 0);
+ pending = true;
+ pendingTimer = setTimeout(settle, 0);
+ pendingFrame = requestAnimationFrame(settle);
})
.observe(tiles, {childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'data-speaker']});
new MutationObserver(chatState).observe(chat, {attributes: true, attributeFilter: ['hidden']});
diff --git a/internal/calls/static/calls-shell.css b/internal/calls/static/calls-shell.css
index ce86e71..91f71f0 100644
--- a/internal/calls/static/calls-shell.css
+++ b/internal/calls/static/calls-shell.css
@@ -10,6 +10,9 @@ body { background: var(--rst-bg); }
so its hover is square (§11), and it sits above the row's
whole-row link overlay. */
[rst-row] > [rst-row-menu] { position: relative; z-index: 1; flex: none; }
+/* The open one above the rows after it, whose own ⋯ would otherwise
+ paint through its panel (each ⋯ is a stacking context at 1). */
+[rst-row] > [rst-row-menu][open] { z-index: 2; }
[rst-row] > [rst-row-menu] > summary { inline-size: 2rem; block-size: 2rem; border-radius: 0; color: var(--rst-text-muted); }
.calls-row-menu { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); }
.calls-row-title { color: var(--rst-text); font-weight: 600; }
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index 7a85ffd..e3ca30c 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -84,6 +84,7 @@ var scenarios = []string{
"layout",
"glyphs",
"window",
+ "chrome",
"devices",
"audiotest",
"call",
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 59058f8..ea14e9e 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -4330,7 +4330,8 @@ const run = {
check(await waitStatus(page, 'You have been signed out.', 10000),
'the signed-out room says so — status: ' + (await statusText(page)));
const href = await page.locator('#status a').first().getAttribute('href');
- check(href === '/signin', 'the signed-out room offers the way back in (' + href + ')');
+ // Back in, and back to this meeting's holding screen (spec §9.4).
+ check(href === '/signin?return_to=' + encodeURIComponent('/m/' + sqid + '/join'), 'the signed-out room offers the way back in, to this meeting (' + href + ')');
await ctx.close();
@@ -4974,6 +4975,79 @@ const run = {
// 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.
+ // Shared chrome R2 (estilo spec/shared-chrome): the holding screen and
+ // the room hold still while they settle, and the room's title menu
+ // speaks the account menu's words. The room used to paint a lone self
+ // tile at filmstrip size and then fill the stage with it, a layout
+ // shift of 0.43–0.80 on some joins, so the room is loaded eight times.
+ async chrome() {
+ // The audit's own viewport, where both shifts were measured.
+ const ctx = await newContext('member A', { cookie: cookieA, options: { viewport: { width: 1440, height: 900 } } });
+ await ctx.addInitScript(() => {
+ window.__cls = 0;
+ window.__shifts = [];
+ new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) {
+ if (e.hadRecentInput) continue;
+ window.__cls += e.value;
+ window.__shifts.push({ v: +e.value.toFixed(4), t: Math.round(e.startTime), src: (e.sources || []).map((s) => (s.node && (s.node.id || s.node.className)) || '?').join(',') });
+ }
+ }).observe({ type: 'layout-shift', buffered: true });
+ });
+ const page = await ctx.newPage();
+ const cls = () => page.evaluate(() => ({ cls: window.__cls, shifts: window.__shifts }));
+
+ await page.goto(origin + '/m/' + sqid + '/join');
+ const ready = await until(async () => await page.locator('#join').isEnabled(), 15000);
+ check(ready, 'the holding screen is ready — status: ' + (await page.locator('#status').textContent()));
+ await new Promise((r) => setTimeout(r, 1000));
+ const holding = await cls();
+ check(holding.cls < 0.01, 'the holding screen holds still while its devices arrive (CLS ' + holding.cls.toFixed(4) + ' ' + JSON.stringify(holding.shifts) + ')');
+ check(await page.locator('header[estilo-bar]').count() === 1, 'the holding screen wears the estilo bar');
+
+ await Promise.all([page.waitForURL('**/m/' + sqid), page.click('#join')]);
+ await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
+ check(await until(async () => (await state(page)).admitted, 20000), 'joined from the holding screen');
+ await new Promise((r) => setTimeout(r, 1500));
+ const joined = await cls();
+ check(joined.cls < 0.01, 'holding screen → room holds still (CLS ' + joined.cls.toFixed(4) + ' ' + JSON.stringify(joined.shifts) + ')');
+
+ // The title menu: the account menu's order and words.
+ await page.mouse.move(200, 200);
+ await page.click('#call-nav > summary');
+ const menu = (await page.locator('.calls-nav-menu').innerText()).split('\n').map((l) => l.trim()).filter(Boolean);
+ const want = ['Signed in as', 'Meetings', 'Settings', 'Help', 'Open source', 'Sign out'];
+ const order = want.map((w) => menu.indexOf(w));
+ check(order.every((i, n) => i >= 0 && (n === 0 || i > order[n - 1])), 'the room menu reads ' + want.join(' / ') + ' in that order (' + JSON.stringify(menu) + ')');
+ check(await page.getByText('Sign out', { exact: true }).count() === 1, 'the room has exactly one Sign out');
+ const onTop = await page.evaluate(() => {
+ const m = document.querySelector('.calls-nav-menu');
+ const r = m.getBoundingClientRect();
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 12);
+ return !!hit && m.contains(hit);
+ });
+ check(onTop, 'the open title menu is above the notices (r3 audit C3)');
+ await page.click('#call-nav > summary');
+ await leaveRoom(page);
+
+ // The room loaded straight from the list, eight times: the shift
+ // came on some joins and not others.
+ let worst = { cls: 0, shifts: [] };
+ for (let i = 0; i < 8; i++) {
+ await page.goto(origin + '/');
+ await page.goto(origin + '/m/' + sqid);
+ await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
+ await until(async () => (await state(page)).admitted, 20000);
+ await new Promise((r) => setTimeout(r, 1200));
+ const c = await cls();
+ if (c.cls >= worst.cls) worst = c;
+ await leaveRoom(page);
+ }
+ check(worst.cls < 0.01, 'the room holds still as its tiles arrive, eight loads from the list (worst CLS ' + worst.cls.toFixed(4) + ' ' + JSON.stringify(worst.shifts) + ')');
+ check(errorsFor('member A') === '', 'no console errors (' + errorsFor('member A') + ')');
+ await ctx.close();
+ },
+
async window() {
const out = process.env.CALLS_WINDOW_SNAPSHOTS;
if (out) mkdirSync(out, { recursive: true });