diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts
index 15341e0f76..28cca60f42 100644
--- a/packages/lint/src/rules/core.test.ts
+++ b/packages/lint/src/rules/core.test.ts
@@ -77,6 +77,62 @@ ${headContent}
}
describe("core rules", () => {
+ it("warns when a custom-positioned clip inherits opposite inset edges", async () => {
+ const html = `
+
+
+
+`;
+
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "clip_inset_overconstrained");
+
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ expect(finding?.selector).toBe(".badge");
+ expect(finding?.fixHint).toContain("left: auto");
+ expect(finding?.fixHint).toContain("bottom: auto");
+ });
+
+ it("accepts a custom-positioned clip that resets inherited opposite edges", async () => {
+ const html = `
+
+
+
+`;
+
+ const result = await lintHyperframeHtml(html);
+
+ expect(result.findings.find((f) => f.code === "clip_inset_overconstrained")).toBeUndefined();
+ });
+
+ it("accepts a custom-positioned clip with explicit dimensions", async () => {
+ const html = `
+
+
+
+`;
+
+ const result = await lintHyperframeHtml(html);
+
+ expect(result.findings.find((f) => f.code === "clip_inset_overconstrained")).toBeUndefined();
+ });
+
it("reports error when root is missing data-composition-id", async () => {
const html = `
diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts
index 016fa25845..cadd487c1c 100644
--- a/packages/lint/src/rules/core.ts
+++ b/packages/lint/src/rules/core.ts
@@ -24,6 +24,22 @@ function selectorTargetsCompositionId(selector: string, compositionId: string):
).test(selector);
}
+function selectorMatchesClassList(selector: string, classes: Set): boolean {
+ const target =
+ selector
+ .trim()
+ .split(/\s+|>|\+|~/)
+ .at(-1) ?? "";
+ const required = [...target.matchAll(/\.([\w-]+)/g)].flatMap((match) =>
+ match[1] ? [match[1]] : [],
+ );
+ return required.length > 0 && required.every((className) => classes.has(className));
+}
+
+function isActiveInset(value: string | undefined): boolean {
+ return value !== undefined && value.trim().toLowerCase() !== "auto";
+}
+
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
@@ -181,6 +197,90 @@ function findVisibleMarkupCommentLeak(source: string): string | null {
}
export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
+ // clip_inset_overconstrained
+ // `.clip { inset: 0 }` is the full-canvas default. A custom-positioning rule
+ // that changes only top/right (or another subset) retains the opposite edges,
+ // so an auto-sized absolute element silently stretches across the canvas.
+ ({ tags, styles }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const parsedRules: postcss.Rule[] = [];
+ for (const style of styles) {
+ let root: postcss.Root;
+ try {
+ root = postcss.parse(style.content);
+ } catch {
+ continue;
+ }
+ root.walkRules((rule) => {
+ parsedRules.push(rule);
+ });
+ }
+
+ for (const tag of tags) {
+ const classNames = (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
+ if (!classNames.includes("clip") || classNames.length < 2) continue;
+ const classes = new Set(classNames);
+ const edges = new Map();
+ let inheritedInset = false;
+ let customSelector: string | undefined;
+
+ for (const rule of parsedRules) {
+ const matchingSelectors = rule.selectors.filter((selector) =>
+ selectorMatchesClassList(selector, classes),
+ );
+ if (matchingSelectors.length === 0) continue;
+ rule.walkDecls((decl) => {
+ const property = decl.prop.trim().toLowerCase();
+ if (property === "inset") {
+ const values = decl.value.trim().split(/\s+/);
+ if (values.length === 1 && isActiveInset(values[0])) {
+ for (const edge of ["top", "right", "bottom", "left"]) edges.set(edge, values[0]!);
+ if (matchingSelectors.some((selector) => /\.clip(?:\b|[.#:[>+~])/.test(selector))) {
+ inheritedInset = true;
+ }
+ }
+ return;
+ }
+ if (!["top", "right", "bottom", "left", "width", "height"].includes(property)) {
+ return;
+ }
+ edges.set(property, decl.value);
+ if (property !== "width" && property !== "height") {
+ const selector = matchingSelectors.find((candidate) => !/^\.clip\s*$/.test(candidate));
+ if (selector) customSelector = selector;
+ }
+ });
+ }
+
+ if (!inheritedInset || !customSelector) continue;
+ const horizontalStretch =
+ isActiveInset(edges.get("left")) &&
+ isActiveInset(edges.get("right")) &&
+ !isActiveInset(edges.get("width"));
+ const verticalStretch =
+ isActiveInset(edges.get("top")) &&
+ isActiveInset(edges.get("bottom")) &&
+ !isActiveInset(edges.get("height"));
+ if (!horizontalStretch && !verticalStretch) continue;
+
+ const resets = [
+ horizontalStretch ? "left: auto" : null,
+ verticalStretch ? "bottom: auto" : null,
+ ]
+ .filter(Boolean)
+ .join("; ");
+ findings.push({
+ code: "clip_inset_overconstrained",
+ severity: "warning",
+ message: `\`${customSelector}\` custom-positions a .clip element but retains opposite edges from \`.clip { inset: 0 }\`, so its auto size stretches across the canvas.`,
+ selector: customSelector,
+ fixHint: `Reset the inherited opposite edges (${resets}) or set an explicit width/height for the intended custom placement.`,
+ snippet: truncateSnippet(tag.raw),
+ });
+ }
+ return findings;
+ },
+
// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
const findings: HyperframeLintFinding[] = [];