From dab894948accf59d0cda2ffbbf373199afeeb2e8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 3 Jul 2026 20:41:43 -0700 Subject: [PATCH] feat(lint): warn when a class="clip" element partially overrides inset:0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical `.clip` rule is `position: absolute; inset: 0`, so every class="clip" element is pinned to all four edges by default. A more specific selector (an #id/.class rule, or an inline style) that overrides only SOME of top/right/bottom/left without also giving width/height leaves the un-overridden sides pinned at the base 0 — so the element silently stretches between the author's sides and the inherited zeros instead of shrink-wrapping to its content. Reported four independent times (a user sets e.g. `bottom:40px; left:40px` expecting a corner-anchored box and gets a full-screen stretch). inspect catches the symptom (content_overlap / text_occluded) but never names the CSS root cause, so each reporter had to getBoundingClientRect()-probe to diagnose it, and multiple asked directly for this lint rule. New warning-level rule `clip_partial_inset_stretch`: - Self-gating: only fires once the file's own `.clip` rule actually establishes the base (position:absolute|fixed + all-zero inset), so compositions that define .clip differently never trip it. - Per class="clip" element, merges its override declarations (matching #id/.class +
+
hi
+
+ +`; + } + + 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 = ` + + +
+
hi
+
+ +`; + expect(find((await lintHyperframeHtml(html)).findings)).toBeUndefined(); + }); + + it("flags via an inline style override too", async () => { + const html = ` + + +
+
hi
+
+ +`; + 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