diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts
index 513d76cfe..df278365a 100644
--- a/packages/core/src/lint/hyperframeLinter.ts
+++ b/packages/core/src/lint/hyperframeLinter.ts
@@ -7,6 +7,7 @@ import { gsapRules } from "./rules/gsap";
import { captionRules } from "./rules/captions";
import { compositionRules } from "./rules/composition";
import { adapterRules } from "./rules/adapters";
+import { responsiveUnitRules } from "./rules/responsiveUnits";
const ALL_RULES = [
...coreRules,
@@ -15,6 +16,7 @@ const ALL_RULES = [
...captionRules,
...compositionRules,
...adapterRules,
+ ...responsiveUnitRules,
];
export function lintHyperframeHtml(
diff --git a/packages/core/src/lint/rules/responsiveUnits.test.ts b/packages/core/src/lint/rules/responsiveUnits.test.ts
new file mode 100644
index 000000000..c7b5976ae
--- /dev/null
+++ b/packages/core/src/lint/rules/responsiveUnits.test.ts
@@ -0,0 +1,230 @@
+import { describe, it, expect } from "vitest";
+import { lintHyperframeHtml } from "../hyperframeLinter";
+
+function findByCode(html: string, code: string) {
+ return lintHyperframeHtml(html).findings.filter((f) => f.code === code);
+}
+
+describe("prefer_container_units", () => {
+ it("flags px positioning on elements inside a composition", () => {
+ const html = `
+
Title
+ `;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings.length).toBeGreaterThanOrEqual(3);
+ expect(findings.some((f) => f.message.includes("left"))).toBe(true);
+ expect(findings.some((f) => f.message.includes("top"))).toBe(true);
+ expect(findings.some((f) => f.message.includes("font-size"))).toBe(true);
+ });
+
+ it("suggests cqw for horizontal properties", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings[0].message).toContain("cqw");
+ });
+
+ it("suggests cqh for vertical properties", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings[0].message).toContain("cqh");
+ });
+
+ it("calculates correct container unit values", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings[0].message).toContain("5cqw");
+ });
+
+ it("ignores small px values (borders, shadows)", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("skips composition root but flags children with px", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings).toHaveLength(1);
+ expect(findings[0].message).toContain("left");
+ });
+
+ it("ignores script, style, and audio tags", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("severity is info (suggestion, not error)", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings[0].severity).toBe("info");
+ });
+
+ it("does not flag border-radius", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("flags px in style blocks", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("returns no findings for HTML without a composition root", () => {
+ const html = `no composition
`;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("handles malformed data-width gracefully", () => {
+ const html = ``;
+ const findings = findByCode(html, "prefer_container_units");
+ expect(findings).toHaveLength(1);
+ expect(findings[0].message).toContain("5cqw");
+ });
+});
+
+describe("composition_root_missing_container_type", () => {
+ it("warns when cqw/cqh used but root lacks container-type", () => {
+ const html = `
+
Title
+ `;
+ const findings = findByCode(html, "composition_root_missing_container_type");
+ expect(findings).toHaveLength(1);
+ expect(findings[0].severity).toBe("warning");
+ });
+
+ it("does not warn when root has container-type:size", () => {
+ const html = `
+
Title
+ `;
+ const findings = findByCode(html, "composition_root_missing_container_type");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("does not warn when no cqw/cqh units are used", () => {
+ const html = `
+
Title
+ `;
+ const findings = findByCode(html, "composition_root_missing_container_type");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("detects cqw/cqh in style blocks", () => {
+ const html = `
+
+
Title
+ `;
+ const findings = findByCode(html, "composition_root_missing_container_type");
+ expect(findings).toHaveLength(1);
+ });
+
+ it("accepts container-type from style block on root", () => {
+ const html = `
+
+
Title
+ `;
+ const findings = findByCode(html, "composition_root_missing_container_type");
+ expect(findings).toHaveLength(0);
+ });
+});
+
+describe("gsap_prefer_container_units", () => {
+ it("flags GSAP tween props with bare number values", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings.length).toBeGreaterThanOrEqual(2);
+ expect(findings.some((f) => f.message.includes("x:"))).toBe(true);
+ expect(findings.some((f) => f.message.includes("y:"))).toBe(true);
+ });
+
+ it("suggests cqw for horizontal GSAP props", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings[0].message).toContain("5cqw");
+ });
+
+ it("suggests cqh for vertical GSAP props", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings[0].message).toContain("10cqh");
+ });
+
+ it("handles negative values", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings[0].message).toContain("-10cqw");
+ });
+
+ it("ignores small values", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("ignores non-position props like opacity and duration", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("does not flag scripts without GSAP tween calls", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "gsap_prefer_container_units");
+ expect(findings).toHaveLength(0);
+ });
+});
diff --git a/packages/core/src/lint/rules/responsiveUnits.ts b/packages/core/src/lint/rules/responsiveUnits.ts
new file mode 100644
index 000000000..bb694ce29
--- /dev/null
+++ b/packages/core/src/lint/rules/responsiveUnits.ts
@@ -0,0 +1,236 @@
+import postcss from "postcss";
+import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
+import { readAttr, truncateSnippet } from "../utils";
+
+const HORIZONTAL_PROPS = new Set([
+ "left",
+ "right",
+ "width",
+ "max-width",
+ "min-width",
+ "padding-left",
+ "padding-right",
+ "margin-left",
+ "margin-right",
+ "gap",
+ "column-gap",
+ "font-size",
+]);
+
+const VERTICAL_PROPS = new Set([
+ "top",
+ "bottom",
+ "height",
+ "max-height",
+ "min-height",
+ "padding-top",
+ "padding-bottom",
+ "margin-top",
+ "margin-bottom",
+ "row-gap",
+]);
+
+const ALL_FLAGGED_PROPS = new Set([...HORIZONTAL_PROPS, ...VERTICAL_PROPS]);
+
+const PX_VALUE_PATTERN = /^(\d+(?:\.\d+)?)px$/;
+const CQ_UNIT_PATTERN = /\b\d+(?:\.\d+)?cq[wh]\b/;
+const CONTAINER_TYPE_PATTERN = /container-type\s*:\s*(size|inline-size)/;
+const MIN_PX_THRESHOLD = 4;
+
+function isCompositionRoot(tag: OpenTag): boolean {
+ return Boolean(readAttr(tag.raw, "data-composition-id"));
+}
+
+function suggestUnit(prop: string): string {
+ return VERTICAL_PROPS.has(prop) ? "cqh" : "cqw";
+}
+
+function pxToContainerUnit(
+ px: number,
+ prop: string,
+ compWidth: number,
+ compHeight: number,
+): string {
+ const unit = suggestUnit(prop);
+ const base = unit === "cqh" ? compHeight : compWidth;
+ if (!Number.isFinite(base) || base <= 0) return `${px}px`;
+ const value = (px / base) * 100;
+ const rounded = Math.round(value * 100) / 100;
+ return `${rounded}${unit}`;
+}
+
+function extractPxFindings(
+ style: string,
+ compWidth: number,
+ compHeight: number,
+ elementId: string | undefined,
+ snippet: string | undefined,
+): HyperframeLintFinding[] {
+ const findings: HyperframeLintFinding[] = [];
+ const pairs = style.split(";");
+ for (const pair of pairs) {
+ const colonIdx = pair.indexOf(":");
+ if (colonIdx === -1) continue;
+ const prop = pair.slice(0, colonIdx).trim().toLowerCase();
+ const value = pair.slice(colonIdx + 1).trim();
+ if (!ALL_FLAGGED_PROPS.has(prop)) continue;
+ const match = PX_VALUE_PATTERN.exec(value);
+ if (!match) continue;
+ const px = parseFloat(match[1] ?? "0");
+ if (px <= MIN_PX_THRESHOLD) continue;
+ const suggested = pxToContainerUnit(px, prop, compWidth, compHeight);
+ findings.push({
+ code: "prefer_container_units",
+ severity: "info",
+ message: `${prop}: ${px}px could be ${suggested} for aspect-ratio independence.`,
+ elementId,
+ fixHint: `Use container-relative units (cqw/cqh) instead of px. Ensure the composition root has container-type:size, then replace ${prop}: ${px}px with ${prop}: ${suggested}.`,
+ snippet,
+ });
+ }
+ return findings;
+}
+
+export const responsiveUnitRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
+ // prefer_container_units — suggest cqw/cqh for px layout properties
+ (ctx) => {
+ const findings: HyperframeLintFinding[] = [];
+ if (!ctx.rootTag || !readAttr(ctx.rootTag.raw, "data-composition-id")) return findings;
+
+ const widthRaw = parseInt(readAttr(ctx.rootTag.raw, "data-width") || "", 10);
+ const heightRaw = parseInt(readAttr(ctx.rootTag.raw, "data-height") || "", 10);
+ const compWidth = Number.isFinite(widthRaw) && widthRaw > 0 ? widthRaw : 1920;
+ const compHeight = Number.isFinite(heightRaw) && heightRaw > 0 ? heightRaw : 1080;
+
+ for (const tag of ctx.tags) {
+ if (isCompositionRoot(tag)) continue;
+ if (tag.name === "script" || tag.name === "style" || tag.name === "audio") continue;
+ const style = readAttr(tag.raw, "style") || "";
+ if (!style) continue;
+ const elementId = readAttr(tag.raw, "id") || undefined;
+ findings.push(
+ ...extractPxFindings(style, compWidth, compHeight, elementId, truncateSnippet(tag.raw)),
+ );
+ }
+
+ for (const block of ctx.styles) {
+ try {
+ const root = postcss.parse(block.content);
+ root.walkDecls((decl) => {
+ const prop = decl.prop.toLowerCase();
+ if (!ALL_FLAGGED_PROPS.has(prop)) return;
+ const match = PX_VALUE_PATTERN.exec(decl.value.trim());
+ if (!match) return;
+ const px = parseFloat(match[1] ?? "0");
+ if (px <= MIN_PX_THRESHOLD) return;
+ const suggested = pxToContainerUnit(px, prop, compWidth, compHeight);
+ findings.push({
+ code: "prefer_container_units",
+ severity: "info",
+ message: `${prop}: ${px}px could be ${suggested} for aspect-ratio independence.`,
+ fixHint: `Use container-relative units (cqw/cqh) instead of px. Ensure the composition root has container-type:size, then replace ${prop}: ${px}px with ${prop}: ${suggested}.`,
+ });
+ });
+ } catch {
+ void 0;
+ }
+ }
+
+ return findings;
+ },
+
+ // composition_root_missing_container_type — fires when cqw/cqh used but root lacks container-type
+ (ctx) => {
+ if (!ctx.rootTag || !readAttr(ctx.rootTag.raw, "data-composition-id")) return [];
+ const rootStyle = readAttr(ctx.rootTag.raw, "style") || "";
+ const hasContainerType = CONTAINER_TYPE_PATTERN.test(rootStyle);
+ if (hasContainerType) return [];
+
+ for (const block of ctx.styles) {
+ if (CONTAINER_TYPE_PATTERN.test(block.content)) return [];
+ }
+
+ let usesCqUnits = false;
+ for (const tag of ctx.tags) {
+ const style = readAttr(tag.raw, "style") || "";
+ if (CQ_UNIT_PATTERN.test(style)) {
+ usesCqUnits = true;
+ break;
+ }
+ }
+ if (!usesCqUnits) {
+ for (const block of ctx.styles) {
+ if (CQ_UNIT_PATTERN.test(block.content)) {
+ usesCqUnits = true;
+ break;
+ }
+ }
+ }
+
+ if (!usesCqUnits) return [];
+
+ return [
+ {
+ code: "composition_root_missing_container_type",
+ severity: "warning",
+ message:
+ "Composition uses cqw/cqh units but the root element is missing container-type:size. Container query units will resolve against the viewport instead of the composition dimensions.",
+ fixHint:
+ 'Add style="container-type:size" to the composition root element (the one with data-composition-id).',
+ snippet: truncateSnippet(ctx.rootTag.raw),
+ },
+ ];
+ },
+
+ // gsap_prefer_container_units — flag GSAP tween props using bare numbers (px) for position/size
+ (ctx) => {
+ const findings: HyperframeLintFinding[] = [];
+ if (!ctx.rootTag || !readAttr(ctx.rootTag.raw, "data-composition-id")) return findings;
+
+ const widthRaw = parseInt(readAttr(ctx.rootTag.raw, "data-width") || "", 10);
+ const heightRaw = parseInt(readAttr(ctx.rootTag.raw, "data-height") || "", 10);
+ const compWidth = Number.isFinite(widthRaw) && widthRaw > 0 ? widthRaw : 1920;
+ const compHeight = Number.isFinite(heightRaw) && heightRaw > 0 ? heightRaw : 1080;
+
+ const GSAP_V_PROPS = new Set(["y", "top", "bottom", "height"]);
+ const GSAP_TWEEN = /\.(to|from|fromTo|set)\s*\(/g;
+ const PROP_NUM =
+ /\b(x|y|left|right|top|bottom|width|height|fontSize|padding)\s*:\s*(-?\d+(?:\.\d+)?)\b/g;
+
+ for (const script of ctx.scripts) {
+ if (!GSAP_TWEEN.test(script.content)) {
+ GSAP_TWEEN.lastIndex = 0;
+ continue;
+ }
+ GSAP_TWEEN.lastIndex = 0;
+
+ let propMatch: RegExpExecArray | null;
+ PROP_NUM.lastIndex = 0;
+ while ((propMatch = PROP_NUM.exec(script.content)) !== null) {
+ const prop = propMatch[1] ?? "";
+ const value = parseFloat(propMatch[2] ?? "0");
+ if (Math.abs(value) <= MIN_PX_THRESHOLD) continue;
+
+ const absValue = Math.abs(value);
+ const sign = value < 0 ? "-" : "";
+ let suggested: string;
+ if (GSAP_V_PROPS.has(prop)) {
+ const cq = Math.round((absValue / compHeight) * 100 * 100) / 100;
+ suggested = `"${sign}${cq}cqh"`;
+ } else {
+ const cq = Math.round((absValue / compWidth) * 100 * 100) / 100;
+ suggested = `"${sign}${cq}cqw"`;
+ }
+
+ findings.push({
+ code: "gsap_prefer_container_units",
+ severity: "info",
+ message: `GSAP ${prop}: ${value} (px) could be ${suggested} for aspect-ratio independence.`,
+ fixHint: `Use string values with cqw/cqh in GSAP tweens for responsive positioning: ${prop}: ${suggested}`,
+ });
+ }
+ }
+
+ return findings;
+ },
+];
diff --git a/skills/hyperframes/patterns.md b/skills/hyperframes/patterns.md
index 94121dd6d..605739894 100644
--- a/skills/hyperframes/patterns.md
+++ b/skills/hyperframes/patterns.md
@@ -1,5 +1,47 @@
# Composition Patterns
+## Responsive Sizing (preferred for multi-ratio support)
+
+Use container-relative units (`cqw`/`cqh`) instead of pixels for positioning, sizing, and typography. The composition root is a CSS container — these units resolve to percentages of its dimensions, so the same CSS works at 16:9, 9:16, 1:1, or any custom ratio without layout breakage.
+
+```html
+
+
+
+ Title Text
+
+
+
+
+
Body text scales proportionally.
+
+
+```
+
+**When to use `cqw`/`cqh` vs `px`:**
+
+- Typography, spacing, border-radius, padding → `cqw` (scales with width)
+- Vertical positioning → `cqh` (scales with height)
+- Media elements that fill a region → `width:100%; height:100%` on the element, size the wrapper with `cqw`/`cqh`
+- Pixel values are fine for: border widths (`1px`, `2px`), box shadows, blur radii — things that shouldn't scale
+
+**GSAP animations with container units:** GSAP can animate to container-unit values directly:
+
+```js
+tl.from("#title", { y: "5cqh", opacity: 0, duration: 0.8 });
+tl.to("#card", { width: "80cqw", duration: 0.6 }, 0.3);
+```
+
## Picture-in-Picture (Video in a Frame)
Animate a wrapper div for position/size. The video fills the wrapper. The wrapper has NO data attributes.