diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d3f614b7ed..7d333c2707 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -53,6 +53,11 @@ import { normalizeResolutionFlag, type CanvasResolution, } from "@hyperframes/core"; +import { + HTML_BODY_CSS_HEIGHT_FIRST_RE, + HTML_BODY_CSS_WIDTH_FIRST_RE, + VIEWPORT_META_SIZE_RE, +} from "@hyperframes/parsers"; function resolveScaffoldTemplateId(exampleFlag: string | undefined, hasMediaFile: boolean): string { const example = exampleFlag === "agent" ? "blank" : exampleFlag; @@ -527,20 +532,18 @@ export function applyResolutionPreset(destDir: string, resolution: CanvasResolut // Inline `html, body { ... }` CSS: handle width-before-height and // height-before-width orderings. Hand-authored templates can use either. - const bodyCssRe = /(html\s*,\s*body\s*\{[^}]*?width:\s*)\d+px([^}]*?height:\s*)\d+px/i; - if (bodyCssRe.test(html)) { - html = html.replace(bodyCssRe, `$1${width}px$2${height}px`); + // Groups 1 and 3 are the text before each dimension, 2 and 4 the digits. + if (HTML_BODY_CSS_WIDTH_FIRST_RE.test(html)) { + html = html.replace(HTML_BODY_CSS_WIDTH_FIRST_RE, `$1${width}px$3${height}px`); changed = true; } - const bodyCssReverseRe = /(html\s*,\s*body\s*\{[^}]*?height:\s*)\d+px([^}]*?width:\s*)\d+px/i; - if (bodyCssReverseRe.test(html)) { - html = html.replace(bodyCssReverseRe, `$1${height}px$2${width}px`); + if (HTML_BODY_CSS_HEIGHT_FIRST_RE.test(html)) { + html = html.replace(HTML_BODY_CSS_HEIGHT_FIRST_RE, `$1${height}px$3${width}px`); changed = true; } - const viewportRe = /(]*name=["']viewport["'][^>]*content=["'])width=\d+,\s*height=\d+/i; - if (viewportRe.test(html)) { - html = html.replace(viewportRe, `$1width=${width}, height=${height}`); + if (VIEWPORT_META_SIZE_RE.test(html)) { + html = html.replace(VIEWPORT_META_SIZE_RE, `$1${width}$3${height}`); changed = true; } diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index 17dd9dbc56..5cef1f332d 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -176,6 +176,71 @@ describe("core rules", () => { expect(result.findings.find((f) => f.code === "root_dimensions_mismatch")).toBeUndefined(); }); + it("does not report root_dimensions_mismatch for a full sub-composition document whose own viewport meta disagrees with its root", async () => { + // Matches the hf2550 flowchart-vertical fixture's shape: a full standalone + // document mounted as a sub-composition. See the rule's comment in core.ts + // for why its own never reaches the rendering document. + const html = ` + + + + + + +
+ + +`; + const result = await lintHyperframeHtml(html, { isSubComposition: true }); + expect(result.findings.find((f) => f.code === "root_dimensions_mismatch")).toBeUndefined(); + }); + + it("still reports root_dimensions_mismatch for the same shape linted as a top-level composition, with no-clipping-risk wording since there is no html/body CSS block at all", async () => { + const html = ` + + + + + + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "root_dimensions_mismatch"); + expect(finding).toBeDefined(); + // No html/body CSS block is present here at all (the real hf2550 fixture + // shape) -- distinct from the "present and matching" case covered below -- + // so the "absent" and "matches" cases of describeSizeMismatch must both + // route to the same no-clipping-risk wording, not just the "matches" one. + expect(finding?.message).not.toContain("clips"); + expect(finding?.message.toLowerCase()).toContain("no effect on capture"); + }); + + it("uses no-clipping-risk wording when only the viewport meta disagrees and html/body CSS matches the root", async () => { + const html = portraitCompositionWithScaffold( + "width: 1080px; height: 1920px;", + "width=1440, height=2560", + ); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "root_dimensions_mismatch"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("the viewport meta is 1440x2560"); + expect(finding?.message).not.toContain("clips"); + expect(finding?.message.toLowerCase()).toContain("no effect on capture"); + }); + + it("keeps the body-clipping wording when html/body CSS itself disagrees with the root", async () => { + const html = portraitCompositionWithScaffold( + "width: 1920px; height: 1080px;", + "width=1080, height=1920", + ); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "root_dimensions_mismatch"); + expect(finding?.message).toContain("clips"); + }); + 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 f0eca83881..8c3e3ed90d 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -239,15 +239,17 @@ function findVisibleMarkupCommentLeak(source: string): string | null { type ScaffoldSize = { width: string; height: string }; +// Groups 1 and 3 are prefix text for applyResolutionPreset's in-place +// replace; lint only reads the digit groups (2 and 4). function readHtmlBodyCssSize(source: string): ScaffoldSize | null { const widthFirst = source.match(HTML_BODY_CSS_WIDTH_FIRST_RE); if (widthFirst) { - const [, width = "", height = ""] = widthFirst; + const [, , width = "", , height = ""] = widthFirst; return { width, height }; } const heightFirst = source.match(HTML_BODY_CSS_HEIGHT_FIRST_RE); if (heightFirst) { - const [, height = "", width = ""] = heightFirst; + const [, , height = "", , width = ""] = heightFirst; return { width, height }; } return null; @@ -256,7 +258,7 @@ function readHtmlBodyCssSize(source: string): ScaffoldSize | null { function readViewportMetaSize(source: string): ScaffoldSize | null { const match = source.match(VIEWPORT_META_SIZE_RE); if (!match) return null; - const [, width = "", height = ""] = match; + const [, , width = "", , height = ""] = match; return { width, height }; } @@ -270,15 +272,43 @@ function describeSizeMismatch( return `${label} is ${size.width}x${size.height}`; } -function findScaffoldSizeMismatches( +// Only html/body CSS actually clips the root; the CDP viewport comes from +// data-width/data-height, not `` — so a viewport-only drift gets distinct wording. +function describeRootDimensionsDrift( 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); +): { message: string; fixHint: string } | null { + const bodyCssMismatch = describeSizeMismatch( + "html/body CSS", + readHtmlBodyCssSize(source), + dataWidth, + dataHeight, + ); + const viewportMismatch = describeSizeMismatch( + "the viewport meta", + readViewportMetaSize(source), + dataWidth, + dataHeight, + ); + if (!bodyCssMismatch && !viewportMismatch) return null; + + const declared = `Root composition declares data-width="${dataWidth}" data-height="${dataHeight}"`; + if (!bodyCssMismatch) { + return { + message: `${declared}, but ${viewportMismatch}. The viewport meta has no effect on capture — the renderer sizes the viewport from the root's own data-width/data-height — so this is stale metadata, not a clipping risk.`, + fixHint: + "update the meta viewport to match, or scaffold with `hyperframes init --resolution portrait`", + }; + } + const mismatches = viewportMismatch + ? `${bodyCssMismatch} and ${viewportMismatch}` + : bodyCssMismatch; + return { + message: `${declared}, but ${mismatches}. The scaffolded body clips the composition at its old size.`, + fixHint: + "update html/body CSS and the meta viewport to match, or scaffold with `hyperframes init --resolution portrait`", + }; } export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ @@ -339,28 +369,26 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // 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 []; + // Sub-compositions are exempt: loadExternalCompositions (packages/core/src/ + // runtime/compositionLoader.ts) mounts only the matched