de-globalise styles

This commit is contained in:
Timo K.
2026-09-04 13:51:47 +02:00
parent ace78de749
commit f5458bb03e
13 changed files with 416 additions and 66 deletions
+6
View File
@@ -230,6 +230,12 @@ Element Call behaves when it does not own the page — the size it is given,
whether it stays inside its container, and what it says to its host, which is whether it stays inside its container, and what it says to its host, which is
logged along the bottom. logged along the bottom.
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
what is inside it, with `html`, `body` and `:root` standing for that root (see
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
and Element Call brings its own fonts and design tokens along.
### Backend ### Backend
A docker compose file `docker-compose-dev.yml` is provided to start the A docker compose file `docker-compose-dev.yml` is provided to start the
+112
View File
@@ -0,0 +1,112 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import postcss from "postcss";
import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot";
const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
const isRoot = `:where(${ROOT_SELECTOR})`;
async function scope(css: string, file = "base.css"): Promise<string> {
const result = await postcss([scopeStylesToRoot()]).process(css, {
from: file,
});
return result.css;
}
describe("scopeStylesToRoot", () => {
it("makes the root stand in for the document", async () => {
expect(await scope("html { line-height: 1.15 }")).toBe(
`${isRoot} { line-height: 1.15 }`,
);
expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`);
expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`);
expect(await scope("body.no-scroll-body { position: fixed }")).toBe(
`${isRoot}.no-scroll-body { position: fixed }`,
);
expect(await scope("body .x { color: red }")).toBe(
`${isRoot} .x { color: red }`,
);
});
it("collapses selectors that all became the root", async () => {
expect(await scope("html, body, input { font: inherit }")).toBe(
`${isRoot},input${inRoot} { font: inherit }`,
);
});
it("confines everything else to the root and what is inside it", async () => {
expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`);
expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe(
`.cpd-theme-dark${inRoot} { --a: 1 }`,
);
expect(await scope("* { box-sizing: border-box }")).toBe(
`*${inRoot} { box-sizing: border-box }`,
);
expect(await scope(".a > .b + .c { color: red }")).toBe(
`.a>.b+.c${inRoot} { color: red }`,
);
});
it("keeps pseudo-elements last", async () => {
expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe(
`button${inRoot}::-moz-focus-inner { border: 0 }`,
);
expect(await scope(".a .b:hover::after { content: '' }")).toBe(
`.a .b:hover${inRoot}::after { content: '' }`,
);
expect(await scope("p:first-letter { color: red }")).toBe(
`p${inRoot}:first-letter { color: red }`,
);
});
it("leaves alone what already names the root", async () => {
const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`;
expect(await scope(css)).toBe(css);
});
it("reaches into layers and media queries", async () => {
expect(
await scope(
"@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }",
),
).toBe(
`@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`,
);
});
it("does not touch keyframes or nested rules", async () => {
expect(
await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"),
).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }");
expect(
await scope(
".a { color: red; &:hover { color: blue } .b { color: green } }",
),
).toBe(
`.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`,
);
});
it("only touches the bare selectors of a CSS module", async () => {
const file = "Settings.module.css";
expect(await scope("pre { font-size: 1px }", file)).toBe(
`pre${inRoot} { font-size: 1px }`,
);
expect(await scope(".modal pre { font-size: 1px }", file)).toBe(
`.modal pre${inRoot} { font-size: 1px }`,
);
expect(await scope(".box_abc12 { border: 0 }", file)).toBe(
".box_abc12 { border: 0 }",
);
expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe(
".a .b_abc12:hover { border: 0 }",
);
});
});
+161
View File
@@ -0,0 +1,161 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type AtRule,
type Container,
type Document,
type Plugin,
type Rule,
} from "postcss";
import selectorParser, {
type Node,
type Pseudo,
type Selector,
} from "postcss-selector-parser";
/**
* How the stylesheets find Element Call's root element. The attribute is put
* there by `useTheme`, on the container the host gives the component.
*/
export const ROOT_SELECTOR = "[data-element-call-root]";
// Both are `:where()`, which has no specificity of its own, so the rules keep
// exactly the weight they had before being scoped and nothing in Element Call's
// cascade changes — only where it applies.
//
// The root, or anything inside it. Appended to the element a rule is about,
// rather than prepended to the whole selector, so that a rule about the root
// itself (its theme class, say) still matches.
const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
// The root itself, standing in for the document.
const IS_ROOT = `:where(${ROOT_SELECTOR})`;
/**
* Confines a stylesheet to Element Call's root element, for the build of
* Element Call as a component.
*
* As a page of its own, Element Call can style the document: normalize.css and
* Compound speak of `html`, `body` and bare elements, and the design tokens are
* declared on `:root`. Embedded in a host, all of that would land on the host's
* document too. This rewrites every selector so that it matches only the root
* or its descendants:
*
* - `html`, `body` and `:root` become the root element, which is what stands in
* for the document inside a host.
* - Everything else keeps its selector and gains `:where([data-element-call-root],
* [data-element-call-root] *)` on the element it styles.
* - Selectors that already name the root are left alone, as are keyframe
* selectors and rules nested inside another rule, which are relative to it.
*
* CSS modules are scoped by their class names already, so only their selectors
* that would match by element alone — `pre` rather than `.pre` — are touched.
*
* The root's fonts and design tokens are still inherited by everything inside
* it, the way they were from `body` and `:root`, and `@font-face` declarations
* stay global, which they are by nature.
*/
export function scopeStylesToRoot(): Plugin {
return {
postcssPlugin: "element-call-scope-styles-to-root",
Once(root) {
const isModule =
root.source?.input.file?.endsWith(".module.css") ?? false;
root.walkRules((rule) => {
if (isRelative(rule)) return;
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
rule.selector,
{ lossless: false },
);
});
},
};
}
/** Whether a rule's selectors are relative to something other than the document. */
function isRelative(rule: Rule): boolean {
let parent: Container | Document | undefined = rule.parent;
while (parent !== undefined) {
if (parent.type === "rule") return true;
if (parent.type === "atrule") {
const { name } = parent as AtRule;
if (name.endsWith("keyframes") || name === "page") return true;
}
parent = parent.parent;
}
return false;
}
const processor = (isModule: boolean): ReturnType<typeof selectorParser> =>
selectorParser((selectors) => {
selectors.each((selector) => {
scopeSelector(selector, isModule);
});
// Mapping `html, body` onto the root leaves the same selector twice
const seen = new Set<string>();
selectors.each((selector) => {
const text = String(selector).trim();
if (seen.has(text)) selector.remove();
else seen.add(text);
});
});
// Everything, for stylesheets that speak of the document; only what a class
// does not already confine, for CSS modules
const scopeAll = processor(false);
const scopeBare = processor(true);
function scopeSelector(selector: Selector, isModule: boolean): void {
if (String(selector).includes(ROOT_SELECTOR)) return;
const compounds = splitCompounds(selector);
if (compounds.length === 0) return;
// Something said of the document is said of the root instead
const document = compounds[0].find(isDocumentSelector);
if (document !== undefined) {
document.replaceWith(pseudo(IS_ROOT));
return;
}
const subject = compounds.at(-1)!;
if (isModule && subject.some((node) => node.type === "class")) return;
// Pseudo-elements have to come last in a compound selector
const pseudoElement = subject.find(isPseudoElement);
if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT));
else selector.insertBefore(pseudoElement, pseudo(IN_ROOT));
}
/** The compound selectors making up a complex selector, in order. */
function splitCompounds(selector: Selector): Node[][] {
const compounds: Node[][] = [[]];
for (const node of selector.nodes) {
if (node.type === "combinator") compounds.push([]);
else if (node.type !== "comment") compounds.at(-1)!.push(node);
}
return compounds.filter((compound) => compound.length > 0);
}
function isDocumentSelector(node: Node): boolean {
return (
(node.type === "tag" && (node.value === "html" || node.value === "body")) ||
(node.type === "pseudo" && node.value === ":root")
);
}
function isPseudoElement(node: Node): node is Pseudo {
if (node.type !== "pseudo") return false;
return (
node.value.startsWith("::") ||
[":before", ":after", ":first-line", ":first-letter"].includes(node.value)
);
}
function pseudo(text: string): Pseudo {
return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo;
}
+4 -1
View File
@@ -18,7 +18,10 @@ Please see LICENSE in the repository root for full details.
*/ */
// The design tokens, fonts and element defaults every Element Call stylesheet // The design tokens, fonts and element defaults every Element Call stylesheet
// builds on. // builds on. Written for a page, they speak of `html`, `body` and bare
// elements; the component build confines them, and every other stylesheet in
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
// that the host's document is left as it was.
// //
// Where these land relative to the component stylesheets is the bundler's // Where these land relative to the component stylesheets is the bundler's
// choice — the standalone app puts them first, this build puts them in the // choice — the standalone app puts them first, this build puts them in the
+1
View File
@@ -112,6 +112,7 @@
"pako": "^2.0.4", "pako": "^2.0.4",
"postcss": "^8.4.41", "postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0", "postcss-preset-env": "^10.0.0",
"postcss-selector-parser": "^7.1.1",
"posthog-js": "1.408.2", "posthog-js": "1.408.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19", "react": "19",
@@ -77,6 +77,38 @@ test("keeps its modals inside the container it was given", async ({ page }) => {
); );
}); });
test("leaves the host's own page unstyled", async ({ page }) => {
const { username, roomId } = await createUserAndRoom("hoststyles");
const panes = await startHarness(page, username, roomId);
await expect(panes.first().getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
// Element Call's stylesheet is written for a page of its own: normalize.css
// gives `html` a line height, Compound gives `body` its font and feature
// settings, and the design tokens live on `:root`. None of that may reach the
// host's document — the harness sets none of these itself, so anything other
// than the browser's defaults here came from us.
const host = await page.evaluate(() => {
const html = getComputedStyle(document.documentElement);
const body = getComputedStyle(document.body);
return {
lineHeight: html.lineHeight,
fontFeatureSettings: body.fontFeatureSettings,
token: html.getPropertyValue("--cpd-color-text-primary"),
};
});
expect(host).toEqual({
lineHeight: "normal",
fontFeatureSettings: "normal",
token: "",
});
// While inside the container, the same rules do apply
const root = panes.first().locator("[data-element-call-root]");
await expect(root).toHaveCSS("font-feature-settings", /"kern"/);
});
test("tells its host what it is doing", async ({ page }) => { test("tells its host what it is doing", async ({ page }) => {
const { username, roomId } = await createUserAndRoom("hostbridge"); const { username, roomId } = await createUserAndRoom("hostbridge");
const panes = await startHarness(page, username, roomId); const panes = await startHarness(page, username, roomId);
+3
View File
@@ -223,6 +223,9 @@ importers:
postcss-preset-env: postcss-preset-env:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.6.1(postcss@8.5.25) version: 10.6.1(postcss@8.5.25)
postcss-selector-parser:
specifier: ^7.1.1
version: 7.1.1
posthog-js: posthog-js:
specifier: 1.408.2 specifier: 1.408.2
version: 1.408.2 version: 1.408.2
+8 -5
View File
@@ -10,11 +10,14 @@ and element defaults its own stylesheets build on top of.
Split out from index.css so that Element Call embedded in a host application Split out from index.css so that Element Call embedded in a host application
can have these without also being given the standalone page's layout, which can have these without also being given the standalone page's layout, which
would style the host's own document. What remains here does still reach outside would style the host's own document. What remains here still speaks of the
Element Call's container — normalize.css and the typography below use bare document — normalize.css and the typography below use bare element selectors,
element selectors, and the custom properties are declared on `:root` — so a and the custom properties are declared on `:root` — which is right for the
host gets those too. Narrowing them needs a real host to check against, so it page, and is why the component build rewrites it: there every selector is
waits for the Element Web integration rather than being guessed at here. confined to Element Call's root element (see component/build/scopeStylesToRoot.ts),
with `html`, `body` and `:root` becoming that element. Nothing needs to be
written differently here for that to work, but nothing here may rely on
reaching the host's document either.
Nothing here should depend on where it lands relative to Element Call's Nothing here should depend on where it lands relative to Element Call's
component stylesheets: the bundler decides that, and it decides differently for component stylesheets: the bundler decides that, and it decides differently for
+1 -1
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
pre { .pre {
font-size: var(--font-size-micro); font-size: var(--font-size-micro);
} }
+2 -2
View File
@@ -680,9 +680,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
<AudioProcessingSettings /> <AudioProcessingSettings />
<Separator /> <Separator />
<p>{t("developer_mode.environment_variables")}</p> <p>{t("developer_mode.environment_variables")}</p>
<pre>{JSON.stringify(env, null, 2)}</pre> <pre className={styles.pre}>{JSON.stringify(env, null, 2)}</pre>
<p>{t("developer_mode.url_params")}</p> <p>{t("developer_mode.url_params")}</p>
<pre>{JSON.stringify(urlParams, null, 2)}</pre> <pre className={styles.pre}>{JSON.stringify(urlParams, null, 2)}</pre>
</> </>
); );
}; };
+4
View File
@@ -10,6 +10,7 @@ import { realpathSync } from "node:fs";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { vitePluginsConfig } from "./vite.config"; import { vitePluginsConfig } from "./vite.config";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
// Serves the harness under `component/dev`, which embeds Element Call as a // Serves the harness under `component/dev`, which embeds Element Call as a
// component the way a host application would. Development only: this is not // component the way a host application would. Development only: this is not
@@ -35,6 +36,9 @@ export default defineConfig(({ mode }) => {
return { return {
...vitePluginsConfig({ mode, html: false }), ...vitePluginsConfig({ mode, html: false }),
root: "component/dev", root: "component/dev",
// The same scoping the library build applies, so the harness shows what a
// host will get — including whether its own page is left alone
css: { postcss: { plugins: [scopeStylesToRoot()] } },
// So that the harness can read the same config.json the standalone app // So that the harness can read the same config.json the standalone app
// does, if the developer has written one // does, if the developer has written one
publicDir: "../../public", publicDir: "../../public",
+77 -56
View File
@@ -8,67 +8,88 @@ Please see LICENSE in the repository root for full details.
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { vitePluginsConfig } from "./vite.config"; import { vitePluginsConfig } from "./vite.config";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
// Config for Element Call as a React component, to be imported by an // Config for Element Call as a React component, to be imported by an
// application embedding it rather than served as a page of its own. // application embedding it rather than served as a page of its own.
// //
// Deliberately not built on top of the full app's config, which exists to // Deliberately not built on top of the full app's config, which exists to
// produce a page and brings an HTML entry point along with it. // produce a page and brings an HTML entry point along with it.
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => {
...vitePluginsConfig({ mode, html: false }), const base = vitePluginsConfig({ mode, html: false });
// A library has no public directory to serve. Without this the build copies return {
// whatever is in `public` — including the developer's own config.json, which ...base,
// is not in the repository — into the output we would publish. resolve: {
publicDir: false, ...base.resolve,
build: { alias: {
minify: mode === "production", ...base.resolve?.alias,
sourcemap: true, // react-i18next depends on the CommonJS `use-sync-external-store/shim`, whose
// One stylesheet rather than one per chunk, so a host has a single file to // `require("react")` cannot be bundled against an external React: rolldown leaves a
// include // `require` shim that throws in the browser. React 18+ provides `useSyncExternalStore`
cssCodeSplit: false, // itself, so point the shim at React.
lib: { "use-sync-external-store/shim": "react",
formats: ["es" as const], },
entry: "./component/index.tsx",
fileName: "element-call",
}, },
rollupOptions: { // A library has no public directory to serve. Without this the build copies
// The host already has these, and a second copy of any of them does not // whatever is in `public` — including the developer's own config.json, which
// merely bloat the bundle: React would hold two sets of hooks, and the // is not in the repository — into the output we would publish.
// Matrix client would run two sync loops. publicDir: false,
// // A host's document is not ours to style: everything in the stylesheet is
// Every subpath has to be named. Element Call reaches most of the Matrix // confined to the element Element Call is mounted in
// SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not css: { postcss: { plugins: [scopeStylesToRoot()] } },
// catch those — while the pattern and callback forms of this option are build: {
// silently ignored by the bundler, so they cannot be used to cover them. minify: mode === "production",
// `pnpm lint:externals` reads this list and fails if the source imports sourcemap: true,
// one of these packages by a path it does not name; a few entries below // One stylesheet rather than one per chunk, so a host has a single file to
// are there only because the standalone app imports them, which costs // include
// nothing. cssCodeSplit: false,
external: [ lib: {
"react", formats: ["es" as const],
"react/jsx-runtime", entry: "./component/index.tsx",
"react-dom", fileName: "element-call",
"react-dom/client", },
"livekit-client", rollupOptions: {
"matrix-js-sdk", // The host already has these, and a second copy of any of them does not
"matrix-js-sdk/lib/browser-index", // merely bloat the bundle: React would hold two sets of hooks, and the
"matrix-js-sdk/lib/client", // Matrix client would run two sync loops.
"matrix-js-sdk/lib/crypto-api", //
"matrix-js-sdk/lib/indexeddb-worker", // Every subpath has to be named. Element Call reaches most of the Matrix
"matrix-js-sdk/lib/logger", // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not
"matrix-js-sdk/lib/matrix", // catch those — while the pattern and callback forms of this option are
"matrix-js-sdk/lib/matrixrtc", // silently ignored by the bundler, so they cannot be used to cover them.
"matrix-js-sdk/lib/matrixrtc/EncryptionManager", // `pnpm lint:externals` reads this list and fails if the source imports
"matrix-js-sdk/lib/matrixrtc/IKeyTransport", // one of these packages by a path it does not name; a few entries below
"matrix-js-sdk/lib/matrixrtc/IMembershipManager", // are there only because the standalone app imports them, which costs
"matrix-js-sdk/lib/models/relations-container", // nothing.
"matrix-js-sdk/lib/models/room", external: [
"matrix-js-sdk/lib/models/typed-event-emitter", "react",
"matrix-js-sdk/lib/randomstring", "react/jsx-runtime",
"matrix-js-sdk/lib/sync", // Emitted by the React Compiler for every compiled component; part of React, so it must be
"matrix-js-sdk/lib/types", // the host's copy too (bundled, its CommonJS `require("react")` throws in the browser).
"matrix-js-sdk/lib/utils", "react/compiler-runtime",
], "react-dom",
"react-dom/client",
"livekit-client",
"matrix-js-sdk",
"matrix-js-sdk/lib/browser-index",
"matrix-js-sdk/lib/client",
"matrix-js-sdk/lib/crypto-api",
"matrix-js-sdk/lib/indexeddb-worker",
"matrix-js-sdk/lib/logger",
"matrix-js-sdk/lib/matrix",
"matrix-js-sdk/lib/matrixrtc",
"matrix-js-sdk/lib/matrixrtc/EncryptionManager",
"matrix-js-sdk/lib/matrixrtc/IKeyTransport",
"matrix-js-sdk/lib/matrixrtc/IMembershipManager",
"matrix-js-sdk/lib/models/relations-container",
"matrix-js-sdk/lib/models/room",
"matrix-js-sdk/lib/models/typed-event-emitter",
"matrix-js-sdk/lib/randomstring",
"matrix-js-sdk/lib/sync",
"matrix-js-sdk/lib/types",
"matrix-js-sdk/lib/utils",
],
},
}, },
}, };
})); });
+5 -1
View File
@@ -21,7 +21,11 @@ export default defineConfig((configEnv) =>
css: { include: /.+/ }, css: { include: /.+/ },
setupFiles: ["src/vitest.setup.ts"], setupFiles: ["src/vitest.setup.ts"],
environment: "jsdom", environment: "jsdom",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"], include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"component/**/*.test.ts",
],
}, },
}, },
{ {