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 {
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
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);
}
body[data-platform="ios"] .drawer {
[data-element-call-root][data-platform="ios"] .drawer {
--border-radius: 10px;
--handle-block-size: 5px;
--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
* 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`
* directly — `body[data-background="gradient"]` and `body[data-platform=…]` in
* `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and
* `Modal.module.css` — and `Initializer.initBeforeReact` writes
* `data-platform` straight onto the body. So anything other than the body will
* be decorated correctly and styled incorrectly, silently. Until those are
* scoped, treat this as preparation rather than a working seam.
* The stylesheets find this element by its `data-element-call-root` attribute,
* which {@link useTheme} sets along with the platform and theme, so they no
* longer depend on it being the body.
*
* What remains body-specific is the standalone page's own furniture: the
* `body` rule in `index.css` still sets the page background and margin, and
* `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
// consumer falls back to the document body. M1 adds one along with the
// component that mounts Element Call into a container.
// consumer falls back to the document body. One arrives with the entry point
// that mounts Element Call into a container.
const RootElementContext = createContext<HTMLElement | null>(null);
/**
+4 -4
View File
@@ -75,7 +75,7 @@ body {
}
@media (min-height: 330px) {
body[data-background="gradient"]::before {
[data-element-call-root][data-background="gradient"]::before {
content: "";
position: fixed;
/* Chromium abruptly fades our images to fully transparent at the edge of
@@ -88,7 +88,7 @@ body {
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-size: max(1440px, 100vw) max(1440px, 100vh);
background-position: center;
@@ -126,11 +126,11 @@ body,
/* 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
font-family. */
body[data-platform="android"] {
[data-element-call-root][data-platform="android"] {
--cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif;
}
body[data-platform="ios"] {
[data-element-call-root][data-platform="ios"] {
--cpd-font-family-sans:
-apple-system, BlinkMacSystemFont, "Inter", sans-serif;
}
-4
View File
@@ -30,7 +30,6 @@ import {
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch";
import { initializeWidget, type WidgetHelpers } from "./widget";
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
setLKLogExtension((level, msg, context) => {
// 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";
import { useTheme } from "./useTheme";
import { platform } from "./Platform";
import { useUrlParams } from "./UrlParams";
import {
type HostBridge,
@@ -88,6 +89,20 @@ describe("useTheme", () => {
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", () => {
renderHook(() => useTheme(), { wrapper });
+9
View File
@@ -9,6 +9,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useUrlParams } from "./UrlParams";
import { useRootElement } from "./RootElementContext";
import { platform } from "./Platform";
import { useHostBridge } from "./HostBridge";
export const useTheme = (): void => {
@@ -28,6 +29,14 @@ export const useTheme = (): void => {
return (): void => subscription.unsubscribe();
}, [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(() => {
// If no theme has been explicitly requested we default to dark
const theme = requestedTheme?.includes("light") ? "light" : "dark";