diff --git a/docs/packages/lint.mdx b/docs/packages/lint.mdx
index fb60bd6424..9863281207 100644
--- a/docs/packages/lint.mdx
+++ b/docs/packages/lint.mdx
@@ -112,6 +112,8 @@ Detected issues include:
- Missing `class="clip"` on timed visible elements
- Deprecated attribute names
- Missing composition dimensions (`data-width`, `data-height`)
+- CSS `zoom` on the composition root or its ancestors (`root_zoom_rescales_a_fixed_canvas`);
+ zoom on descendants is allowed, including scoped selectors such as `#root .badge`
- Invalid `data-start` references to nonexistent clip IDs
- Seek-order hazards that render differently on cold render workers: relative tween
values (`"+=..."`) whose property has a second concurrent writer
diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts
index 8cbf698c51..2ef69e5eaa 100644
--- a/packages/lint/src/rules/composition.test.ts
+++ b/packages/lint/src/rules/composition.test.ts
@@ -2387,6 +2387,257 @@ describe("composition rules", () => {
expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
});
+ it.each([
+ "#root .badge",
+ "#root .badge.zoomed",
+ "#root #badge",
+ "#root [data-badge]",
+ "#root > .badge",
+ ".canvas .badge",
+ "body .badge",
+ "#root > *",
+ ":is(#root, body) .badge",
+ ":is(.missing, #root .badge)",
+ '#root[data-label="main canvas"] > .badge',
+ "#root::before",
+ "#root:is(span.zoomed)",
+ "#root:is(.badge::before)",
+ "#root :is(.badge.zoomed)",
+ "#root:is(#root .canvas)",
+ ":is(span#root.zoomed).active",
+ ":is(#root .badge).active",
+ ":is(#root::before).active",
+ ])("allows descendant or pseudo-element zoom selected by %s", async (selector) => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it.each([
+ "body > #root",
+ "body > *",
+ "html body .canvas",
+ ":is(.missing, #root)",
+ ":where(.missing, .canvas)",
+ "#root:not(.badge)",
+ ".canvas:has(.badge)",
+ ".badge::before, #root",
+ ])("keeps canvas zoom selected by %s blocking", async (selector) => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it.each([
+ ["#root.zoomed", 'root.classList.add("zoomed")'],
+ [":is(#root.zoomed).active", 'root.classList.add("zoomed", "active")'],
+ [
+ ":where(.canvas.zoomed)[data-active]",
+ 'root.classList.add("zoomed"); root.dataset.active = ""',
+ ],
+ [":is(#root[data-zoomed]).active", 'root.dataset.zoomed = ""; root.classList.add("active")'],
+ ["#root:is(.zoomed)", 'root.classList.add("zoomed")'],
+ ["#root:where([data-zoomed])", 'root.dataset.zoomed = ""'],
+ [".canvas:is(.zoomed)", 'root.classList.add("zoomed")'],
+ [":is(#root).zoomed", 'root.classList.add("zoomed")'],
+ ["#root:is(:where(.zoomed))", 'root.classList.add("zoomed")'],
+ [".canvas.zoomed", 'root.classList.add("zoomed")'],
+ [".zoomed.canvas", 'root.classList.add("zoomed")'],
+ [".canvas#zoomed", 'root.id = "zoomed"'],
+ ["#root[data-zoomed]", 'root.dataset.zoomed = ""'],
+ ["body > div.zoomed", 'root.classList.add("zoomed")'],
+ ["body > div#zoomed", 'root.id = "zoomed"'],
+ ["body > div[data-zoomed]", 'root.dataset.zoomed = ""'],
+ ])("retains canvas protection for runtime identity in %s", async (selector, mutation) => {
+ const result = await lintHyperframeHtml(`
+
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it("does not assume a head composition root is a child of body", async () => {
+ const result = await lintHyperframeHtml(`
+
+
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it("does not assume a template composition root is a child of body", async () => {
+ const result = await lintHyperframeHtml(`
+
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it.each([
+ '',
+ '',
+ ])("matches the lint context's root in body and template sources", async (root) => {
+ for (const [selector, expected] of [
+ ["[data-composition-id] .badge", false],
+ ["[data-composition-id]", true],
+ ["body", true],
+ ] as const) {
+ const result = await lintHyperframeHtml(root.replace("CSS", `${selector} { zoom: 2; }`));
+
+ expect(codes(result).includes("root_zoom_rescales_a_fixed_canvas")).toBe(expected);
+ }
+ });
+
+ it("treats optional document tags like their explicit equivalents", async () => {
+ const root =
+ '';
+ for (const source of [
+ `${root}`,
+ `${root}`,
+ `Canvas${root}`,
+ `Canvas${root}`,
+ `${root}`,
+ `${root}`,
+ ]) {
+ for (const [selector, expected] of [
+ ["#root:first-child", true],
+ ["body:nth-child(2) > #root", true],
+ ["#root:first-child .badge", false],
+ ] as const) {
+ const result = await lintHyperframeHtml(
+ source.replace("CSS", `${selector} { zoom: 2; }`),
+ );
+
+ expect(codes(result).includes("root_zoom_rescales_a_fixed_canvas"), source).toBe(
+ expected,
+ );
+ }
+ }
+ });
+
+ it.each([
+ "#root:lang(en)",
+ "#root:dir(ltr)",
+ "#root:nth-child(1 of .canvas)",
+ ":is(#root, :unknown)",
+ ])("retains canvas protection for unsupported static selectors: %s", async (selector) => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it("keeps table-dependent canvas zoom blocking with optional tbody tags", async () => {
+ for (const table of [
+ "",
+ "",
+ ]) {
+ const result = await lintHyperframeHtml(`
+ ${table}
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ }
+ });
+
+ it("keeps SVG canvas zoom blocking with case-sensitive attributes", async () => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it.each([".canvas #ROOT", ".canvas .BADGE", ".canvas :is(.BADGE, .missing)"])(
+ "keeps quirks-mode canvas zoom blocking for %s",
+ async (selector) => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ },
+ );
+
+ it("does not invent a canvas hit for a false body position", async () => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it("does not treat a relative selector as an unconditional canvas hit", async () => {
+ const result = await lintHyperframeHtml(`
+
+ `);
+
+ expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
+ it("preserves uncertainty about stylesheet context before adding canvas hits", async () => {
+ for (const selector of ["[data-review-zoom]", "body:nth-child(2) > #root"]) {
+ for (const [style, expected] of [
+ [``, true],
+ [
+ ``,
+ false,
+ ],
+ [``, false],
+ [``, false],
+ ] as const) {
+ const result = await lintHyperframeHtml(`${style}
+
+
+
+ `);
+
+ expect(codes(result).includes("root_zoom_rescales_a_fixed_canvas"), style).toBe(expected);
+ }
+ }
+ });
+
+ it("keeps namespace context local to its stylesheet", async () => {
+ const result = await lintHyperframeHtml(`
+
+
+
+
+ `);
+
+ expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
+ });
+
it("stays silent on identity zoom values", async () => {
for (const value of ["1", "1.0", "100%", "normal", "unset", "initial"]) {
const result = await lintHyperframeHtml(`