Find Element Call's root by attribute, not by being the body

Six selectors named `body` directly — the gradient backdrop and the
platform font overrides in index.css, and the iOS adjustments in
AppBar.module.css and Modal.module.css — so they only applied when Element
Call owned the page. A host mounting it into a container would have got an
interface decorated correctly and styled incorrectly, with nothing to show
that anything was wrong.

Mark the root element with data-element-call-root and match on that
instead. Scoping this way keeps the selectors more specific than they were,
rather than less: widening them to a bare [data-platform=…] would have
dropped specificity from (0,1,1) to (0,1,0) and changed which rules win.

The platform attribute moves with them, from the initializer's write onto
document.body to a layout effect on the root, alongside the theme — so it
still lands before anything is painted.

No visual change while Element Call owns the page: the root is the body,
which now carries the attribute, so every rewritten selector matches the
element it always did.
This commit is contained in:
Valere
2026-09-03 12:55:52 +02:00
parent 1bfb018d90
commit 856e055d30
7 changed files with 40 additions and 19 deletions
+1 -1
View File
@@ -131,7 +131,7 @@
} }
} }
body[data-platform="ios"] { [data-element-call-root][data-platform="ios"] {
.bar > header { .bar > header {
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x); grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
grid-template-areas: "primaryButton title secondaryButton"; grid-template-areas: "primaryButton title secondaryButton";
+1 -1
View File
@@ -48,7 +48,7 @@ Please see LICENSE in the repository root for full details.
--handle-inset-block-end: var(--cpd-space-4x); --handle-inset-block-end: var(--cpd-space-4x);
} }
body[data-platform="ios"] .drawer { [data-element-call-root][data-platform="ios"] .drawer {
--border-radius: 10px; --border-radius: 10px;
--handle-block-size: 5px; --handle-block-size: 5px;
--handle-inline-size: 36px; --handle-inline-size: 36px;
+10 -9
View File
@@ -16,17 +16,18 @@ import { createContext, use } from "react";
* that when embedded in a host application it becomes the container the host * that when embedded in a host application it becomes the container the host
* mounted it into, so that Element Call does not reach outside its own subtree. * mounted it into, so that Element Call does not reach outside its own subtree.
* *
* That intent is not yet achievable: several selectors still name `body` * The stylesheets find this element by its `data-element-call-root` attribute,
* directly — `body[data-background="gradient"]` and `body[data-platform=…]` in * which {@link useTheme} sets along with the platform and theme, so they no
* `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and * longer depend on it being the body.
* `Modal.module.css` — and `Initializer.initBeforeReact` writes *
* `data-platform` straight onto the body. So anything other than the body will * What remains body-specific is the standalone page's own furniture: the
* be decorated correctly and styled incorrectly, silently. Until those are * `body` rule in `index.css` still sets the page background and margin, and
* scoped, treat this as preparation rather than a working seam. * `index.html` starts the body hidden with `no-theme` until the theme lands.
* Neither applies when a host mounts Element Call into a container of its own.
*/ */
// No provider is exported yet: nothing supplies a root element, so every // No provider is exported yet: nothing supplies a root element, so every
// consumer falls back to the document body. M1 adds one along with the // consumer falls back to the document body. One arrives with the entry point
// component that mounts Element Call into a container. // that mounts Element Call into a container.
const RootElementContext = createContext<HTMLElement | null>(null); const RootElementContext = createContext<HTMLElement | null>(null);
/** /**
+4 -4
View File
@@ -75,7 +75,7 @@ body {
} }
@media (min-height: 330px) { @media (min-height: 330px) {
body[data-background="gradient"]::before { [data-element-call-root][data-background="gradient"]::before {
content: ""; content: "";
position: fixed; position: fixed;
/* Chromium abruptly fades our images to fully transparent at the edge of /* Chromium abruptly fades our images to fully transparent at the edge of
@@ -88,7 +88,7 @@ body {
background-repeat: no-repeat; background-repeat: no-repeat;
} }
body[data-background="gradient"][data-platform="desktop"]::before { [data-element-call-root][data-background="gradient"][data-platform="desktop"]::before {
background-image: url("graphics/desktop-gradient.png"); background-image: url("graphics/desktop-gradient.png");
background-size: max(1440px, 100vw) max(1440px, 100vh); background-size: max(1440px, 100vw) max(1440px, 100vh);
background-position: center; background-position: center;
@@ -126,11 +126,11 @@ body,
/* On Android and iOS, prefer native system fonts. The global.css file of /* On Android and iOS, prefer native system fonts. The global.css file of
Compound Web is where these variables ultimately get consumed to set the page's Compound Web is where these variables ultimately get consumed to set the page's
font-family. */ font-family. */
body[data-platform="android"] { [data-element-call-root][data-platform="android"] {
--cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif; --cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif;
} }
body[data-platform="ios"] { [data-element-call-root][data-platform="ios"] {
--cpd-font-family-sans: --cpd-font-family-sans:
-apple-system, BlinkMacSystemFont, "Inter", sans-serif; -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
} }
-4
View File
@@ -30,7 +30,6 @@ import {
import { getUrlParams } from "./UrlParams"; import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings"; import { seedSettingsFromConfig } from "./settings/settings";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch"; import { isFailure } from "./utils/fetch";
import { initializeWidget, type WidgetHelpers } from "./widget"; import { initializeWidget, type WidgetHelpers } from "./widget";
import { enableExtendedLivekitLogs } from "./settings/settings.ts"; import { enableExtendedLivekitLogs } from "./settings/settings.ts";
@@ -229,9 +228,6 @@ export class Initializer {
); );
} }
// Add the platform to the DOM, so CSS can query it
document.body.setAttribute("data-platform", platform);
// livekit logging configuration // livekit logging configuration
setLKLogExtension((level, msg, context) => { setLKLogExtension((level, msg, context) => {
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read // we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
+15
View File
@@ -19,6 +19,7 @@ import {
} from "vitest"; } from "vitest";
import { useTheme } from "./useTheme"; import { useTheme } from "./useTheme";
import { platform } from "./Platform";
import { useUrlParams } from "./UrlParams"; import { useUrlParams } from "./UrlParams";
import { import {
type HostBridge, type HostBridge,
@@ -88,6 +89,20 @@ describe("useTheme", () => {
expect(originalClassList.add).not.toHaveBeenCalled(); expect(originalClassList.add).not.toHaveBeenCalled();
}); });
test("marks the element as Element Call's root, for the stylesheets", () => {
renderHook(() => useTheme(), { wrapper });
// The stylesheets find the root by this rather than by naming `body`, so
// that they still apply when Element Call is mounted into a container
expect(document.body.hasAttribute("data-element-call-root")).toBe(true);
});
test("records the platform on the root, for the stylesheets", () => {
renderHook(() => useTheme(), { wrapper });
expect(document.body.getAttribute("data-platform")).toBe(platform);
});
test("theme changes in response to host requests", () => { test("theme changes in response to host requests", () => {
renderHook(() => useTheme(), { wrapper }); renderHook(() => useTheme(), { wrapper });
+9
View File
@@ -9,6 +9,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useUrlParams } from "./UrlParams"; import { useUrlParams } from "./UrlParams";
import { useRootElement } from "./RootElementContext"; import { useRootElement } from "./RootElementContext";
import { platform } from "./Platform";
import { useHostBridge } from "./HostBridge"; import { useHostBridge } from "./HostBridge";
export const useTheme = (): void => { export const useTheme = (): void => {
@@ -28,6 +29,14 @@ export const useTheme = (): void => {
return (): void => subscription.unsubscribe(); return (): void => subscription.unsubscribe();
}, [hostBridge]); }, [hostBridge]);
// Mark the element as Element Call's root and record the platform on it, so
// that the stylesheets can find both without naming `body`. A layout effect,
// like the theme below, so that it lands before anything is painted.
useLayoutEffect(() => {
rootElement.setAttribute("data-element-call-root", "");
rootElement.setAttribute("data-platform", platform);
}, [rootElement]);
useLayoutEffect(() => { useLayoutEffect(() => {
// If no theme has been explicitly requested we default to dark // If no theme has been explicitly requested we default to dark
const theme = requestedTheme?.includes("light") ? "light" : "dark"; const theme = requestedTheme?.includes("light") ? "light" : "dark";