diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts
index 5ac2ad48d..dc635817a 100644
--- a/packages/lint/src/rules/composition.test.ts
+++ b/packages/lint/src/rules/composition.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
+import { collectClipBoxProps, clipStretchAxes } from "./composition.js";
describe("composition rules", () => {
describe("subcomposition guidance", () => {
@@ -1421,4 +1422,142 @@ describe("composition rules", () => {
expect(find(result.findings)).toBeUndefined();
});
});
+
+ describe("clip_partial_inset_stretch", () => {
+ const find = (fs: Array<{ code: string }>) =>
+ fs.find((f) => f.code === "clip_partial_inset_stretch");
+
+ // A composition whose .clip base is `position:absolute; inset:0`, with one
+ // clip element carrying a specific #id rule of `overrideDecls`.
+ function comp(overrideDecls: string, extraClipAttrs = "") {
+ return `
+
+
+
+
+`;
+ }
+
+ it("flags a clip that sets only bottom+left (far corner) with no width/height", async () => {
+ const result = await lintHyperframeHtml(comp("bottom: 40px; left: 40px;"));
+ const f = find(result.findings);
+ expect(f).toBeDefined();
+ expect(f?.severity).toBe("warning");
+ expect(f?.message).toContain("stretches horizontally and vertically");
+ });
+
+ it("flags a single-side override (left only) as a horizontal stretch", async () => {
+ const result = await lintHyperframeHtml(comp("left: 40px;"));
+ expect(find(result.findings)).toBeDefined();
+ });
+
+ it("does NOT flag when width AND height are both set (sized box)", async () => {
+ const result = await lintHyperframeHtml(
+ comp("top: 40px; left: 40px; width: 200px; height: 80px;"),
+ );
+ expect(find(result.findings)).toBeUndefined();
+ });
+
+ it("does NOT flag a full-bleed clip that overrides nothing", async () => {
+ const result = await lintHyperframeHtml(comp("background: red;"));
+ expect(find(result.findings)).toBeUndefined();
+ });
+
+ it("does NOT flag when all four sides are set explicitly (intentional stretch box)", async () => {
+ const result = await lintHyperframeHtml(
+ comp("top: 10px; right: 10px; bottom: 10px; left: 10px;"),
+ );
+ expect(find(result.findings)).toBeUndefined();
+ });
+
+ it("does NOT flag when the opposite side is released to auto", async () => {
+ // left:40 pins left; right:auto releases the base right pin → no h-stretch.
+ // top/bottom both inherited-pinned but author touched neither → not a bug axis.
+ const result = await lintHyperframeHtml(
+ comp("left: 40px; right: auto; top: 40px; bottom: auto;"),
+ );
+ expect(find(result.findings)).toBeUndefined();
+ });
+
+ it("does NOT flag when the element re-declares position: relative", async () => {
+ const result = await lintHyperframeHtml(comp("position: relative; left: 40px;"));
+ expect(find(result.findings)).toBeUndefined();
+ });
+
+ it("does NOT fire at all when the file's .clip base has no inset:0", async () => {
+ const html = `
+
+
+
+
+`;
+ expect(find((await lintHyperframeHtml(html)).findings)).toBeUndefined();
+ });
+
+ it("flags via an inline style override too", async () => {
+ const html = `
+
+
+
+
+`;
+ expect(find((await lintHyperframeHtml(html)).findings)).toBeDefined();
+ });
+ });
+
+ // Direct unit tests for the pure box-model logic (branch coverage without HTML).
+ describe("collectClipBoxProps / clipStretchAxes", () => {
+ it("2 far-corner sides, no size → both axes stretch", () => {
+ const p = collectClipBoxProps(["bottom: 40px; left: 40px;"]);
+ expect(clipStretchAxes(p)).toEqual({ horizontal: true, vertical: true });
+ });
+
+ it("width set but not height → only vertical could stretch, but untouched axis is quiet", () => {
+ // Author touched left (h) + width; vertical axis untouched → no XOR → no v-bug.
+ const p = collectClipBoxProps(["left: 40px; width: 200px;"]);
+ expect(clipStretchAxes(p)).toEqual({ horizontal: false, vertical: false });
+ });
+
+ it("both sides of an axis author-pinned → not a bug (XOR false)", () => {
+ const p = collectClipBoxProps(["left: 10px; right: 10px;"]);
+ expect(clipStretchAxes(p).horizontal).toBe(false);
+ });
+
+ it("inset shorthand override (all four) → no stretch bug", () => {
+ const p = collectClipBoxProps(["inset: 20px;"]);
+ expect(clipStretchAxes(p)).toEqual({ horizontal: false, vertical: false });
+ });
+
+ it("auto on the opposite side releases the base pin", () => {
+ const p = collectClipBoxProps(["left: 40px; right: auto;"]);
+ expect(clipStretchAxes(p).horizontal).toBe(false);
+ });
+
+ it("position:relative detaches from inset entirely", () => {
+ const p = collectClipBoxProps(["position: relative; left: 40px;"]);
+ expect(clipStretchAxes(p)).toEqual({ horizontal: false, vertical: false });
+ });
+
+ it("later blocks override earlier ones (inline wins)", () => {
+ // A matching