From 36bcededf47af437b74964bcfca512351c7eaee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 18:38:00 -0400 Subject: [PATCH 1/2] feat: add layout audit command --- docs/packages/cli.mdx | 35 ++ packages/cli/scripts/build-copy.mjs | 6 + packages/cli/src/cli.ts | 1 + .../cli/src/commands/layout-audit.browser.js | 329 ++++++++++++++ packages/cli/src/commands/layout.ts | 414 ++++++++++++++++++ packages/cli/src/help.ts | 2 + packages/cli/src/utils/layoutAudit.test.ts | 86 ++++ packages/cli/src/utils/layoutAudit.ts | 163 +++++++ skills/hyperframes-cli/SKILL.md | 28 +- skills/hyperframes/SKILL.md | 15 + 10 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/layout-audit.browser.js create mode 100644 packages/cli/src/commands/layout.ts create mode 100644 packages/cli/src/utils/layoutAudit.test.ts create mode 100644 packages/cli/src/utils/layoutAudit.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 07575b3e2..3940d6b4f 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -19,6 +19,7 @@ npx hyperframes - Preview compositions with live hot reload (`preview`) - Render compositions to MP4 locally or in Docker (`render`) - Lint compositions for structural issues (`lint`) +- Audit rendered layout for text overflow and clipped containers (`layout`) - Capture key frames as PNG screenshots (`snapshot`) - Check your environment for missing dependencies (`doctor`) @@ -445,6 +446,40 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. + ### `layout` + + Audit rendered layout across the composition timeline: + + ```bash + npx hyperframes layout [dir] + npx hyperframes layout [dir] --json + npx hyperframes layout [dir] --samples 15 + npx hyperframes layout [dir] --at 1.5,4,7.25 + ``` + + ``` + ◆ Auditing layout for my-project (9 timeline samples) + + ✗ text_box_overflow t=3.25s #headline inside .bubble overflowed right 18px — "Quarterly plan" + Fix: Increase the bubble/container size or padding, reduce font-size/letter-spacing, or set a max-width that allows wrapping inside the container. + + ◇ 1 error(s), 0 warning(s) + ``` + + `layout` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes. It is designed for agent workflows: each finding includes a timestamp, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. + + | Flag | Description | + |------|-------------| + | `--json` | Output agent-readable findings with `samples`, `issues`, bounding boxes, and summary counts | + | `--samples` | Number of midpoint samples across the composition duration (default: 9) | + | `--at` | Comma-separated timestamps in seconds for explicit hero-frame checks | + | `--tolerance` | Allowed pixel overflow before reporting an issue (default: 2) | + | `--timeout` | Ms to wait for runtime initialization (default: 5000) | + | `--max-issues` | Maximum findings to print or return (default: 80) | + | `--strict` | Exit non-zero on warnings as well as errors | + + Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited. + ### `snapshot` Capture key frames from a composition as PNG screenshots — verify visual output without a full render: diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 15bd91161..9132c7095 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -58,6 +58,7 @@ async function main() { for (const sub of ["studio", "docs", "templates", "skills", "docker"]) { mkdirSync(join(DIST, sub), { recursive: true }); } + mkdirSync(join(DIST, "commands"), { recursive: true }); const studioDist = resolve(CLI_ROOT, "..", "studio", "dist"); await waitForStudioDist(studioDist); @@ -76,6 +77,11 @@ async function main() { cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render")); } + const layoutAuditScript = join(CLI_ROOT, "src", "commands", "layout-audit.browser.js"); + if (existsSync(layoutAuditScript)) { + cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js")); + } + copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs")); console.log("[build-copy] done"); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 83528dbb0..ad84f7cba 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,7 @@ const subCommands = { publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), + layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), benchmark: () => import("./commands/benchmark.js").then((m) => m.default), diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js new file mode 100644 index 000000000..00bdc4ccc --- /dev/null +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -0,0 +1,329 @@ +(function () { + const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]); + + function toRect(rect) { + return { + left: round(rect.left), + top: round(rect.top), + right: round(rect.right), + bottom: round(rect.bottom), + width: round(rect.width), + height: round(rect.height), + }; + } + + function round(value) { + return Math.round(value * 100) / 100; + } + + function overflowFor(subject, container, tolerance) { + const overflow = {}; + if (subject.left < container.left - tolerance) + overflow.left = round(container.left - subject.left); + if (subject.right > container.right + tolerance) + overflow.right = round(subject.right - container.right); + if (subject.top < container.top - tolerance) overflow.top = round(container.top - subject.top); + if (subject.bottom > container.bottom + tolerance) + overflow.bottom = round(subject.bottom - container.bottom); + return Object.keys(overflow).length > 0 ? overflow : null; + } + + function escapeCss(value) { + if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); + return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + } + + function selectorFor(element) { + if (element.id) return `#${escapeCss(element.id)}`; + const dataName = + element.getAttribute("data-layout-name") || + element.getAttribute("data-composition-id") || + element.getAttribute("data-start"); + if (dataName) { + const attr = element.hasAttribute("data-layout-name") + ? "data-layout-name" + : element.hasAttribute("data-composition-id") + ? "data-composition-id" + : "data-start"; + return `${element.tagName.toLowerCase()}[${attr}="${escapeCss(dataName)}"]`; + } + const classes = Array.from(element.classList).slice(0, 2); + if (classes.length > 0) { + return `${element.tagName.toLowerCase()}.${classes.map(escapeCss).join(".")}`; + } + const parent = element.parentElement; + if (!parent) return element.tagName.toLowerCase(); + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === element.tagName, + ); + const index = siblings.indexOf(element) + 1; + return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; + } + + function hasIgnoreFlag(element) { + return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']"); + } + + function hasAllowOverflowFlag(element) { + return !!element.closest("[data-layout-allow-overflow]"); + } + + function opacityChain(element) { + let opacity = 1; + for (let current = element; current; current = current.parentElement) { + const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1"); + if (Number.isFinite(parsed)) opacity *= parsed; + } + return opacity; + } + + function isVisibleElement(element) { + if (IGNORE_TAGS.has(element.tagName)) return false; + if (hasIgnoreFlag(element)) return false; + const style = getComputedStyle(element); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.visibility === "collapse" + ) { + return false; + } + if (opacityChain(element) < 0.2) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0.5 && rect.height > 0.5; + } + + function textContentFor(element) { + return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim(); + } + + function hasOwnTextCandidate(element) { + const text = textContentFor(element); + if (!text) return false; + for (const child of Array.from(element.children)) { + if (isVisibleElement(child) && textContentFor(child)) return false; + } + return true; + } + + function textRectFor(element) { + const range = document.createRange(); + range.selectNodeContents(element); + const rects = Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0.5 && rect.height > 0.5, + ); + range.detach(); + if (rects.length === 0) return null; + + const union = rects.reduce( + (acc, rect) => ({ + left: Math.min(acc.left, rect.left), + top: Math.min(acc.top, rect.top), + right: Math.max(acc.right, rect.right), + bottom: Math.max(acc.bottom, rect.bottom), + }), + { + left: Number.POSITIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + }, + ); + + return toRect({ + ...union, + width: union.right - union.left, + height: union.bottom - union.top, + }); + } + + function parsePx(value) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + function hasPaint(style) { + const backgroundColor = style.backgroundColor || ""; + const hasBackground = + backgroundColor !== "" && + backgroundColor !== "transparent" && + !backgroundColor.endsWith(", 0)") && + backgroundColor !== "rgba(0, 0, 0, 0)"; + const hasImage = style.backgroundImage && style.backgroundImage !== "none"; + const hasBorder = + parsePx(style.borderTopWidth) + + parsePx(style.borderRightWidth) + + parsePx(style.borderBottomWidth) + + parsePx(style.borderLeftWidth) > + 0; + const hasRadius = + parsePx(style.borderTopLeftRadius) + + parsePx(style.borderTopRightRadius) + + parsePx(style.borderBottomRightRadius) + + parsePx(style.borderBottomLeftRadius) > + 0; + return hasBackground || hasImage || hasBorder || hasRadius; + } + + function clipsOverflow(style) { + return [style.overflowX, style.overflowY, style.overflow].some( + (value) => value && value !== "visible" && value !== "clip visible", + ); + } + + function isConstraintCandidate(element, root) { + if (element === root) return true; + const style = getComputedStyle(element); + if (clipsOverflow(style)) return true; + if (element.hasAttribute("data-layout-boundary")) return true; + if (!hasPaint(style)) return false; + const rect = element.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + const rootArea = rootRect.width * rootRect.height; + const area = rect.width * rect.height; + return area > 0 && area < rootArea * 0.95; + } + + function nearestConstraint(element, root) { + for ( + let current = element; + current && current !== document.body; + current = current.parentElement + ) { + if (!isVisibleElement(current)) continue; + if (isConstraintCandidate(current, root)) return current; + if (current === root) return current; + } + return root; + } + + function clippedTextIssue(element, time, tolerance) { + const style = getComputedStyle(element); + if (!clipsOverflow(style)) return null; + const overflowX = element.scrollWidth - element.clientWidth; + const overflowY = element.scrollHeight - element.clientHeight; + if (overflowX <= tolerance && overflowY <= tolerance) return null; + const overflow = {}; + if (overflowX > tolerance) overflow.right = round(overflowX); + if (overflowY > tolerance) overflow.bottom = round(overflowY); + const selector = selectorFor(element); + const text = textContentFor(element); + return { + code: "clipped_text", + severity: "error", + time, + selector, + text, + message: "Text content is clipped by its own box.", + rect: toRect(element.getBoundingClientRect()), + overflow, + fixHint: + "Increase the element width/height, reduce font-size, loosen letter-spacing, or use fitTextFontSize for dynamic copy.", + }; + } + + function textOverflowIssues(element, root, rootRect, time, tolerance) { + const textRect = textRectFor(element); + if (!textRect) return []; + const text = textContentFor(element); + const selector = selectorFor(element); + const issues = []; + + const container = nearestConstraint(element, root); + const containerRect = toRect(container.getBoundingClientRect()); + const containerOverflow = overflowFor(textRect, containerRect, tolerance); + if (containerOverflow && !hasAllowOverflowFlag(element)) { + issues.push({ + code: "text_box_overflow", + severity: "error", + time, + selector, + containerSelector: selectorFor(container), + text, + message: "Text extends outside its nearest visual/container box.", + rect: textRect, + containerRect, + overflow: containerOverflow, + fixHint: + "Increase the bubble/container size or padding, reduce font-size/letter-spacing, or set a max-width that allows wrapping inside the container.", + }); + } + + const canvasOverflow = overflowFor(textRect, rootRect, tolerance); + if (canvasOverflow && !hasAllowOverflowFlag(element)) { + issues.push({ + code: "canvas_overflow", + severity: "warning", + time, + selector, + containerSelector: selectorFor(root), + text, + message: "Text extends outside the composition canvas.", + rect: textRect, + containerRect: rootRect, + overflow: canvasOverflow, + fixHint: + "Move the text inward, reduce its size, or mark intentional off-canvas animation with data-layout-allow-overflow.", + }); + } + + return issues; + } + + function containerOverflowIssues(root, time, tolerance) { + const issues = []; + const containers = Array.from(root.querySelectorAll("*")).filter((element) => { + if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) return false; + const style = getComputedStyle(element); + return clipsOverflow(style) || element.hasAttribute("data-layout-boundary"); + }); + + for (const container of containers) { + const containerRect = toRect(container.getBoundingClientRect()); + for (const child of Array.from(container.children)) { + if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) continue; + const childRect = toRect(child.getBoundingClientRect()); + const overflow = overflowFor(childRect, containerRect, tolerance); + if (!overflow) continue; + issues.push({ + code: "container_overflow", + severity: "warning", + time, + selector: selectorFor(child), + containerSelector: selectorFor(container), + message: "Element extends outside a clipping layout container.", + rect: childRect, + containerRect, + overflow, + fixHint: + "Resize/reposition the child or container, or mark intentional overflow with data-layout-allow-overflow.", + }); + } + } + + return issues; + } + + window.__hyperframesLayoutAudit = function auditLayout(options) { + const time = options && typeof options.time === "number" ? options.time : 0; + const tolerance = + options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const rootRect = toRect(root.getBoundingClientRect()); + const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement); + const issues = []; + + for (const element of elements) { + if (!hasOwnTextCandidate(element)) continue; + const clipped = clippedTextIssue(element, time, tolerance); + if (clipped) issues.push(clipped); + issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance)); + } + + issues.push(...containerOverflowIssues(root, time, tolerance)); + return issues; + }; +})(); diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts new file mode 100644 index 000000000..31da96e61 --- /dev/null +++ b/packages/cli/src/commands/layout.ts @@ -0,0 +1,414 @@ +import { defineCommand } from "citty"; +import { createServer } from "node:http"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; +import { resolveProject } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { + buildLayoutSampleTimes, + dedupeLayoutIssues, + formatLayoutIssue, + summarizeLayoutIssues, + type LayoutIssue, +} from "../utils/layoutAudit.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const SEEK_SETTLE_MS = 120; + +export const examples: Example[] = [ + ["Audit layout across the current composition", "hyperframes layout"], + ["Audit a specific project", "hyperframes layout ./my-video"], + ["Output agent-readable JSON", "hyperframes layout --json"], + ["Use explicit hero-frame timestamps", "hyperframes layout --at 1.5,4.0,7.25"], +]; + +interface LayoutAuditResult { + duration: number; + samples: number[]; + issues: LayoutIssue[]; +} + +async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { + return page.evaluate(() => { + const win = window as unknown as { + __hf?: { duration?: number }; + __player?: { duration?: number | (() => number) }; + __timelines?: Record number) }>; + }; + if (typeof win.__hf?.duration === "number" && win.__hf.duration > 0) return win.__hf.duration; + const playerDuration = win.__player?.duration; + if (typeof playerDuration === "function") return playerDuration(); + if (typeof playerDuration === "number" && playerDuration > 0) return playerDuration; + + const root = document.querySelector("[data-composition-id][data-duration]"); + const attrDuration = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0; + if (attrDuration > 0) return attrDuration; + + const timelines = win.__timelines; + if (timelines) { + for (const timeline of Object.values(timelines)) { + const duration = timeline.duration; + if (typeof duration === "function") return duration(); + if (typeof duration === "number" && duration > 0) return duration; + } + } + + return 0; + }); +} + +async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { + await page.evaluate((t: number) => { + const win = window as unknown as { + __hf?: { seek?: (time: number) => void }; + __player?: { seek?: (time: number) => void }; + __timelines?: Record void; seek?: (time: number) => void }>; + }; + if (typeof win.__hf?.seek === "function") { + win.__hf.seek(t); + return; + } + if (typeof win.__player?.seek === "function") { + win.__player.seek(t); + return; + } + const timelines = win.__timelines; + if (timelines) { + for (const timeline of Object.values(timelines)) { + if (typeof timeline.pause === "function") timeline.pause(); + if (typeof timeline.seek === "function") timeline.seek(t); + } + } + }, time); + await page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), + ), + ); + await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); +} + +async function bundleProjectHtml(projectDir: string): Promise { + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + let html = await bundleToSingleHtml(projectDir); + + const runtimePath = resolve( + __dirname, + "..", + "..", + "..", + "core", + "dist", + "hyperframe.runtime.iife.js", + ); + if (existsSync(runtimePath)) { + const runtimeSource = readFileSync(runtimePath, "utf-8"); + html = html.replace( + /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, + () => ``, + ); + } + + return html; +} + +async function serveProject( + projectDir: string, + html: string, +): Promise<{ + url: string; + close: () => Promise; +}> { + const { getMimeType } = await import("@hyperframes/core/studio-api"); + const server = createServer((req, res) => { + const url = req.url ?? "/"; + if (url === "/" || url === "/index.html") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(html); + return; + } + + const filePath = resolve(projectDir, decodeURIComponent(url).replace(/^\//, "")); + const rel = relative(projectDir, filePath); + if (rel.startsWith("..") || isAbsolute(rel)) { + res.writeHead(403); + res.end(); + return; + } + if (existsSync(filePath)) { + res.writeHead(200, { "Content-Type": getMimeType(filePath) }); + res.end(readFileSync(filePath)); + return; + } + res.writeHead(404); + res.end(); + }); + + const port = await new Promise((resolvePort, rejectPort) => { + server.on("error", rejectPort); + server.listen(0, () => { + const addr = server.address(); + const resolvedPort = typeof addr === "object" && addr ? addr.port : 0; + if (!resolvedPort) rejectPort(new Error("Failed to bind local layout audit server")); + else resolvePort(resolvedPort); + }); + }); + + return { + url: `http://127.0.0.1:${port}/`, + close: () => + new Promise((resolveClose) => { + server.close(() => resolveClose()); + }), + }; +} + +async function alignViewportToComposition( + page: import("puppeteer-core").Page, + url: string, +): Promise { + const size = await page.evaluate(() => { + const root = document.querySelector("[data-composition-id][data-width][data-height]"); + const width = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0; + const height = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0; + return { + width: Number.isFinite(width) && width > 0 ? Math.min(width, 4096) : 1920, + height: Number.isFinite(height) && height > 0 ? Math.min(height, 4096) : 1080, + }; + }); + + await page.setViewport(size); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); +} + +async function runLayoutAudit( + projectDir: string, + opts: { samples: number; at?: number[]; timeout: number; tolerance: number; maxIssues: number }, +): Promise { + const { ensureBrowser } = await import("../browser/manager.js"); + const puppeteer = await import("puppeteer-core"); + const html = await bundleProjectHtml(projectDir); + const server = await serveProject(projectDir, html); + let chromeBrowser: import("puppeteer-core").Browser | undefined; + + try { + const browser = await ensureBrowser(); + chromeBrowser = await puppeteer.default.launch({ + headless: true, + executablePath: browser.executablePath, + args: [ + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", + "--enable-webgl", + "--use-gl=angle", + "--use-angle=swiftshader", + ], + }); + + const page = await chromeBrowser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 }); + await alignViewportToComposition(page, server.url); + await page + .waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, { + timeout: opts.timeout, + }) + .catch(() => {}); + await new Promise((resolveSettle) => setTimeout(resolveSettle, 250)); + + const duration = await getCompositionDuration(page); + const samples = buildLayoutSampleTimes({ duration, samples: opts.samples, at: opts.at }); + if (samples.length === 0) return { duration, samples, issues: [] }; + + await page.addScriptTag({ content: loadLayoutAuditScript() }); + + const issues: LayoutIssue[] = []; + for (const time of samples) { + await seekTo(page, time); + const sampleIssues = await page.evaluate( + (auditOptions: { time: number; tolerance: number }) => { + const win = window as unknown as { + __hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[]; + }; + return win.__hyperframesLayoutAudit?.(auditOptions) ?? []; + }, + { time, tolerance: opts.tolerance }, + ); + issues.push(...(sampleIssues as LayoutIssue[])); + if (issues.length >= opts.maxIssues * 2) break; + } + + return { + duration, + samples, + issues: dedupeLayoutIssues(issues).slice(0, opts.maxIssues), + }; + } finally { + await chromeBrowser?.close().catch(() => {}); + await server.close(); + } +} + +function loadLayoutAuditScript(): string { + const candidates = [ + join(__dirname, "layout-audit.browser.js"), + join(__dirname, "commands", "layout-audit.browser.js"), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) return readFileSync(candidate, "utf-8"); + } + + throw new Error("Missing layout audit browser script"); +} + +function parseAt(value: unknown): number[] | undefined { + if (!value) return undefined; + const times = String(value) + .split(",") + .map((entry) => parseFloat(entry.trim())) + .filter((time) => Number.isFinite(time) && time >= 0); + return times.length > 0 ? times : undefined; +} + +export default defineCommand({ + meta: { + name: "layout", + description: "Audit rendered composition layout for text and container overflow", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + samples: { + type: "string", + description: "Number of midpoint samples across the duration (default: 9)", + default: "9", + }, + at: { + type: "string", + description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", + }, + tolerance: { + type: "string", + description: "Allowed pixel overflow before reporting an issue (default: 2)", + default: "2", + }, + timeout: { + type: "string", + description: "Ms to wait for runtime to initialize (default: 5000)", + default: "5000", + }, + "max-issues": { + type: "string", + description: "Maximum issues to print or return (default: 80)", + default: "80", + }, + strict: { + type: "boolean", + description: "Exit non-zero on warnings too", + default: false, + }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); + const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); + const timeout = Math.max(500, parseInt(args.timeout as string, 10) || 5000); + const maxIssues = Math.max(1, parseInt(args["max-issues"] as string, 10) || 80); + const at = parseAt(args.at); + const strict = !!args.strict; + + if (!args.json) { + const sampleLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`; + console.log( + `${c.accent("◆")} Auditing layout for ${c.accent(project.name)} (${sampleLabel})`, + ); + } + + try { + const result = await runLayoutAudit(project.dir, { + samples, + at, + timeout, + tolerance, + maxIssues, + }); + const summary = summarizeLayoutIssues(result.issues); + const ok = summary.errorCount === 0 && (!strict || summary.warningCount === 0); + + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + duration: result.duration, + samples: result.samples, + tolerance, + strict, + ...summary, + ok, + issues: result.issues, + }), + null, + 2, + ), + ); + process.exit(ok ? 0 : 1); + } + + if (result.samples.length === 0) { + console.log(); + console.log( + `${c.error("✗")} Could not determine composition duration — no layout samples run`, + ); + process.exit(1); + } + + console.log(); + if (result.issues.length === 0) { + console.log(`${c.success("◇")} 0 layout issues across ${result.samples.length} sample(s)`); + return; + } + + for (const issue of result.issues) { + const icon = issue.severity === "error" ? c.error("✗") : c.warn("⚠"); + const formatted = formatLayoutIssue(issue).replace(/\n/g, "\n "); + console.log(` ${icon} ${c.dim(formatted)}`); + } + + console.log(); + const parts = [`${summary.errorCount} error(s)`, `${summary.warningCount} warning(s)`]; + const suffix = + result.issues.length >= maxIssues ? c.dim(`, truncated at ${maxIssues} issue(s)`) : ""; + console.log(`${ok ? c.success("◇") : c.error("◇")} ${parts.join(", ")}${suffix}`); + + process.exit(ok ? 0 : 1); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + issueCount: 0, + }), + null, + 2, + ), + ); + process.exit(1); + } + console.error(`${c.error("✗")} Layout audit failed: ${message}`); + process.exit(1); + } + }, +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 0f5630c72..8805b28a8 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -32,6 +32,7 @@ const GROUPS: Group[] = [ title: "Project", commands: [ ["lint", "Validate a composition for common mistakes"], + ["layout", "Audit rendered text and container layout across the timeline"], ["snapshot", "Capture key frames as PNG screenshots for visual verification"], ["info", "Print project metadata"], ["compositions", "List all compositions in a project"], @@ -77,6 +78,7 @@ const ROOT_EXAMPLES: Example[] = [ ["Render to MP4", "hyperframes render -o out.mp4"], ["Transparent WebM overlay", "hyperframes render --format webm -o out.webm"], ["Validate your composition", "hyperframes lint"], + ["Audit timeline layout", "hyperframes layout"], ["Check system dependencies", "hyperframes doctor"], ]; diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts new file mode 100644 index 000000000..e4d298c29 --- /dev/null +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + buildLayoutSampleTimes, + computeOverflow, + summarizeLayoutIssues, + formatLayoutIssue, + type LayoutIssue, +} from "./layoutAudit.js"; + +describe("layoutAudit helpers", () => { + it("samples the whole duration using stable midpoint timestamps", () => { + expect(buildLayoutSampleTimes({ duration: 10, samples: 5 })).toEqual([1, 3, 5, 7, 9]); + }); + + it("prefers explicit timestamps and keeps them inside the composition duration", () => { + expect(buildLayoutSampleTimes({ duration: 10, samples: 5, at: [0, 2.5, 12, -1, NaN] })).toEqual( + [0, 2.5], + ); + }); + + it("computes per-side overflow beyond a tolerance", () => { + const overflow = computeOverflow( + { left: 88, top: 102, right: 231, bottom: 181, width: 143, height: 79 }, + { left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 }, + 2, + ); + + expect(overflow).toEqual({ left: 12, right: 11 }); + }); + + it("returns no overflow when the subject only exceeds the box within tolerance", () => { + const overflow = computeOverflow( + { left: 99, top: 100, right: 221, bottom: 180, width: 122, height: 80 }, + { left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 }, + 2, + ); + + expect(overflow).toBeNull(); + }); + + it("summarizes errors and warnings separately", () => { + const issues: LayoutIssue[] = [ + issue("text_box_overflow", "error"), + issue("canvas_overflow", "warning"), + issue("clipped_text", "error"), + ]; + + expect(summarizeLayoutIssues(issues)).toEqual({ + ok: false, + errorCount: 2, + warningCount: 1, + issueCount: 3, + }); + }); + + it("formats issues with timestamp, selector, container, and fix hint", () => { + const formatted = formatLayoutIssue({ + ...issue("text_box_overflow", "error"), + time: 3.25, + selector: "#headline", + containerSelector: ".bubble", + text: "Quarterly plan", + overflow: { right: 18, bottom: 7 }, + fixHint: "Increase container padding or reduce font-size.", + }); + + expect(formatted).toContain("t=3.25s"); + expect(formatted).toContain("#headline"); + expect(formatted).toContain("inside .bubble"); + expect(formatted).toContain("right 18px, bottom 7px"); + expect(formatted).toContain("Fix: Increase container padding"); + }); +}); + +function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue { + return { + code, + severity, + time: 1, + selector: ".label", + message: "Layout issue", + rect: { left: 0, top: 0, right: 100, bottom: 20, width: 100, height: 20 }, + overflow: { right: 8 }, + fixHint: "Adjust layout.", + }; +} diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts new file mode 100644 index 000000000..e637be871 --- /dev/null +++ b/packages/cli/src/utils/layoutAudit.ts @@ -0,0 +1,163 @@ +export interface LayoutRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export type LayoutOverflow = Partial>; + +export type LayoutIssueCode = + | "text_box_overflow" + | "clipped_text" + | "canvas_overflow" + | "container_overflow"; + +export interface LayoutIssue { + code: LayoutIssueCode; + severity: "error" | "warning"; + time: number; + selector: string; + containerSelector?: string; + text?: string; + message: string; + rect: LayoutRect; + containerRect?: LayoutRect; + overflow?: LayoutOverflow; + fixHint?: string; +} + +export interface LayoutSummary { + ok: boolean; + errorCount: number; + warningCount: number; + issueCount: number; +} + +export interface LayoutSampleOptions { + duration: number; + samples: number; + at?: number[]; +} + +export function buildLayoutSampleTimes({ duration, samples, at }: LayoutSampleOptions): number[] { + if (at?.length) { + return uniqueSortedTimes( + at.filter( + (time) => Number.isFinite(time) && time >= 0 && (duration <= 0 || time <= duration), + ), + ); + } + + if (!Number.isFinite(duration) || duration <= 0 || samples <= 0) return []; + + const count = Math.max(1, Math.floor(samples)); + return Array.from({ length: count }, (_, index) => roundTime(((index + 0.5) / count) * duration)); +} + +export function computeOverflow( + subject: LayoutRect, + container: LayoutRect, + tolerance: number, +): LayoutOverflow | null { + const overflow: LayoutOverflow = {}; + + if (subject.left < container.left - tolerance) { + overflow.left = roundPx(container.left - subject.left); + } + if (subject.right > container.right + tolerance) { + overflow.right = roundPx(subject.right - container.right); + } + if (subject.top < container.top - tolerance) { + overflow.top = roundPx(container.top - subject.top); + } + if (subject.bottom > container.bottom + tolerance) { + overflow.bottom = roundPx(subject.bottom - container.bottom); + } + + return Object.keys(overflow).length > 0 ? overflow : null; +} + +export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary { + const errorCount = issues.filter((issue) => issue.severity === "error").length; + const warningCount = issues.filter((issue) => issue.severity === "warning").length; + + return { + ok: errorCount === 0, + errorCount, + warningCount, + issueCount: issues.length, + }; +} + +export function formatLayoutIssue(issue: LayoutIssue): string { + const parts = [ + `t=${formatNumber(issue.time)}s`, + issue.code, + issue.selector, + issue.containerSelector ? `inside ${issue.containerSelector}` : "", + issue.overflow ? `overflowed ${formatOverflow(issue.overflow)}` : "", + issue.text ? quoteText(issue.text) : "", + ].filter(Boolean); + + const line = `${parts.join(" ")} — ${issue.message}`; + return issue.fixHint ? `${line}\n Fix: ${issue.fixHint}` : line; +} + +export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { + const seen = new Set(); + const result: LayoutIssue[] = []; + + for (const issue of issues) { + const key = [ + issue.code, + issue.severity, + issue.time.toFixed(3), + issue.selector, + issue.containerSelector ?? "", + issue.text ?? "", + issue.overflow ? formatOverflow(issue.overflow) : "", + ].join("|"); + if (seen.has(key)) continue; + seen.add(key); + result.push(issue); + } + + return result; +} + +function uniqueSortedTimes(times: number[]): number[] { + const rounded = times.map(roundTime); + return [...new Set(rounded)].sort((a, b) => a - b); +} + +function formatOverflow(overflow: LayoutOverflow): string { + return (["left", "right", "top", "bottom"] as const) + .flatMap((side) => { + const value = overflow[side]; + return value == null ? [] : `${side} ${formatNumber(value)}px`; + }) + .join(", "); +} + +function quoteText(text: string): string { + const normalized = text.replace(/\s+/g, " ").trim(); + const truncated = normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; + return `"${truncated}"`; +} + +function formatNumber(value: number): string { + return Number.isInteger(value) + ? String(value) + : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); +} + +function roundTime(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function roundPx(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index ba14cfe64..947382399 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hyperframes-cli -description: HyperFrames CLI tool — hyperframes init, lint, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting or validating compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. +description: HyperFrames CLI tool — hyperframes init, lint, layout, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting, validating, layout-checking compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. --- # HyperFrames CLI @@ -12,10 +12,11 @@ Everything runs through `npx hyperframes`. Requires Node.js >= 22 and FFmpeg. 1. **Scaffold** — `npx hyperframes init my-video` 2. **Write** — author HTML composition (see the `hyperframes` skill) 3. **Lint** — `npx hyperframes lint` -4. **Preview** — `npx hyperframes preview` -5. **Render** — `npx hyperframes render` +4. **Layout audit** — `npx hyperframes layout` +5. **Preview** — `npx hyperframes preview` +6. **Render** — `npx hyperframes render` -Lint before preview — catches missing `data-composition-id`, overlapping tracks, unregistered timelines. +Lint and layout-audit before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `layout` opens the rendered composition in headless Chrome, seeks through the timeline, and reports text spilling out of bubbles/containers or off the canvas. ## Scaffolding @@ -42,6 +43,25 @@ npx hyperframes lint --json # machine-readable Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`). +## Layout Audit + +```bash +npx hyperframes layout # audit rendered layout over the timeline +npx hyperframes layout ./my-project # specific project +npx hyperframes layout --json # agent-readable findings +npx hyperframes layout --samples 15 # denser timeline sweep +npx hyperframes layout --at 1.5,4,7.25 # explicit hero-frame timestamps +``` + +Use this after `lint` and `validate`, especially for compositions with speech bubbles, cards, captions, or tight typography. It reports: + +- Text extending outside the nearest visual container or bubble +- Text clipped by its own fixed-width/fixed-height box +- Text extending outside the composition canvas +- Children escaping clipping containers + +Errors should be fixed before rendering. Warnings are surfaced for agent review; add `--strict` to fail on warnings too. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. + ## Previewing ```bash diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 5584493f3..0c5e4ec6e 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -277,11 +277,26 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl ## Output Checklist - [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass +- [ ] `npx hyperframes layout` passes, or every reported overflow is intentionally marked - [ ] Contrast warnings addressed (see Quality Checks below) +- [ ] Layout issues addressed (see Quality Checks below) - [ ] Animation choreography verified (see Quality Checks below) ## Quality Checks +### Layout + +`hyperframes layout` runs the composition in headless Chrome, seeks through the timeline, and maps layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`: + +```bash +npx hyperframes layout +npx hyperframes layout --json +``` + +Failures usually mean text is spilling out of a bubble/card, a fixed-size label is clipping dynamic copy, or text has moved off the canvas. Fix by increasing container size or padding, reducing font size or letter spacing, adding a real `max-width` so text wraps inside the container, or using `window.__hyperframes.fitTextFontSize(...)` for dynamic copy. + +Use `--samples 15` for dense videos and `--at 1.5,4,7.25` for specific hero frames. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. + ### Contrast `hyperframes validate` runs a WCAG contrast audit by default. It seeks to 5 timestamps, screenshots the page, samples background pixels behind every text element, and computes contrast ratios. Failures appear as warnings: From c0a3158f3cdb58c26cb5d9b6f3b08d14742ea136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 19:45:53 -0400 Subject: [PATCH 2/2] feat: refine visual inspect command --- docs/packages/cli.mdx | 33 +- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/inspect.ts | 12 + .../cli/src/commands/layout-audit.browser.js | 122 ++++++- .../src/commands/layout-audit.browser.test.ts | 196 +++++++++++ packages/cli/src/commands/layout.ts | 315 ++++++++++-------- packages/cli/src/help.ts | 4 +- packages/cli/src/utils/layoutAudit.test.ts | 57 ++++ packages/cli/src/utils/layoutAudit.ts | 88 ++++- skills/hyperframes-cli/SKILL.md | 22 +- skills/hyperframes/SKILL.md | 14 +- 11 files changed, 686 insertions(+), 178 deletions(-) create mode 100644 packages/cli/src/commands/inspect.ts create mode 100644 packages/cli/src/commands/layout-audit.browser.test.ts diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 3940d6b4f..779fce12a 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -19,7 +19,7 @@ npx hyperframes - Preview compositions with live hot reload (`preview`) - Render compositions to MP4 locally or in Docker (`render`) - Lint compositions for structural issues (`lint`) -- Audit rendered layout for text overflow and clipped containers (`layout`) +- Inspect rendered visual layout for text overflow and clipped containers (`inspect`) - Capture key frames as PNG screenshots (`snapshot`) - Check your environment for missing dependencies (`doctor`) @@ -446,40 +446,47 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. - ### `layout` + ### `inspect` - Audit rendered layout across the composition timeline: + Inspect rendered visual layout across the composition timeline: ```bash - npx hyperframes layout [dir] - npx hyperframes layout [dir] --json - npx hyperframes layout [dir] --samples 15 - npx hyperframes layout [dir] --at 1.5,4,7.25 + npx hyperframes inspect [dir] + npx hyperframes inspect [dir] --json + npx hyperframes inspect [dir] --samples 15 + npx hyperframes inspect [dir] --at 1.5,4,7.25 ``` ``` - ◆ Auditing layout for my-project (9 timeline samples) + ◆ Inspecting layout for my-project (9 timeline samples) ✗ text_box_overflow t=3.25s #headline inside .bubble overflowed right 18px — "Quarterly plan" - Fix: Increase the bubble/container size or padding, reduce font-size/letter-spacing, or set a max-width that allows wrapping inside the container. + Fix: Text is 418px x 42px inside 400px x 120px and overflows by up to 18px; widen the container to at least ~418px, or allow wrapping with max-width/fitTextFontSize. - ◇ 1 error(s), 0 warning(s) + ◇ 1 error(s), 0 warning(s), 0 info(s) ``` - `layout` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes. It is designed for agent workflows: each finding includes a timestamp, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. + `inspect` bundles the project, serves it locally, opens headless Chrome, seeks through the composition, and reports text or elements that escape their intended boxes. It is designed for agent workflows: each finding includes a schema version, timestamp or collapsed timestamp range, selector, nearest container selector, measured bounding boxes, overflow sides, and a fix hint. | Flag | Description | |------|-------------| - | `--json` | Output agent-readable findings with `samples`, `issues`, bounding boxes, and summary counts | + | `--json` | Output agent-readable findings with `schemaVersion`, `samples`, `issues`, bounding boxes, and summary counts | | `--samples` | Number of midpoint samples across the composition duration (default: 9) | | `--at` | Comma-separated timestamps in seconds for explicit hero-frame checks | | `--tolerance` | Allowed pixel overflow before reporting an issue (default: 2) | | `--timeout` | Ms to wait for runtime initialization (default: 5000) | - | `--max-issues` | Maximum findings to print or return (default: 80) | + | `--collapse-static` | Collapse repeated static issues across samples (default: true) | + | `--max-issues` | Maximum findings to print or return after static collapse (default: 80) | | `--strict` | Exit non-zero on warnings as well as errors | Use `data-layout-allow-overflow` on an element or ancestor when overflow is intentional, such as a planned off-canvas entrance. Use `data-layout-ignore` for decorative elements that should not be audited. + `layout` remains available as a compatibility alias for the same visual inspection pass: + + ```bash + npx hyperframes layout [dir] --json + ``` + ### `snapshot` Capture key frames from a composition as PNG screenshots — verify visual output without a full render: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ad84f7cba..632674312 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,7 @@ const subCommands = { publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), + inspect: () => import("./commands/inspect.js").then((m) => m.default), layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts new file mode 100644 index 000000000..1c79a7051 --- /dev/null +++ b/packages/cli/src/commands/inspect.ts @@ -0,0 +1,12 @@ +import type { Example } from "./_examples.js"; +import { createInspectCommand } from "./layout.js"; + +export const examples: Example[] = [ + ["Inspect visual layout across the current composition", "hyperframes inspect"], + ["Inspect a specific project", "hyperframes inspect ./my-video"], + ["Output agent-readable JSON", "hyperframes inspect --json"], + ["Use explicit hero-frame timestamps", "hyperframes inspect --at 1.5,4.0,7.25"], + ["Run the compatibility alias", "hyperframes layout --json"], +]; + +export default createInspectCommand("inspect"); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 00bdc4ccc..aebcfdfac 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -12,6 +12,17 @@ }; } + function rectFromOrigin(left, top, width, height) { + return { + left: round(left), + top: round(top), + right: round(left + width), + bottom: round(top + height), + width: round(width), + height: round(height), + }; + } + function round(value) { return Math.round(value * 100) / 100; } @@ -33,6 +44,10 @@ return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } + function escapeAttr(value) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + } + function selectorFor(element) { if (element.id) return `#${escapeCss(element.id)}`; const dataName = @@ -45,7 +60,9 @@ : element.hasAttribute("data-composition-id") ? "data-composition-id" : "data-start"; - return `${element.tagName.toLowerCase()}[${attr}="${escapeCss(dataName)}"]`; + const attrSelector = `[${attr}="${escapeAttr(dataName)}"]`; + if (document.querySelectorAll(attrSelector).length === 1) return attrSelector; + return `${element.tagName.toLowerCase()}${attrSelector}`; } const classes = Array.from(element.classList).slice(0, 2); if (classes.length > 0) { @@ -142,6 +159,24 @@ return Number.isFinite(parsed) ? parsed : 0; } + function hasMeaningfulBoxStyle(style) { + return ( + parsePx(style.paddingTop) + + parsePx(style.paddingRight) + + parsePx(style.paddingBottom) + + parsePx(style.paddingLeft) + + parsePx(style.borderTopWidth) + + parsePx(style.borderRightWidth) + + parsePx(style.borderBottomWidth) + + parsePx(style.borderLeftWidth) + + parsePx(style.borderTopLeftRadius) + + parsePx(style.borderTopRightRadius) + + parsePx(style.borderBottomRightRadius) + + parsePx(style.borderBottomLeftRadius) > + 0 + ); + } + function hasPaint(style) { const backgroundColor = style.backgroundColor || ""; const hasBackground = @@ -171,32 +206,84 @@ ); } - function isConstraintCandidate(element, root) { + function rootRectFor(root) { + const measured = toRect(root.getBoundingClientRect()); + const authoredWidth = Number.parseFloat(root.getAttribute("data-width") || ""); + const authoredHeight = Number.parseFloat(root.getAttribute("data-height") || ""); + const hasAuthoredSize = + Number.isFinite(authoredWidth) && + authoredWidth > 0 && + Number.isFinite(authoredHeight) && + authoredHeight > 0; + + if (!hasAuthoredSize) return measured; + if (measured.width > 0.5 && measured.height > 0.5) return measured; + return rectFromOrigin(measured.left, measured.top, authoredWidth, authoredHeight); + } + + function isConstraintCandidate(element, root, rootRect) { if (element === root) return true; const style = getComputedStyle(element); if (clipsOverflow(style)) return true; if (element.hasAttribute("data-layout-boundary")) return true; if (!hasPaint(style)) return false; + if (!hasMeaningfulBoxStyle(style)) return false; const rect = element.getBoundingClientRect(); - const rootRect = root.getBoundingClientRect(); const rootArea = rootRect.width * rootRect.height; const area = rect.width * rect.height; return area > 0 && area < rootArea * 0.95; } - function nearestConstraint(element, root) { + function nearestConstraint(element, root, rootRect) { for ( let current = element; current && current !== document.body; current = current.parentElement ) { if (!isVisibleElement(current)) continue; - if (isConstraintCandidate(current, root)) return current; + if (isConstraintCandidate(current, root, rootRect)) return current; if (current === root) return current; } return root; } + function formatPx(value) { + return `${Math.round(value)}px`; + } + + function maxOverflow(overflow) { + return Math.max(...Object.values(overflow).filter((value) => typeof value === "number")); + } + + function textOverflowFixHint(textRect, containerRect, overflow, fontSize, targetName) { + const horizontalOverflow = (overflow.left || 0) + (overflow.right || 0); + const verticalOverflow = (overflow.top || 0) + (overflow.bottom || 0); + const neededWidth = containerRect.width + horizontalOverflow; + const neededHeight = containerRect.height + verticalOverflow; + const widthRatio = containerRect.width > 0 ? containerRect.width / textRect.width : 0; + const heightRatio = containerRect.height > 0 ? containerRect.height / textRect.height : 0; + const limitingRatio = Math.min( + widthRatio > 0 ? widthRatio : Number.POSITIVE_INFINITY, + heightRatio > 0 ? heightRatio : Number.POSITIVE_INFINITY, + ); + const shrinkPercent = + Number.isFinite(limitingRatio) && limitingRatio < 1 + ? Math.ceil((1 - limitingRatio) * 100) + : 0; + const targetFont = + shrinkPercent > 0 && Number.isFinite(fontSize) && fontSize > 0 + ? ` or shrink font-size from ${formatPx(fontSize)} to ~${formatPx(fontSize * limitingRatio)}` + : ""; + const sizeTarget = + horizontalOverflow > 0 && verticalOverflow > 0 + ? `resize ${targetName} to at least ~${formatPx(neededWidth)} x ${formatPx(neededHeight)}` + : horizontalOverflow > 0 + ? `widen ${targetName} to at least ~${formatPx(neededWidth)}` + : `increase ${targetName} height to at least ~${formatPx(neededHeight)}`; + + return `Text is ${formatPx(textRect.width)} x ${formatPx(textRect.height)} inside ${formatPx(containerRect.width)} x ${formatPx(containerRect.height)} and overflows by up to ${formatPx(maxOverflow(overflow))}; ${sizeTarget}${targetFont}, or allow wrapping with max-width/fitTextFontSize.`; + } + function clippedTextIssue(element, time, tolerance) { const style = getComputedStyle(element); if (!clipsOverflow(style)) return null; @@ -208,6 +295,8 @@ if (overflowY > tolerance) overflow.bottom = round(overflowY); const selector = selectorFor(element); const text = textContentFor(element); + const rect = toRect(element.getBoundingClientRect()); + const fontSize = parsePx(style.fontSize); return { code: "clipped_text", severity: "error", @@ -215,10 +304,9 @@ selector, text, message: "Text content is clipped by its own box.", - rect: toRect(element.getBoundingClientRect()), + rect, overflow, - fixHint: - "Increase the element width/height, reduce font-size, loosen letter-spacing, or use fitTextFontSize for dynamic copy.", + fixHint: textOverflowFixHint(rect, rect, overflow, fontSize, "the text box"), }; } @@ -229,10 +317,11 @@ const selector = selectorFor(element); const issues = []; - const container = nearestConstraint(element, root); - const containerRect = toRect(container.getBoundingClientRect()); + const container = nearestConstraint(element, root, rootRect); + const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect()); const containerOverflow = overflowFor(textRect, containerRect, tolerance); if (containerOverflow && !hasAllowOverflowFlag(element)) { + const style = getComputedStyle(element); issues.push({ code: "text_box_overflow", severity: "error", @@ -244,8 +333,13 @@ rect: textRect, containerRect, overflow: containerOverflow, - fixHint: - "Increase the bubble/container size or padding, reduce font-size/letter-spacing, or set a max-width that allows wrapping inside the container.", + fixHint: textOverflowFixHint( + textRect, + containerRect, + containerOverflow, + parsePx(style.fontSize), + "the container", + ), }); } @@ -253,7 +347,7 @@ if (canvasOverflow && !hasAllowOverflowFlag(element)) { issues.push({ code: "canvas_overflow", - severity: "warning", + severity: "info", time, selector, containerSelector: selectorFor(root), @@ -312,7 +406,7 @@ document.querySelector("[data-composition-id][data-width][data-height]") || document.querySelector("[data-composition-id]") || document.body; - const rootRect = toRect(root.getBoundingClientRect()); + const rootRect = rootRectFor(root); const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement); const issues = []; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts new file mode 100644 index 000000000..981c052df --- /dev/null +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const script = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8"); + +interface RectInput { + left: number; + top: number; + width: number; + height: number; +} + +describe("layout-audit.browser", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + }); + + it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { + document.body.innerHTML = ` +
+
Quarterly plan overflow
+
+ `; + + installGeometry({ + root: rect({ left: 0, top: 0, width: 0, height: 0 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 120 }), + headline: rect({ left: 96, top: 138, width: 1539, height: 56 }), + text: rect({ left: 96, top: 138, width: 1539, height: 56 }), + }); + + installAuditScript(); + + const issues = runAudit(); + const boxOverflow = issues.find((issue) => issue.code === "text_box_overflow"); + + expect(boxOverflow).toMatchObject({ + selector: "#headline", + containerSelector: "#bubble", + overflow: { right: 1155 }, + }); + expect( + issues.some( + (issue) => + issue.code === "text_box_overflow" && + issue.selector === "#headline" && + issue.containerSelector === "#root", + ), + ).toBe(false); + }); + + it("omits tag prefixes for unique data-attribute selectors", () => { + document.body.innerHTML = ` +
+
Quarterly plan overflow
+
+ `; + + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 120 }), + headline: rect({ left: 96, top: 138, width: 1539, height: 56 }), + text: rect({ left: 96, top: 138, width: 1539, height: 56 }), + }); + + installAuditScript(); + + const issues = runAudit(); + + expect(issues[0]?.selector).toBe('[data-layout-name="headline"]'); + }); + + it("respects layout ignore and allow-overflow opt-outs", () => { + document.body.innerHTML = ` +
+
+
Quarterly plan overflow
+
+
Ignored overflow
+
+ `; + + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 120 }), + headline: rect({ left: 96, top: 138, width: 1539, height: 56 }), + ignored: rect({ left: 600, top: 20, width: 500, height: 40 }), + text: rect({ left: 96, top: 138, width: 1539, height: 56 }), + }); + + installAuditScript(); + + expect(runAudit()).toEqual([]); + }); +}); + +function installAuditScript(): void { + window.eval(script); +} + +function runAudit(): Array<{ + code: string; + selector: string; + containerSelector?: string; + overflow?: Record; +}> { + const audit = ( + window as unknown as { + __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{ + code: string; + selector: string; + containerSelector?: string; + overflow?: Record; + }>; + } + ).__hyperframesLayoutAudit; + return audit({ time: 1, tolerance: 2 }); +} + +function installGeometry(rects: Record): void { + vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { + const el = element as Element; + const isBubble = el.id === "bubble"; + return { + display: "block", + visibility: "visible", + opacity: "1", + overflow: "visible", + overflowX: "visible", + overflowY: "visible", + backgroundColor: isBubble ? "rgb(255, 255, 255)" : "rgba(0, 0, 0, 0)", + backgroundImage: "none", + borderTopWidth: "0px", + borderRightWidth: "0px", + borderBottomWidth: "0px", + borderLeftWidth: "0px", + borderTopLeftRadius: isBubble ? "28px" : "0px", + borderTopRightRadius: isBubble ? "28px" : "0px", + borderBottomRightRadius: isBubble ? "28px" : "0px", + borderBottomLeftRadius: isBubble ? "28px" : "0px", + paddingTop: isBubble ? "16px" : "0px", + paddingRight: isBubble ? "16px" : "0px", + paddingBottom: isBubble ? "16px" : "0px", + paddingLeft: isBubble ? "16px" : "0px", + fontSize: "36px", + } as unknown as CSSStyleDeclaration; + }); + + for (const element of Array.from(document.querySelectorAll("*"))) { + const key = + element.id === "root" || element.hasAttribute("data-composition-id") + ? "root" + : element.id === "headline" || element.hasAttribute("data-layout-name") + ? "headline" + : element.id; + const rectValue = rects[key] ?? rect({ left: 0, top: 0, width: 10, height: 10 }); + vi.spyOn(element, "getBoundingClientRect").mockReturnValue(rectValue); + } + + vi.spyOn(document, "createRange").mockImplementation(() => { + let selected: Node | null = null; + return { + selectNodeContents(node: Node) { + selected = node; + }, + getClientRects() { + const element = selected as Element | null; + const textRect = element?.id === "ignored" ? rects.ignored : rects.text; + return textRect ? ([textRect] as unknown as DOMRectList) : ([] as unknown as DOMRectList); + }, + detach() {}, + } as unknown as Range; + }); +} + +function rect({ left, top, width, height }: RectInput): DOMRect { + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON() { + return this; + }, + } as DOMRect; +} diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 31da96e61..92cc0c94b 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -9,8 +9,10 @@ import { resolveProject } from "../utils/project.js"; import { withMeta } from "../utils/updateCheck.js"; import { buildLayoutSampleTimes, + collapseStaticLayoutIssues, dedupeLayoutIssues, formatLayoutIssue, + limitLayoutIssues, summarizeLayoutIssues, type LayoutIssue, } from "../utils/layoutAudit.js"; @@ -18,10 +20,11 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SEEK_SETTLE_MS = 120; +const INSPECT_SCHEMA_VERSION = 1; export const examples: Example[] = [ - ["Audit layout across the current composition", "hyperframes layout"], - ["Audit a specific project", "hyperframes layout ./my-video"], + ["Inspect visual layout across the current composition", "hyperframes layout"], + ["Inspect a specific project", "hyperframes layout ./my-video"], ["Output agent-readable JSON", "hyperframes layout --json"], ["Use explicit hero-frame timestamps", "hyperframes layout --at 1.5,4.0,7.25"], ]; @@ -29,7 +32,7 @@ export const examples: Example[] = [ interface LayoutAuditResult { duration: number; samples: number[]; - issues: LayoutIssue[]; + rawIssues: LayoutIssue[]; } async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { @@ -90,6 +93,16 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), ), ); + await page + .evaluate(() => { + const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; + if (!fonts?.ready) return Promise.resolve(); + return Promise.race([ + fonts.ready.then(() => undefined), + new Promise((resolve) => setTimeout(resolve, 500)), + ]); + }) + .catch(() => {}); await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); } @@ -188,7 +201,7 @@ async function alignViewportToComposition( async function runLayoutAudit( projectDir: string, - opts: { samples: number; at?: number[]; timeout: number; tolerance: number; maxIssues: number }, + opts: { samples: number; at?: number[]; timeout: number; tolerance: number }, ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); @@ -220,11 +233,21 @@ async function runLayoutAudit( timeout: opts.timeout, }) .catch(() => {}); + await page + .evaluate(() => { + const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; + if (!fonts?.ready) return Promise.resolve(); + return Promise.race([ + fonts.ready.then(() => undefined), + new Promise((resolve) => setTimeout(resolve, 750)), + ]); + }) + .catch(() => {}); await new Promise((resolveSettle) => setTimeout(resolveSettle, 250)); const duration = await getCompositionDuration(page); const samples = buildLayoutSampleTimes({ duration, samples: opts.samples, at: opts.at }); - if (samples.length === 0) return { duration, samples, issues: [] }; + if (samples.length === 0) return { duration, samples, rawIssues: [] }; await page.addScriptTag({ content: loadLayoutAuditScript() }); @@ -241,13 +264,12 @@ async function runLayoutAudit( { time, tolerance: opts.tolerance }, ); issues.push(...(sampleIssues as LayoutIssue[])); - if (issues.length >= opts.maxIssues * 2) break; } return { duration, samples, - issues: dedupeLayoutIssues(issues).slice(0, opts.maxIssues), + rawIssues: dedupeLayoutIssues(issues), }; } finally { await chromeBrowser?.close().catch(() => {}); @@ -277,138 +299,169 @@ function parseAt(value: unknown): number[] | undefined { return times.length > 0 ? times : undefined; } -export default defineCommand({ - meta: { - name: "layout", - description: "Audit rendered composition layout for text and container overflow", - }, - args: { - dir: { type: "positional", description: "Project directory", required: false }, - json: { type: "boolean", description: "Output agent-readable JSON", default: false }, - samples: { - type: "string", - description: "Number of midpoint samples across the duration (default: 9)", - default: "9", - }, - at: { - type: "string", - description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", +export function createInspectCommand(commandName: "inspect" | "layout") { + return defineCommand({ + meta: { + name: commandName, + description: "Inspect rendered composition layout for text and container overflow", }, - tolerance: { - type: "string", - description: "Allowed pixel overflow before reporting an issue (default: 2)", - default: "2", + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + samples: { + type: "string", + description: "Number of midpoint samples across the duration (default: 9)", + default: "9", + }, + at: { + type: "string", + description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", + }, + tolerance: { + type: "string", + description: "Allowed pixel overflow before reporting an issue (default: 2)", + default: "2", + }, + timeout: { + type: "string", + description: "Ms to wait for runtime to initialize (default: 5000)", + default: "5000", + }, + "max-issues": { + type: "string", + description: "Maximum issues to print or return after static collapse (default: 80)", + default: "80", + }, + "collapse-static": { + type: "boolean", + description: "Collapse repeated static issues across samples (default: true)", + default: true, + }, + strict: { + type: "boolean", + description: "Exit non-zero on warnings too", + default: false, + }, }, - timeout: { - type: "string", - description: "Ms to wait for runtime to initialize (default: 5000)", - default: "5000", - }, - "max-issues": { - type: "string", - description: "Maximum issues to print or return (default: 80)", - default: "80", - }, - strict: { - type: "boolean", - description: "Exit non-zero on warnings too", - default: false, - }, - }, - async run({ args }) { - const project = resolveProject(args.dir); - const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); - const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); - const timeout = Math.max(500, parseInt(args.timeout as string, 10) || 5000); - const maxIssues = Math.max(1, parseInt(args["max-issues"] as string, 10) || 80); - const at = parseAt(args.at); - const strict = !!args.strict; - - if (!args.json) { - const sampleLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`; - console.log( - `${c.accent("◆")} Auditing layout for ${c.accent(project.name)} (${sampleLabel})`, - ); - } - - try { - const result = await runLayoutAudit(project.dir, { - samples, - at, - timeout, - tolerance, - maxIssues, - }); - const summary = summarizeLayoutIssues(result.issues); - const ok = summary.errorCount === 0 && (!strict || summary.warningCount === 0); - - if (args.json) { + async run({ args }) { + const project = resolveProject(args.dir); + const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); + const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); + const timeout = Math.max(500, parseInt(args.timeout as string, 10) || 5000); + const maxIssues = Math.max(1, parseInt(args["max-issues"] as string, 10) || 80); + const at = parseAt(args.at); + const strict = !!args.strict; + const collapseStatic = args["collapse-static"] !== false; + + if (!args.json) { + const sampleLabel = at + ? `${at.length} explicit timestamp(s)` + : `${samples} timeline samples`; console.log( - JSON.stringify( - withMeta({ - duration: result.duration, - samples: result.samples, - tolerance, - strict, - ...summary, - ok, - issues: result.issues, - }), - null, - 2, - ), + `${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`, ); - process.exit(ok ? 0 : 1); } - if (result.samples.length === 0) { - console.log(); - console.log( - `${c.error("✗")} Could not determine composition duration — no layout samples run`, - ); - process.exit(1); - } - - console.log(); - if (result.issues.length === 0) { - console.log(`${c.success("◇")} 0 layout issues across ${result.samples.length} sample(s)`); - return; - } + try { + const result = await runLayoutAudit(project.dir, { + samples, + at, + timeout, + tolerance, + }); + const allIssues = collapseStatic + ? collapseStaticLayoutIssues(result.rawIssues) + : result.rawIssues; + const limited = limitLayoutIssues(allIssues, maxIssues); + const summary = summarizeLayoutIssues(allIssues); + const ok = summary.errorCount === 0 && (!strict || summary.warningCount === 0); + + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + schemaVersion: INSPECT_SCHEMA_VERSION, + duration: result.duration, + samples: result.samples, + tolerance, + strict, + collapseStatic, + ...summary, + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + ok, + issues: limited.issues, + }), + null, + 2, + ), + ); + process.exit(ok ? 0 : 1); + } + + if (result.samples.length === 0) { + console.log(); + console.log( + `${c.error("✗")} Could not determine composition duration — no layout samples run`, + ); + process.exit(1); + } - for (const issue of result.issues) { - const icon = issue.severity === "error" ? c.error("✗") : c.warn("⚠"); - const formatted = formatLayoutIssue(issue).replace(/\n/g, "\n "); - console.log(` ${icon} ${c.dim(formatted)}`); - } + console.log(); + if (limited.issues.length === 0) { + console.log( + `${c.success("◇")} 0 layout issues across ${result.samples.length} sample(s)`, + ); + return; + } + + for (const issue of limited.issues) { + const icon = + issue.severity === "error" + ? c.error("✗") + : issue.severity === "warning" + ? c.warn("⚠") + : c.dim("ℹ"); + const formatted = formatLayoutIssue(issue).replace(/\n/g, "\n "); + console.log(` ${icon} ${c.dim(formatted)}`); + } - console.log(); - const parts = [`${summary.errorCount} error(s)`, `${summary.warningCount} warning(s)`]; - const suffix = - result.issues.length >= maxIssues ? c.dim(`, truncated at ${maxIssues} issue(s)`) : ""; - console.log(`${ok ? c.success("◇") : c.error("◇")} ${parts.join(", ")}${suffix}`); + console.log(); + const parts = [ + `${summary.errorCount} error(s)`, + `${summary.warningCount} warning(s)`, + `${summary.infoCount} info(s)`, + ]; + const suffix = limited.truncated ? c.dim(`, truncated at ${maxIssues} issue(s)`) : ""; + console.log(`${ok ? c.success("◇") : c.error("◇")} ${parts.join(", ")}${suffix}`); - process.exit(ok ? 0 : 1); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (args.json) { - console.log( - JSON.stringify( - withMeta({ - ok: false, - error: message, - issues: [], - errorCount: 0, - warningCount: 0, - issueCount: 0, - }), - null, - 2, - ), - ); + process.exit(ok ? 0 : 1); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (args.json) { + console.log( + JSON.stringify( + withMeta({ + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }), + null, + 2, + ), + ); + process.exit(1); + } + console.error(`${c.error("✗")} Inspect failed: ${message}`); process.exit(1); } - console.error(`${c.error("✗")} Layout audit failed: ${message}`); - process.exit(1); - } - }, -}); + }, + }); +} + +export default createInspectCommand("layout"); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8805b28a8..01fe56742 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -32,7 +32,7 @@ const GROUPS: Group[] = [ title: "Project", commands: [ ["lint", "Validate a composition for common mistakes"], - ["layout", "Audit rendered text and container layout across the timeline"], + ["inspect", "Inspect rendered visual layout across the timeline"], ["snapshot", "Capture key frames as PNG screenshots for visual verification"], ["info", "Print project metadata"], ["compositions", "List all compositions in a project"], @@ -78,7 +78,7 @@ const ROOT_EXAMPLES: Example[] = [ ["Render to MP4", "hyperframes render -o out.mp4"], ["Transparent WebM overlay", "hyperframes render --format webm -o out.webm"], ["Validate your composition", "hyperframes lint"], - ["Audit timeline layout", "hyperframes layout"], + ["Inspect visual layout", "hyperframes inspect"], ["Check system dependencies", "hyperframes doctor"], ]; diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index e4d298c29..f4fc1f30c 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { buildLayoutSampleTimes, computeOverflow, + collapseStaticLayoutIssues, + limitLayoutIssues, summarizeLayoutIssues, formatLayoutIssue, type LayoutIssue, @@ -49,10 +51,37 @@ describe("layoutAudit helpers", () => { ok: false, errorCount: 2, warningCount: 1, + infoCount: 0, issueCount: 3, }); }); + it("tracks info findings separately from warnings and errors", () => { + expect(summarizeLayoutIssues([issue("canvas_overflow", "info")])).toEqual({ + ok: true, + errorCount: 0, + warningCount: 0, + infoCount: 1, + issueCount: 1, + }); + }); + + it("collapses repeated static issues across sampled timestamps", () => { + const collapsed = collapseStaticLayoutIssues([ + { ...issue("text_box_overflow", "error"), time: 1 }, + { ...issue("text_box_overflow", "error"), time: 3 }, + { ...issue("text_box_overflow", "error"), time: 5 }, + ]); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ + time: 1, + firstSeen: 1, + lastSeen: 5, + occurrences: 3, + }); + }); + it("formats issues with timestamp, selector, container, and fix hint", () => { const formatted = formatLayoutIssue({ ...issue("text_box_overflow", "error"), @@ -70,6 +99,34 @@ describe("layoutAudit helpers", () => { expect(formatted).toContain("right 18px, bottom 7px"); expect(formatted).toContain("Fix: Increase container padding"); }); + + it("formats collapsed issue time ranges", () => { + const formatted = formatLayoutIssue({ + ...issue("text_box_overflow", "error"), + time: 1, + firstSeen: 1, + lastSeen: 5, + occurrences: 3, + }); + + expect(formatted).toContain("t=1-5s (3 samples)"); + }); + + it("limits returned issues by severity before truncating", () => { + const limited = limitLayoutIssues( + [ + { ...issue("canvas_overflow", "info"), time: 1 }, + { ...issue("text_box_overflow", "error"), time: 2 }, + ], + 1, + ); + + expect(limited).toMatchObject({ + totalIssueCount: 2, + truncated: true, + issues: [{ code: "text_box_overflow", severity: "error" }], + }); + }); }); function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue { diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index e637be871..237366b54 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -15,10 +15,15 @@ export type LayoutIssueCode = | "canvas_overflow" | "container_overflow"; +export type LayoutIssueSeverity = "error" | "warning" | "info"; + export interface LayoutIssue { code: LayoutIssueCode; - severity: "error" | "warning"; + severity: LayoutIssueSeverity; time: number; + firstSeen?: number; + lastSeen?: number; + occurrences?: number; selector: string; containerSelector?: string; text?: string; @@ -33,6 +38,7 @@ export interface LayoutSummary { ok: boolean; errorCount: number; warningCount: number; + infoCount: number; issueCount: number; } @@ -83,18 +89,24 @@ export function computeOverflow( export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary { const errorCount = issues.filter((issue) => issue.severity === "error").length; const warningCount = issues.filter((issue) => issue.severity === "warning").length; + const infoCount = issues.filter((issue) => issue.severity === "info").length; return { ok: errorCount === 0, errorCount, warningCount, + infoCount, issueCount: issues.length, }; } export function formatLayoutIssue(issue: LayoutIssue): string { + const timeLabel = + issue.occurrences && issue.occurrences > 1 + ? `t=${formatNumber(issue.firstSeen ?? issue.time)}-${formatNumber(issue.lastSeen ?? issue.time)}s (${issue.occurrences} samples)` + : `t=${formatNumber(issue.time)}s`; const parts = [ - `t=${formatNumber(issue.time)}s`, + timeLabel, issue.code, issue.selector, issue.containerSelector ? `inside ${issue.containerSelector}` : "", @@ -128,6 +140,78 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { return result; } +export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { + const groups = new Map< + string, + { + issue: LayoutIssue; + firstSeen: number; + lastSeen: number; + occurrences: number; + } + >(); + + for (const issue of issues) { + const key = staticIssueKey(issue); + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + issue, + firstSeen: issue.time, + lastSeen: issue.time, + occurrences: 1, + }); + continue; + } + + existing.firstSeen = Math.min(existing.firstSeen, issue.time); + existing.lastSeen = Math.max(existing.lastSeen, issue.time); + existing.occurrences += 1; + } + + return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({ + ...issue, + time: firstSeen, + firstSeen, + lastSeen, + occurrences, + })); +} + +export function limitLayoutIssues( + issues: LayoutIssue[], + maxIssues: number, +): { issues: LayoutIssue[]; totalIssueCount: number; truncated: boolean } { + const limit = Math.max(1, Math.floor(maxIssues)); + const sortedIssues = [...issues].sort((a, b) => { + const severityDelta = severityRank(a.severity) - severityRank(b.severity); + if (severityDelta !== 0) return severityDelta; + return a.time - b.time; + }); + return { + issues: sortedIssues.slice(0, limit), + totalIssueCount: issues.length, + truncated: issues.length > limit, + }; +} + +function severityRank(severity: LayoutIssueSeverity): number { + if (severity === "error") return 0; + if (severity === "warning") return 1; + return 2; +} + +function staticIssueKey(issue: LayoutIssue): string { + return [ + issue.code, + issue.severity, + issue.selector, + issue.containerSelector ?? "", + issue.text ?? "", + issue.overflow ? formatOverflow(issue.overflow) : "", + ].join("|"); +} + function uniqueSortedTimes(times: number[]): number[] { const rounded = times.map(roundTime); return [...new Set(rounded)].sort((a, b) => a - b); diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 947382399..5aa3c11fb 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hyperframes-cli -description: HyperFrames CLI tool — hyperframes init, lint, layout, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting, validating, layout-checking compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. +description: HyperFrames CLI tool — hyperframes init, lint, inspect, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting, validating, inspecting visual layout in compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment. --- # HyperFrames CLI @@ -12,11 +12,11 @@ Everything runs through `npx hyperframes`. Requires Node.js >= 22 and FFmpeg. 1. **Scaffold** — `npx hyperframes init my-video` 2. **Write** — author HTML composition (see the `hyperframes` skill) 3. **Lint** — `npx hyperframes lint` -4. **Layout audit** — `npx hyperframes layout` +4. **Visual inspect** — `npx hyperframes inspect` 5. **Preview** — `npx hyperframes preview` 6. **Render** — `npx hyperframes render` -Lint and layout-audit before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `layout` opens the rendered composition in headless Chrome, seeks through the timeline, and reports text spilling out of bubbles/containers or off the canvas. +Lint and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `inspect` opens the rendered composition in headless Chrome, seeks through the timeline, and reports text spilling out of bubbles/containers or off the canvas. ## Scaffolding @@ -43,14 +43,14 @@ npx hyperframes lint --json # machine-readable Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`). -## Layout Audit +## Visual Inspect ```bash -npx hyperframes layout # audit rendered layout over the timeline -npx hyperframes layout ./my-project # specific project -npx hyperframes layout --json # agent-readable findings -npx hyperframes layout --samples 15 # denser timeline sweep -npx hyperframes layout --at 1.5,4,7.25 # explicit hero-frame timestamps +npx hyperframes inspect # inspect rendered layout over the timeline +npx hyperframes inspect ./my-project # specific project +npx hyperframes inspect --json # agent-readable findings +npx hyperframes inspect --samples 15 # denser timeline sweep +npx hyperframes inspect --at 1.5,4,7.25 # explicit hero-frame timestamps ``` Use this after `lint` and `validate`, especially for compositions with speech bubbles, cards, captions, or tight typography. It reports: @@ -60,7 +60,9 @@ Use this after `lint` and `validate`, especially for compositions with speech bu - Text extending outside the composition canvas - Children escaping clipping containers -Errors should be fixed before rendering. Warnings are surfaced for agent review; add `--strict` to fail on warnings too. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. +Errors should be fixed before rendering. Warnings are surfaced for agent review; add `--strict` to fail on warnings too. Repeated static issues are collapsed by default so JSON output stays compact for LLM context windows. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. + +`npx hyperframes layout` remains available as a compatibility alias for the same visual inspection pass. ## Previewing diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 0c5e4ec6e..60f816e67 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -277,25 +277,27 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl ## Output Checklist - [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass -- [ ] `npx hyperframes layout` passes, or every reported overflow is intentionally marked +- [ ] `npx hyperframes inspect` passes, or every reported overflow is intentionally marked - [ ] Contrast warnings addressed (see Quality Checks below) - [ ] Layout issues addressed (see Quality Checks below) - [ ] Animation choreography verified (see Quality Checks below) ## Quality Checks -### Layout +### Visual Inspect -`hyperframes layout` runs the composition in headless Chrome, seeks through the timeline, and maps layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`: +`hyperframes inspect` runs the composition in headless Chrome, seeks through the timeline, and maps visual layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`: ```bash -npx hyperframes layout -npx hyperframes layout --json +npx hyperframes inspect +npx hyperframes inspect --json ``` Failures usually mean text is spilling out of a bubble/card, a fixed-size label is clipping dynamic copy, or text has moved off the canvas. Fix by increasing container size or padding, reducing font size or letter spacing, adding a real `max-width` so text wraps inside the container, or using `window.__hyperframes.fitTextFontSize(...)` for dynamic copy. -Use `--samples 15` for dense videos and `--at 1.5,4,7.25` for specific hero frames. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. +Use `--samples 15` for dense videos and `--at 1.5,4,7.25` for specific hero frames. Repeated static issues are collapsed by default to avoid flooding agent context. If overflow is intentional for an entrance/exit animation, mark the element or ancestor with `data-layout-allow-overflow`. If a decorative element should never be audited, mark it with `data-layout-ignore`. + +`hyperframes layout` is the compatibility alias for the same check. ### Contrast