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