Skip to content

Commit 121c743

Browse files
miguel-heygenclaude
andcommitted
feat(lint): add prefer_container_units rule, drop runtime injection
Adds a lint rule that suggests cqw/cqh over px for positioning and sizing on elements inside compositions. Severity: info (suggestion). Calculates the exact container-unit equivalent based on composition dimensions. Drops the runtime container-type injection per feedback — authors opt in explicitly via CSS. 8 new tests covering suggestions, unit selection, value calculation, small-value exemption, and tag filtering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 664b7c3 commit 121c743

4 files changed

Lines changed: 150 additions & 1 deletion

File tree

packages/core/src/lint/hyperframeLinter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { gsapRules } from "./rules/gsap";
77
import { captionRules } from "./rules/captions";
88
import { compositionRules } from "./rules/composition";
99
import { adapterRules } from "./rules/adapters";
10+
import { responsiveUnitRules } from "./rules/responsiveUnits";
1011

1112
const ALL_RULES = [
1213
...coreRules,
@@ -15,6 +16,7 @@ const ALL_RULES = [
1516
...captionRules,
1617
...compositionRules,
1718
...adapterRules,
19+
...responsiveUnitRules,
1820
];
1921

2022
export function lintHyperframeHtml(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect } from "vitest";
2+
import { lintHyperframeHtml } from "../hyperframeLinter";
3+
4+
function findByCode(html: string, code: string) {
5+
return lintHyperframeHtml(html).findings.filter((f) => f.code === code);
6+
}
7+
8+
describe("prefer_container_units", () => {
9+
it("flags px positioning on elements inside a composition", () => {
10+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
11+
<h1 style="position:absolute; left:96px; top:108px; font-size:64px;">Title</h1>
12+
</div>`;
13+
const findings = findByCode(html, "prefer_container_units");
14+
expect(findings.length).toBeGreaterThanOrEqual(3);
15+
expect(findings.some((f) => f.message.includes("left"))).toBe(true);
16+
expect(findings.some((f) => f.message.includes("top"))).toBe(true);
17+
expect(findings.some((f) => f.message.includes("font-size"))).toBe(true);
18+
});
19+
20+
it("suggests cqw for horizontal properties", () => {
21+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
22+
<div style="left:192px;">content</div>
23+
</div>`;
24+
const findings = findByCode(html, "prefer_container_units");
25+
expect(findings[0].message).toContain("cqw");
26+
});
27+
28+
it("suggests cqh for vertical properties", () => {
29+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
30+
<div style="top:108px;">content</div>
31+
</div>`;
32+
const findings = findByCode(html, "prefer_container_units");
33+
expect(findings[0].message).toContain("cqh");
34+
});
35+
36+
it("calculates correct container unit values", () => {
37+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
38+
<div style="left:96px;">content</div>
39+
</div>`;
40+
const findings = findByCode(html, "prefer_container_units");
41+
expect(findings[0].message).toContain("5cqw");
42+
});
43+
44+
it("ignores small px values (borders, shadows)", () => {
45+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
46+
<div style="border-radius:2px; width:4px;">content</div>
47+
</div>`;
48+
const findings = findByCode(html, "prefer_container_units");
49+
expect(findings).toHaveLength(0);
50+
});
51+
52+
it("ignores composition root elements", () => {
53+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080" style="width:1920px; height:1080px;">
54+
<p>content</p>
55+
</div>`;
56+
const findings = findByCode(html, "prefer_container_units");
57+
expect(findings).toHaveLength(0);
58+
});
59+
60+
it("ignores script, style, and audio tags", () => {
61+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
62+
<script style="width:500px;"></script>
63+
<style>body { width: 1920px; }</style>
64+
<audio style="width:100px;" data-start="0" src="vo.mp3"></audio>
65+
</div>`;
66+
const findings = findByCode(html, "prefer_container_units");
67+
expect(findings).toHaveLength(0);
68+
});
69+
70+
it("severity is info (suggestion, not error)", () => {
71+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
72+
<div style="left:200px;">content</div>
73+
</div>`;
74+
const findings = findByCode(html, "prefer_container_units");
75+
expect(findings[0].severity).toBe("info");
76+
});
77+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
2+
import { readAttr, truncateSnippet } from "../utils";
3+
4+
const POSITION_PROPS =
5+
/\b(left|right|top|bottom|width|height|font-size|padding|margin|gap|border-radius)\s*:\s*(\d+(?:\.\d+)?)px/gi;
6+
7+
function isCompositionRoot(tag: OpenTag): boolean {
8+
return Boolean(readAttr(tag.raw, "data-composition-id"));
9+
}
10+
11+
function suggestUnit(prop: string): string {
12+
const p = prop.toLowerCase();
13+
if (p === "top" || p === "bottom" || p === "height") return "cqh";
14+
return "cqw";
15+
}
16+
17+
function pxToContainerUnit(
18+
px: number,
19+
prop: string,
20+
compWidth: number,
21+
compHeight: number,
22+
): string {
23+
const unit = suggestUnit(prop);
24+
const base = unit === "cqh" ? compHeight : compWidth;
25+
if (base <= 0) return `${px}px`;
26+
const value = (px / base) * 100;
27+
const rounded = Math.round(value * 100) / 100;
28+
return `${rounded}${unit}`;
29+
}
30+
31+
export const responsiveUnitRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
32+
(ctx) => {
33+
const findings: HyperframeLintFinding[] = [];
34+
if (!ctx.rootTag) return findings;
35+
36+
const widthAttr = readAttr(ctx.rootTag.raw, "data-width");
37+
const heightAttr = readAttr(ctx.rootTag.raw, "data-height");
38+
const compWidth = parseInt(widthAttr || "1920", 10);
39+
const compHeight = parseInt(heightAttr || "1080", 10);
40+
41+
for (const tag of ctx.tags) {
42+
if (isCompositionRoot(tag)) continue;
43+
if (tag.name === "script" || tag.name === "style" || tag.name === "audio") continue;
44+
45+
const style = readAttr(tag.raw, "style") || "";
46+
if (!style) continue;
47+
48+
let match: RegExpExecArray | null;
49+
POSITION_PROPS.lastIndex = 0;
50+
while ((match = POSITION_PROPS.exec(style)) !== null) {
51+
const prop = match[1] ?? "";
52+
const px = parseFloat(match[2] ?? "0");
53+
if (px <= 4) continue;
54+
55+
const elementId = readAttr(tag.raw, "id") || undefined;
56+
const suggested = pxToContainerUnit(px, prop, compWidth, compHeight);
57+
58+
findings.push({
59+
code: "prefer_container_units",
60+
severity: "info",
61+
message: `${prop}: ${px}px could be ${suggested} for aspect-ratio independence.`,
62+
elementId,
63+
fixHint: `Use container-relative units (cqw/cqh) instead of px for layout properties. Add container-type:size to the composition root, then replace ${prop}: ${px}px with ${prop}: ${suggested}.`,
64+
snippet: truncateSnippet(tag.raw),
65+
});
66+
}
67+
}
68+
69+
return findings;
70+
},
71+
];

packages/core/src/runtime/init.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,6 @@ export function initSandboxRuntimeModular(): void {
230230
if (forcedHeight) rootEl.style.height = forcedHeight;
231231
if (forcedWidth) rootEl.style.setProperty("--comp-width", forcedWidth);
232232
if (forcedHeight) rootEl.style.setProperty("--comp-height", forcedHeight);
233-
rootEl.style.containerType = "size";
234233
};
235234

236235
const sanitizeCompositionDurationAttributes = () => {

0 commit comments

Comments
 (0)