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 [ + "
Cell
", + "
Cell
", + ]) { + 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(`