From b4fbc3e264a946a9b8ca9bf615f46ba8a37cf529 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Wed, 16 Sep 2026 15:50:49 +0000 Subject: [PATCH] fix(core,lint): keep html/body sized to the composition root, warn when a scaffold's copies drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composition whose root declares portrait data-width/data-height renders at the correct output size, but a project scaffolded landscape (init without --resolution) still ships html/body CSS and a viewport meta at the old landscape size. The stale body's overflow:hidden then clips the correctly-sized root at the old height, so everything past it renders as page background instead of real content. applyCompositionSizing now mirrors the SAME forced width/height it already computes for the root onto documentElement/body, reusing rather than recomputing them. overflow:hidden is left untouched (a deliberate anti-white-bar guard set earlier in the same init pass): once body's own size agrees with the root it contains, it clips nothing that matters. Runs in both preview and render, since both load the one bundled runtime artifact this file builds into. Adds a warning-level root_dimensions_mismatch lint rule alongside root_missing_dimensions, comparing the root's dims against the html/body CSS block and the viewport meta - the two other places a scaffold's resolution lives besides the root itself. The read-only regexes live in @hyperframes/parsers (packages/lint cannot depend on packages/cli, which owns the closest existing pattern in applyResolutionPreset) so a future caller has one place to update if the scaffold's shape changes, rather than a rule silently comparing against a guess. Closes #4001. Co-Authored-By: Miguel Ángel --- packages/core/src/runtime/init.test.ts | 41 +++++++++ packages/core/src/runtime/init.ts | 19 ++++ packages/lint/src/rules/core.test.ts | 61 +++++++++++++ packages/lint/src/rules/core.ts | 87 +++++++++++++++++++ .../parsers/src/canvasScaffoldPatterns.ts | 25 ++++++ packages/parsers/src/index.ts | 1 + 6 files changed, 234 insertions(+) create mode 100644 packages/parsers/src/canvasScaffoldPatterns.ts diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index cae10882d3..f495a01aa8 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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; delete window.__player; delete window.__playerReady; @@ -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); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index eae0f61dc4..66f141cece 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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 = () => { diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index fb0c2ec5e3..17dd9dbc56 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -19,6 +19,24 @@ ${rootContent} `; } +/** 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 ` + + + + + + +
+ + +`; +} + describe("core rules", () => { it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => { const html = ` @@ -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 = `
`; + 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 = ` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 071c24f4c4..f0eca83881 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -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, @@ -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 }) => { @@ -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 `` 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; diff --git a/packages/parsers/src/canvasScaffoldPatterns.ts b/packages/parsers/src/canvasScaffoldPatterns.ts new file mode 100644 index 0000000000..92a9251c26 --- /dev/null +++ b/packages/parsers/src/canvasScaffoldPatterns.ts @@ -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 `` `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: px... height: 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 ``. */ +export const VIEWPORT_META_SIZE_RE = + /]*name=["']viewport["'][^>]*content=["']width=(\d+),\s*height=(\d+)/i; diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index 7570ac5354..b5df80769a 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -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