From 51592030ee45b7dd39a0bde9478a8239ecdcabee Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 24 Oct 2026 16:26:59 -0400 Subject: [PATCH 1/5] fix(lint): allow scoped descendant zoom --- docs/packages/lint.mdx | 2 + packages/lint/src/rules/composition.test.ts | 191 ++++++++++++++++++ packages/lint/src/rules/composition.ts | 212 +++++++++++++++++--- 3 files changed, 373 insertions(+), 32 deletions(-) diff --git a/docs/packages/lint.mdx b/docs/packages/lint.mdx index 39cdc3ee9e5..65de9c14479 100644 --- a/docs/packages/lint.mdx +++ b/docs/packages/lint.mdx @@ -109,6 +109,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 8cbf698c51d..f430cf6c7ea 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -2387,6 +2387,197 @@ describe("composition rules", () => { expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas"); }); + it.each([ + "#root .badge", + "#root > .badge", + ".canvas .badge", + "body .badge", + "#root > *", + ":is(#root, body) .badge", + ":is(.missing, #root .badge)", + '#root[data-label="main canvas"] > .badge', + "#root::before", + ])("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([ + '
', + '', + ])("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(` +
+ + `); + + 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([ '
', '', diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index a6989bb77ba..30d7ae1649b 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -394,7 +394,20 @@ function canvasPathMatch(nodes: selectorParser.Node[], element: CanvasElement): } const subjectNodes = nodes.slice(split + 1); if (subjectNodes.length === 0) return undefined; - const subject = allCanvasMatch(subjectNodes.map((node) => canvasNodeMatch(node, element))); + const hasIdentity = subjectNodes.some((node) => + node.type === "tag" + ? node.namespace === undefined && node.value.toLowerCase() === element.name.toLowerCase() + : ["id", "class"].includes(node.type) && canvasNodeMatch(node, element) === true, + ); + const subject = allCanvasMatch( + subjectNodes.map((node) => { + const match = canvasNodeMatch(node, element); + // Scripts can change the remaining identity tokens on a known canvas element. + if (hasIdentity && match === false && ["id", "class", "attribute"].includes(node.type)) + return undefined; + return match; + }), + ); if (subject === false) return false; if (split === -1) return subject; const preceding = canvasAncestorMatch(nodes.slice(0, split), relation, element); From 18ceab0bc10c688d2995beb3eac80aff7e2ed073 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 24 Oct 2026 19:27:32 -0400 Subject: [PATCH 4/5] fix(lint): preserve runtime identity in selector functions --- packages/lint/src/rules/composition.test.ts | 9 ++++ packages/lint/src/rules/composition.ts | 47 +++++++++++++++------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 64a21efcc10..6dfc7ce31d0 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -2400,6 +2400,10 @@ describe("composition rules", () => { ":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)", ])("allows descendant or pseudo-element zoom selected by %s", async (selector) => { const result = await lintHyperframeHtml(`