From 365204d04ca99169ca21c8bb393024196292db76 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Mon, 7 Sep 2026 14:50:36 +0200 Subject: [PATCH] fix media queries in component based element call. --- README.md | 3 + playwright/component/component-call.spec.ts | 82 ++++++++++++++++++++- src/AppBar.module.css | 4 +- src/Header.module.css | 2 +- src/base.css | 12 ++- src/button/ReactionToggleButton.module.css | 2 +- src/components/CallFooter.module.css | 16 ++-- src/grid/OneOnOneMobileLayout.module.css | 2 +- src/grid/SpotlightExpandedLayout.module.css | 2 +- src/room/CallEndedView.module.css | 2 +- src/room/InCallView.module.css | 2 +- src/room/LobbyView.module.css | 4 +- src/room/VideoPreview.module.css | 2 +- 13 files changed, 114 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0732ff419..273846afc 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,9 @@ logged along the bottom. The call lays itself out for the size of the element it is mounted in, not the window: a host that shrinks the container to a corner of its page gets the picture-in-picture layout, just as a host that shrank the whole iframe used to. +The breakpoints in Element Call's stylesheets are `@container element-call` +queries against its root element for the same reason; for the standalone app +the root is the page, so they mean what the media queries they replaced did. The component's stylesheet is confined to the element it is mounted in: the build rewrites every selector so that it matches only Element Call's root or diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 97a002675..784158adb 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, type Locator, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; +import { SpaHelpers } from "../spa-helpers.ts"; /** * Element Call embedded as a React component, driven through the development @@ -165,3 +166,82 @@ test("lays itself out for the space it is given, not the page", async ({ await resize(900, 700); await expect(call).not.toHaveAttribute("data-layout", "pip"); }); + +/** + * The shape of a call at whatever size it has been given: the layout it chose, + * how much of the height the tile and the footer take, and which controls the + * footer shows. Two calls with the same shape look the same, participants aside. + */ +async function callShape(scope: Page | Locator): Promise<{ + layout: string | null; + tileHeight: number; + footerHeight: number; + buttons: (string | null)[]; +}> { + const call = scope.locator("[data-layout]"); + const footer = scope.getByTestId("footer-container"); + await expect(footer).toBeVisible(); + const tile = scope.getByTestId("videoTile").first(); + await expect(tile).toBeVisible(); + const tileBox = (await tile.boundingBox())!; + const footerBox = (await footer.boundingBox())!; + const buttons = await footer + .getByRole("button") + .filter({ visible: true }) + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("aria-label")), + ); + return { + layout: await call.getAttribute("data-layout"), + tileHeight: Math.round(tileBox.height), + footerHeight: Math.round(footerBox.height), + buttons, + }; +} + +test("looks the same in a small container as in a small window", async ({ + page, + browser, +}) => { + // Two calls to set up, one of them through the harness's two logins + test.setTimeout(240_000); + const size = { width: 300, height: 300 }; + + // The reference is Element Call owning a window of that size, which is what + // a mobile app's webview or a browser's picture-in-picture gives it, and + // what its small-window styling was written for. + const referenceContext = await browser.newContext({ + viewport: size, + ignoreHTTPSErrors: true, + permissions: ["microphone", "camera"], + }); + const referencePage = await referenceContext.newPage(); + await referencePage.goto("/"); + await SpaHelpers.createCall(referencePage, "Reference", "smallwindow", true); + const reference = await callShape(referencePage); + await referencePage.screenshot({ + path: test.info().outputPath("small-window.png"), + }); + + // The component gets a container of that size, in a window that is far larger + const { username, roomId } = await createUserAndRoom("smallcontainer"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + await container.evaluate((element, { width, height }) => { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + }, size); + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 }); + const component = await callShape(pane); + await container.screenshot({ + path: test.info().outputPath("small-container.png"), + }); + await referenceContext.close(); + + // The breakpoints in Element Call's stylesheets are container queries, so a + // small container gets the compact footer a small window does, rather than + // the full-width one the window's own size would call for + expect(component).toEqual(reference); +}); diff --git a/src/AppBar.module.css b/src/AppBar.module.css index faf2b0bf1..1b234a223 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -68,7 +68,7 @@ } /* Hide everything but the subtitle in small windows */ -@media (max-height: 450px) { +@container element-call (max-height: 450px) { .bar { display: none; } @@ -166,7 +166,7 @@ } /* Hide everything but the subtitle in small windows */ - @media (max-height: 450px) { + @container element-call (max-height: 450px) { .bar:has(.subtitle) > header { grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto); grid-template-areas: "." "subtitle"; diff --git a/src/Header.module.css b/src/Header.module.css index f82f5fbd6..07a3e7242 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -107,7 +107,7 @@ Please see LICENSE in the repository root for full details. gap: var(--cpd-space-1-5x); } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .headerLogo, .leftNav.hideMobile, .rightNav.hideMobile { diff --git a/src/base.css b/src/base.css index ae09f4921..d31622fbf 100644 --- a/src/base.css +++ b/src/base.css @@ -33,7 +33,8 @@ the app and for the component build. */ @import url("@fontsource/inconsolata/700.css"); @import url("normalize.css/normalize.css") layer(normalize); -@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") +layer(compound); @import url("@vector-im/compound-web/dist/style.css") layer(compound.components); :root { @@ -76,6 +77,15 @@ the app and for the component build. */ --video-tile-background: var(--cpd-color-bg-subtle-secondary); } +/* The breakpoints in Element Call's stylesheets are container queries against +this element rather than media queries against the viewport. For the standalone +app the two are the same thing, since the root is the page; for a host that +embeds Element Call in a corner of its own page they are not, and it is the +corner that the layout has to fit. */ +[data-element-call-root] { + container: element-call / size; +} + .cpd-theme-dark { --cpd-color-border-accent: var(--cpd-color-green-1100); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 705d4d9ed..3be0fd359 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -10,7 +10,7 @@ width: fit-content; } -@media (max-width: 420px) { +@container element-call (max-width: 420px) { .reactionPopupMenu { --reaction-button-padding: 8px; --reaction-button-fontsize: 16px; diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index d919b33eb..e006a5f7d 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details. } /*First hide the logo*/ -@media (max-width: 750px) { +@container element-call (max-width: 750px) { .logo { display: none; } @@ -94,7 +94,7 @@ Please see LICENSE in the repository root for full details. With the logo hidden >500px is enough space to show overflow, buttons, layout. Once we exceed 500 we hide everything except the buttons. */ -@media (max-width: 500px) { +@container element-call (max-width: 500px) { .footer { grid-template-areas: "buttons buttons buttons"; } @@ -115,27 +115,27 @@ Once we exceed 500 we hide everything except the buttons. } } -@media (max-height: 800px) { +@container element-call (max-height: 800px) { .footer { padding-block: var(--cpd-space-8x) calc(env(safe-area-inset-bottom) + var(--cpd-space-8x)); } } -@media (max-height: 400px) { +@container element-call (max-height: 400px) { .footer { padding-block: var(--cpd-space-4x) calc(env(safe-area-inset-bottom) + var(--cpd-space-4x)); } } -@media (max-width: 370px) { +@container element-call (max-width: 370px) { .shareScreen { display: none; } /* PIP custom css */ - @media (max-height: 400px) { + @container element-call (max-height: 400px) { .shareScreen { display: flex; } @@ -148,13 +148,13 @@ Once we exceed 500 we hide everything except the buttons. } } -@media (max-width: 320px) { +@container element-call (max-width: 320px) { .raiseHand { display: none; } } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .buttons { gap: var(--cpd-space-4x); } diff --git a/src/grid/OneOnOneMobileLayout.module.css b/src/grid/OneOnOneMobileLayout.module.css index e781726c9..d07520f55 100644 --- a/src/grid/OneOnOneMobileLayout.module.css +++ b/src/grid/OneOnOneMobileLayout.module.css @@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details. block-size: 140px; } -@media (max-width: 600px) { +@container element-call (max-width: 600px) { /* Give the PiP a portrait aspect ratio */ .pip[data-size="sm"] { inline-size: 88px; diff --git a/src/grid/SpotlightExpandedLayout.module.css b/src/grid/SpotlightExpandedLayout.module.css index d765c6fce..570b62662 100644 --- a/src/grid/SpotlightExpandedLayout.module.css +++ b/src/grid/SpotlightExpandedLayout.module.css @@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details. var(--content-inset-left); } -@media (min-width: 600px) { +@container element-call (min-width: 600px) { .pip { inline-size: 180px; block-size: 135px; diff --git a/src/room/CallEndedView.module.css b/src/room/CallEndedView.module.css index e62e93d0c..7b2dbee06 100644 --- a/src/room/CallEndedView.module.css +++ b/src/room/CallEndedView.module.css @@ -74,7 +74,7 @@ Please see LICENSE in the repository root for full details. margin-bottom: 44px; } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .logo { display: none; } diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 736a915a2..3c393cd6d 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -75,7 +75,7 @@ spotlight tile is maximised and displaying video, apply a gradient background. * background: none; } -@media (max-width: 320px) { +@container element-call (max-width: 320px) { .invite { display: none; } diff --git a/src/room/LobbyView.module.css b/src/room/LobbyView.module.css index b66d483cc..b112cdf20 100644 --- a/src/room/LobbyView.module.css +++ b/src/room/LobbyView.module.css @@ -28,13 +28,13 @@ Please see LICENSE in the repository root for full details. color: var(--cpd-color-theme-primary) !important; } -@media (max-width: 500px) { +@container element-call (max-width: 500px) { .join { width: 100%; } } -@media (min-height: 650px) { +@container element-call (min-height: 650px) { .content { gap: var(--cpd-space-10x); } diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index 67eae10bb..44c43faf2 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -71,7 +71,7 @@ video.mirror { ); } -@media (max-width: 550px) { +@container element-call (max-width: 550px) { .preview { margin-inline: 0; border-radius: 0;