Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ describe("initSandboxRuntimeModular", () => {
window.__hfRuntimeTeardown?.();
resetRuntimeDataForTests();
document.body.innerHTML = "";
// The runtime sizes html/body from the root, so an init'd test would
// otherwise leave inline dimensions behind for the next one.
document.documentElement.removeAttribute("style");
document.body.removeAttribute("style");
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
delete window.__player;
delete window.__playerReady;
Expand Down Expand Up @@ -293,6 +297,43 @@ describe("initSandboxRuntimeModular", () => {
expect(caption.getAttribute("data-start")).toBe("0");
});

/**
* GH#4001: a root edited to portrait dims whose scaffolded `html, body` CSS
* is left at the old landscape size renders successfully with everything
* below the stale body height clipped away by body's own `overflow: hidden`.
* That guard stays (it keeps browser-default margins out of renders); sizing
* body to the root it contains is what stops it clipping. `applyResolutionPreset`
* (packages/cli/src/commands/init.ts) already keeps html/body in sync when a
* project scaffolds WITH `--resolution`, so only the edit-afterward path needs
* this — forcing the same values back is a no-op for the scaffolded path.
*/
it("mirrors the root's forced dimensions onto html/body", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1080");
root.setAttribute("data-height", "1920");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };

// Mimics the scaffolded template's `html, body { width: 1920px; height:
// 1080px; }` — the stale landscape size this composition was edited on
// top of without `--resolution`.
document.documentElement.style.width = "1920px";
document.documentElement.style.height = "1080px";
document.body.style.width = "1920px";
document.body.style.height = "1080px";

initSandboxRuntimeModular();

expect(document.documentElement.style.width).toBe("1080px");
expect(document.documentElement.style.height).toBe("1920px");
expect(document.body.style.width).toBe("1080px");
expect(document.body.style.height).toBe("1920px");
});

it("resolves Studio hold as a deterministic step at the segment end", () => {
const defaultEase = (progress: number) => progress;
const originalParseEase = vi.fn(() => defaultEase);
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,25 @@ export function initSandboxRuntimeModular(): void {
if (forcedHeight) rootEl.style.height = forcedHeight;
if (forcedWidth) rootEl.style.setProperty("--comp-width", forcedWidth);
if (forcedHeight) rootEl.style.setProperty("--comp-height", forcedHeight);
// A scaffolded project's `html, body` CSS is fixed at init time to whatever
// resolution the template shipped with. An agent that edits ONLY the root's
// data-width/data-height (without `hyperframes init --resolution`, which
// rewrites html/body together with the root) leaves body at the stale
// size, so its `overflow: hidden` (set unconditionally above, to keep
// browser-default margins from bleeding into renders as white bars)
// clips this composition — sized correctly above — at the stale height.
// Mirror the SAME forced values onto documentElement/body (not a second
// read of the root's own dimensions): once body's own size agrees with
// the root it contains, `overflow: hidden` clips nothing that matters and
// the white-bar guard stays intact. `findRootCompositionEl` above returns
// the outermost `[data-root="true"]` composition by convention, not by a
// structural guarantee — this only ever affects a document whose author
// marked a NESTED composition `data-root="true"` too, which nothing in
// this runtime currently validates.
if (forcedWidth) document.documentElement.style.width = forcedWidth;
if (forcedHeight) document.documentElement.style.height = forcedHeight;
if (forcedWidth) document.body.style.width = forcedWidth;
if (forcedHeight) document.body.style.height = forcedHeight;
};

const sanitizeCompositionDurationAttributes = () => {
Expand Down
61 changes: 61 additions & 0 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ ${rootContent}
</html>`;
}

/** A portrait root inside a document whose scaffold copies of the resolution
* are supplied by the caller, so they can be aligned or left stale. */
function portraitCompositionWithScaffold(bodyCss: string, viewportContent: string): string {
return `
<html>
<head>
<meta name="viewport" content="${viewportContent}" />
<style>
html, body { ${bodyCss} overflow: hidden; }
</style>
</head>
<body>
<div id="root" data-composition-id="c1" data-width="1080" data-height="1920"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}

describe("core rules", () => {
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
const html = `
Expand Down Expand Up @@ -115,6 +133,49 @@ describe("core rules", () => {
expect(finding?.severity).toBe("error");
});

it("reports root_dimensions_mismatch when html/body CSS and the viewport meta are still the scaffolded landscape size", async () => {
// GH#4001: the root is edited to portrait without `hyperframes init
// --resolution`, the only thing that otherwise keeps the scaffold's copies
// of the resolution in sync. The stale landscape body (overflow: hidden)
// then clips the correctly-sized root at its old height.
const html = portraitCompositionWithScaffold(
"width: 1920px; height: 1080px;",
"width=1920, height=1080",
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_dimensions_mismatch");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain("html/body CSS is 1920x1080");
expect(finding?.message).toContain("the viewport meta is 1920x1080");
});

it("reads a stale html/body size authored height-before-width", async () => {
const html = portraitCompositionWithScaffold(
"height: 1080px; width: 1920px;",
"width=1080, height=1920",
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_dimensions_mismatch");
expect(finding?.message).toContain("html/body CSS is 1920x1080");
expect(finding?.message).not.toContain("viewport");
});

it("does not report root_dimensions_mismatch when the scaffold agrees with the root", async () => {
const html = portraitCompositionWithScaffold(
"width: 1080px; height: 1920px;",
"width=1080, height=1920",
);
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_dimensions_mismatch")).toBeUndefined();
});

it("does not report root_dimensions_mismatch for a sub-composition fragment with no html/body/viewport to compare", async () => {
const html = `<div data-composition-id="c1" data-width="1080" data-height="1920"></div>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_dimensions_mismatch")).toBeUndefined();
});

it("accepts body as the composition root", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
Expand Down
87 changes: 87 additions & 0 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import {
HTML_BODY_CSS_WIDTH_FIRST_RE,
HTML_BODY_CSS_HEIGHT_FIRST_RE,
VIEWPORT_META_SIZE_RE,
} from "@hyperframes/parsers";
import {
readAttr,
readDecodedAttr,
Expand Down Expand Up @@ -232,6 +237,50 @@ function findVisibleMarkupCommentLeak(source: string): string | null {
return null;
}

type ScaffoldSize = { width: string; height: string };

function readHtmlBodyCssSize(source: string): ScaffoldSize | null {
const widthFirst = source.match(HTML_BODY_CSS_WIDTH_FIRST_RE);
if (widthFirst) {
const [, width = "", height = ""] = widthFirst;
return { width, height };
}
const heightFirst = source.match(HTML_BODY_CSS_HEIGHT_FIRST_RE);
if (heightFirst) {
const [, height = "", width = ""] = heightFirst;
return { width, height };
}
return null;
}

function readViewportMetaSize(source: string): ScaffoldSize | null {
const match = source.match(VIEWPORT_META_SIZE_RE);
if (!match) return null;
const [, width = "", height = ""] = match;
return { width, height };
}

function describeSizeMismatch(
label: string,
size: ScaffoldSize | null,
dataWidth: string,
dataHeight: string,
): string | null {
if (!size || (size.width === dataWidth && size.height === dataHeight)) return null;
return `${label} is ${size.width}x${size.height}`;
}

function findScaffoldSizeMismatches(
source: string,
dataWidth: string,
dataHeight: string,
): string[] {
return [
describeSizeMismatch("html/body CSS", readHtmlBodyCssSize(source), dataWidth, dataHeight),
describeSizeMismatch("the viewport meta", readViewportMetaSize(source), dataWidth, dataHeight),
].filter((mismatch): mismatch is string => mismatch !== null);
}

export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// id_requires_css_escape
({ tags }) => {
Expand Down Expand Up @@ -279,6 +328,44 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},

// root_dimensions_mismatch
//
// Render size and the runtime's forced #root size both read the root's own
// data-width/data-height, so they stay correct. But editing only those two
// attributes — rather than scaffolding with `hyperframes init --resolution`,
// which rewrites the scaffold's other copies of the resolution too — leaves
// the `html, body` CSS and the `<meta viewport>` at the old value, and a
// stale body with `overflow: hidden` visually clips the correctly-sized
// root. `hyperframes check`'s layout audits can't see it: they measure
// against the root's own (already-correct) rect, not the body's.
//
// A sub-composition fragment has no html/body or viewport to compare
// against, which makes this top-level-only without a separate guard.
({ rootTag, source }) => {
if (!rootTag) return [];
const dataWidth = readAttr(rootTag.raw, "data-width");
const dataHeight = readAttr(rootTag.raw, "data-height");
if (!dataWidth || !dataHeight) return [];

const mismatches = findScaffoldSizeMismatches(source, dataWidth, dataHeight);
if (mismatches.length === 0) return [];

return [
{
code: "root_dimensions_mismatch",
severity: "warning",
message:
`Root composition declares data-width="${dataWidth}" data-height="${dataHeight}", ` +
`but ${mismatches.join(" and ")}. The scaffolded body clips the composition at its ` +
`old size.`,
elementId: readAttr(rootTag.raw, "id") || undefined,
fixHint:
"update html/body CSS and the meta viewport to match, or scaffold with `hyperframes init --resolution portrait`",
snippet: truncateSnippet(rootTag.raw),
},
];
},

// unbalanced_style_tags
({ source }) => {
let opens = 0;
Expand Down
25 changes: 25 additions & 0 deletions packages/parsers/src/canvasScaffoldPatterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Where a scaffolded project's canvas resolution lives besides the composition
* root's own `data-width`/`data-height`: the inline `html, body { width;
* height }` CSS block and the `<meta name="viewport">` `content` attribute.
* `@hyperframes/cli`'s `applyResolutionPreset` rewrites those same two places
* when a project scaffolds with `--resolution`, but keeps its own
* prefix-capturing regexes for that replace — these are a separate read-only
* definition of the same locations, not the literal patterns it uses.
*
* Lives in `@hyperframes/parsers` rather than `@hyperframes/cli` because
* `@hyperframes/lint` cannot depend on `cli` (`cli` depends on `lint`, not the
* reverse), and `parsers` is a dependency both already share.
*/

/** Matches `html, body { ...width: <n>px... height: <n>px... }`. */
export const HTML_BODY_CSS_WIDTH_FIRST_RE =
/html\s*,\s*body\s*\{[^}]*?width:\s*(\d+)px[^}]*?height:\s*(\d+)px/i;

/** Matches the same block with height authored before width. */
export const HTML_BODY_CSS_HEIGHT_FIRST_RE =
/html\s*,\s*body\s*\{[^}]*?height:\s*(\d+)px[^}]*?width:\s*(\d+)px/i;

/** Matches `<meta ... name="viewport" ... content="width=<n>, height=<n>">`. */
export const VIEWPORT_META_SIZE_RE =
/<meta[^>]*name=["']viewport["'][^>]*content=["']width=(\d+),\s*height=(\d+)/i;
1 change: 1 addition & 0 deletions packages/parsers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./outputResolutionCompatibility.js";
export { unrollComputedTimeline } from "./gsapUnroll.js";
export { queryByAttr } from "./utils/cssSelector.js";
export * from "./compositionContract.js";
export * from "./canvasScaffoldPatterns.js";

// Pure, browser-safe composition primitives shared by the linter (so it can
// consume them without depending on @hyperframes/core). The Node-only asset
Expand Down
Loading