Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,63 @@ describe("core rules", () => {
expect(finding).toBeUndefined();
});

describe("css_transition_used", () => {
it.each([
["transition", "opacity 0.5s ease"],
["transition-duration", "0.5s"],
["transition-delay", "100ms"],
["transition-property", "opacity"],
["-webkit-transition", "opacity 0.5s ease"],
["-webkit-transition-duration", "0.5s"],
])("errors for %s declarations in style blocks", async (property, value) => {
const result = await lintHyperframeHtml(
compositionWithBodyPrefix(`<style>.card { ${property}: ${value}; }</style>`),
);
const finding = result.findings.find((item) => item.code === "css_transition_used");

expect(finding).toMatchObject({ severity: "error", selector: ".card" });
expect(finding?.message).toContain(property);
expect(finding?.fixHint).toContain("paused GSAP timeline");
});

it("errors for an inline transition and identifies its element", async () => {
const result = await lintHyperframeHtml(
compositionWithBodyPrefix(
"",
'<div id="card" style="transition: opacity 0.5s ease"></div>',
),
);
const finding = result.findings.find((item) => item.code === "css_transition_used");

expect(finding).toMatchObject({ severity: "error", elementId: "card" });
expect(finding?.snippet).toContain('id="card"');
});

it.each([
["transition", "none"],
["transition-property", "none"],
["-webkit-transition", "none"],
["-webkit-transition-property", "NONE"],
])("allows %s: %s", async (property, value) => {
const result = await lintHyperframeHtml(
compositionWithBodyPrefix(`<style>.card { ${property}: ${value} !important; }</style>`),
);

expect(result.findings.find((item) => item.code === "css_transition_used")).toBeUndefined();
});

it("ignores custom properties that contain transition in their name", async () => {
const result = await lintHyperframeHtml(
compositionWithBodyPrefix(
'<div id="card" style="--transition-speed: 0.5s"></div>',
"<style>.card { --transition-easing: ease; opacity: 1; }</style>",
),
);

expect(result.findings.find((item) => item.code === "css_transition_used")).toBeUndefined();
});
});

describe("non_deterministic_code", () => {
it("gives randomness guidance for crypto and clock guidance for wall time", async () => {
const result = await lintHyperframeHtml(`<html><body>
Expand Down
80 changes: 80 additions & 0 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,37 @@ function ruleForcesOpacityZero(rule: postcss.Rule): boolean {
return forcesOpacityZero;
}

const CSS_TRANSITION_PROPERTY_PATTERN = /^(?:-webkit-)?transition(?:-[a-z][a-z-]*)?$/i;

function isSeekUnsafeTransition(declaration: postcss.Declaration): boolean {
const property = declaration.prop.trim().toLowerCase();
if (property.startsWith("--") || !CSS_TRANSITION_PROPERTY_PATTERN.test(property)) return false;

const value = declaration.value.trim().toLowerCase();
const disablesTransition =
[
"transition",
"-webkit-transition",
"transition-property",
"-webkit-transition-property",
].includes(property) && value === "none";
return !disablesTransition;
}

function cssTransitionFinding(
declaration: postcss.Declaration,
details: Pick<HyperframeLintFinding, "selector" | "elementId" | "snippet">,
): HyperframeLintFinding {
return {
code: "css_transition_used",
severity: "error",
message: `CSS declaration \`${declaration.prop}: ${declaration.value}\` runs on the browser clock and cannot be seeked deterministically across render workers.`,
fixHint:
"Keep the class or attribute swap for state; put the visual change on the paused GSAP timeline.",
...details,
};
}

function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
Expand Down Expand Up @@ -380,6 +411,55 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},

// css_transition_used
({ styles, tags }) => {
const findings: HyperframeLintFinding[] = [];

for (const style of styles) {
if (!/transition/i.test(style.content)) continue;
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
// The CSS syntax rule reports malformed style blocks separately.
continue;
}
root.walkDecls((declaration) => {
if (!isSeekUnsafeTransition(declaration)) return;
const selector =
declaration.parent?.type === "rule" ? declaration.parent.selector : undefined;
findings.push(
cssTransitionFinding(declaration, {
selector,
snippet: truncateSnippet(declaration.toString()),
}),
);
});
}

for (const tag of tags) {
const inlineStyle = readDecodedAttr(tag.raw, "style");
if (!inlineStyle || !/transition/i.test(inlineStyle)) continue;
let root: postcss.Root;
try {
root = postcss.parse(inlineStyle);
} catch {
continue;
}
root.walkDecls((declaration) => {
if (!isSeekUnsafeTransition(declaration)) return;
findings.push(
cssTransitionFinding(declaration, {
elementId: readDecodedAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
}),
);
});
}

return findings;
},

// CSS selector safety
({ styles }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down