fix(lint): flag hidden-style opacity guards (#3694)

This commit is contained in:
Miguel Ángel
2026-09-08 18:09:54 +00:00
committed by GitHub
parent 4958a9fba5
commit 2c14438bb0
2 changed files with 147 additions and 7 deletions
+72
View File
@@ -870,6 +870,78 @@ describe("core rules", () => {
});
});
describe("runtime_hidden_style_opacity", () => {
const comp = (css: string, extraMarkup = "") => `
<html><head><style>${css}</style></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<video id="footage" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
${extraMarkup}
</div>
<script>window.__timelines = { main: gsap.timeline({ paused: true }) };</script>
</body></html>`;
it("errors when a broad hidden-style selector forces replacement-frame opacity to zero", async () => {
const result = await lintHyperframeHtml(
comp(`[style*="visibility: hidden"] { opacity: 0 !important; }`),
);
const finding = result.findings.find((item) => item.code === "runtime_hidden_style_opacity");
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe(`[style*="visibility: hidden"]`);
expect(finding?.message).toContain("replacement frame");
expect(finding?.fixHint).toContain("data-composition-src");
});
it("errors when composition scoping still leaves the hidden-style selector on video", async () => {
const result = await lintHyperframeHtml(
comp(`#root > video[style*="visibility: hidden"] { opacity: 0; }`),
);
expect(
result.findings.find((item) => item.code === "runtime_hidden_style_opacity")?.selector,
).toBe(`#root > video[style*="visibility: hidden"]`);
});
it("errors when the root stylesheet can affect video mounted from a sub-composition", async () => {
const result = await lintHyperframeHtml(`
<html><head><style>[style*="visibility: hidden"] { opacity: 0; }</style></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="scene" data-composition-src="scene.html"></div>
</div>
<script>window.__timelines = { main: gsap.timeline({ paused: true }) };</script>
</body></html>`);
expect(
result.findings.find((item) => item.code === "runtime_hidden_style_opacity")?.severity,
).toBe("error");
});
it("allows hidden-style opacity guards scoped to sub-composition hosts", async () => {
const result = await lintHyperframeHtml(
comp(
`[data-composition-src][style*="visibility: hidden"],
[data-composition-file][style*="visibility: hidden"] { opacity: 0 !important; }`,
`<div data-composition-id="scene-a" data-composition-src="scene-a.html"></div>
<div data-composition-id="scene-b" data-composition-file="scene-b.html"></div>`,
),
);
expect(
result.findings.find((item) => item.code === "runtime_hidden_style_opacity"),
).toBeUndefined();
});
it("allows broad hidden-style selectors that do not change opacity", async () => {
const result = await lintHyperframeHtml(
comp(`[style*="visibility: hidden"] { pointer-events: none; }`),
);
expect(
result.findings.find((item) => item.code === "runtime_hidden_style_opacity"),
).toBeUndefined();
});
});
describe("css_parse_error — malformed CSS is reported instead of silently swallowed", () => {
it("reports a css_parse_error finding for unparseable CSS", async () => {
const html = `<html><body>
+75 -7
View File
@@ -97,6 +97,53 @@ function resolvedRuleSelectors(rule: postcss.Rule): string[] {
);
}
function selectorAliasesRuntimeHiddenStyle(selector: string): boolean {
let unsafe = false;
try {
selectorParser((root) => {
root.each((selectorNode) => {
const subject: selectorParser.Node[] = [];
selectorNode.each((node) => {
if (node.type === "combinator") subject.length = 0;
else subject.push(node);
});
const hostScoped = subject.some(
(node) =>
node.type === "attribute" &&
["data-composition-src", "data-composition-file"].includes(
node.attribute.toLowerCase(),
),
);
if (hostScoped) return;
if (
subject.some((node) => {
if (node.type !== "attribute" || node.attribute.toLowerCase() !== "style") return false;
if (node.operator !== "*=" || !node.value) return false;
const needle = node.insensitive ? node.value.toLowerCase() : node.value;
if (!needle.includes("visibility") && !needle.includes("hidden")) return false;
return "visibility: hidden !important;".includes(needle);
})
) {
unsafe = true;
}
});
}).processSync(selector);
} catch {
return false;
}
return unsafe;
}
function ruleForcesOpacityZero(rule: postcss.Rule): boolean {
let forcesOpacityZero = false;
rule.walkDecls(/^opacity$/i, (declaration) => {
if (Number(declaration.value.trim()) === 0) forcesOpacityZero = true;
});
return forcesOpacityZero;
}
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
@@ -333,10 +380,11 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},
// repeated_id_descendant_selector
// CSS selector safety
({ styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
const reportedRepeatedIds = new Set<string>();
const reportedHiddenStyleSelectors = new Set<string>();
for (const style of styles) {
let root: postcss.Root;
try {
@@ -350,16 +398,36 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
continue;
}
root.walkRules((rule) => {
const forcesOpacityZero = ruleForcesOpacityZero(rule);
for (const selector of resolvedRuleSelectors(rule)) {
const repeatedId = repeatedDescendantId(selector);
if (!repeatedId || reported.has(repeatedId)) continue;
reported.add(repeatedId);
if (repeatedId && !reportedRepeatedIds.has(repeatedId)) {
reportedRepeatedIds.add(repeatedId);
findings.push({
code: "repeated_id_descendant_selector",
severity: "error",
message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`,
selector,
fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`,
});
}
if (
!forcesOpacityZero ||
reportedHiddenStyleSelectors.has(selector) ||
!selectorAliasesRuntimeHiddenStyle(selector)
) {
continue;
}
reportedHiddenStyleSelectors.add(selector);
findings.push({
code: "repeated_id_descendant_selector",
code: "runtime_hidden_style_opacity",
severity: "error",
message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`,
message: `Selector "${selector}" observes HyperFrames' runtime-owned hidden style and forces opacity to zero. The renderer hides each native video before copying its computed opacity to the visible replacement frame, so this rule makes both transparent.`,
selector,
fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`,
fixHint:
'Restrict the guard to sub-composition hosts, for example `[data-composition-src][style*="visibility: hidden"]` and `[data-composition-file][style*="visibility: hidden"]`. Do not derive arbitrary element or media opacity from runtime-owned inline visibility.',
snippet: truncateSnippet(rule.toString()),
});
}
});