diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts
index 12502b48e1..1efc3865c9 100644
--- a/packages/lint/src/rules/core.test.ts
+++ b/packages/lint/src/rules/core.test.ts
@@ -870,6 +870,78 @@ describe("core rules", () => {
});
});
+ describe("runtime_hidden_style_opacity", () => {
+ const comp = (css: string, extraMarkup = "") => `
+
+
+
+ ${extraMarkup}
+
+
+`;
+
+ 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(`
+
+
+
+`);
+
+ 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; }`,
+ `
+ `,
+ ),
+ );
+
+ 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 = `
diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts
index 6f8569034b..42c69335d9 100644
--- a/packages/lint/src/rules/core.ts
+++ b/packages/lint/src/rules/core.ts
@@ -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();
+ const reportedRepeatedIds = new Set();
+ const reportedHiddenStyleSelectors = new Set();
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()),
});
}
});