feat(cli): snapshot --zoom and per-finding crops on check --snapshots

snapshot --zoom <selector|x,y,w,h> + --zoom-scale (default 3) crops via
Puppeteer clip at raised deviceScaleFactor — density changes, layout
never does. Selector resolves per frame with 24px padding; no match is
a loud error, and a frame whose clamped region is a sliver (element
collapsed or animated off-canvas) is skipped with a stderr note rather
than written as a useless few-pixel image.

check --snapshots additionally writes finding-NN-<code>.png crops for
error findings with bboxes (cap 12, deterministic re-seek in a second
session) and draws labeled annotation boxes on overview frames via a
transient overlay injected only after audits complete. Skill reference
gains the zoom workflow: check reports a finding, zoom into it, fix,
re-check.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 58f45ef758
commit f4cef54b8b
12 changed files with 856 additions and 31 deletions
+161 -1
View File
@@ -13,15 +13,18 @@ import { createCheckCommand } from "./check.js";
import {
DEFAULT_CHECK_OPTIONS,
checkExitCode,
findingCropFilename,
runAuditGrid,
runCheckPipeline,
selectContrastTimes,
selectFindingCropRequests,
type AnchoredLayoutIssue,
type CheckAnchor,
type CheckAuditDriver,
type CheckBrowserResult,
type CheckDependencies,
type CheckFinding,
type CheckFindingCropRequest,
type CheckOptions,
type CheckReport,
type ContrastAuditEntry,
@@ -29,7 +32,12 @@ import {
} from "../utils/checkPipeline.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js";
import type {
LayoutIssue,
LayoutIssueCode,
LayoutOverflow,
LayoutRect,
} from "../utils/layoutAudit.js";
import type { ProjectDir } from "../utils/project.js";
const PROJECT: ProjectDir = {
@@ -241,6 +249,7 @@ function dependencies(
motion?: MotionSpecResolution;
runtime?: CheckFinding[];
writeSnapshot?: CheckDependencies["writeSnapshot"];
captureFindingCrops?: CheckDependencies["captureFindingCrops"];
} = {},
): { deps: CheckDependencies; runBrowserCheck: ReturnType<typeof vi.fn> } {
const runBrowserCheck = vi.fn(
@@ -264,6 +273,7 @@ function dependencies(
`snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
),
),
captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []),
};
return { deps, runBrowserCheck };
}
@@ -655,6 +665,115 @@ it("keeps contrast and snapshot sampling on the pre-gate layout grid", async ()
expect(collectContrast).toHaveBeenCalledWith(5);
});
function layoutFindingOf(
code: LayoutIssueCode,
severity: "error" | "warning" | "info",
bbox: { x: number; y: number; width: number; height: number },
time = 1,
): AnchoredLayoutIssue {
return {
code,
severity,
message: code,
...anchor("#el", time),
bbox,
rect: {
left: bbox.x,
top: bbox.y,
right: bbox.x + bbox.width,
bottom: bbox.y + bbox.height,
...bbox,
},
};
}
function checkFindingOf(
code: string,
severity: "error" | "warning" | "info",
bbox: { x: number; y: number; width: number; height: number },
time = 1,
): CheckFinding {
return { code, severity, message: code, ...anchor("#el", time), bbox };
}
function emptySection<T extends CheckFinding>(findings: T[] = []) {
return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings };
}
function reportWithFindings(overrides: Partial<CheckReport> = {}): CheckReport {
return {
ok: true,
strict: false,
lint: { ...emptySection(), filesScanned: 0 },
runtime: emptySection(),
layout: {
...emptySection(),
duration: 10,
samples: [],
transitionSamples: [],
transitionSamplesDropped: 0,
tolerance: 2,
totalIssueCount: 0,
truncated: false,
},
motion: { ...emptySection(), enabled: false, samples: 0 },
contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 },
snapshots: { enabled: false, files: [], times: [], findingFiles: [] },
...overrides,
};
}
describe("selectFindingCropRequests", () => {
const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 };
const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 };
it("filenames a request finding-NN-<code>.png with the finding's time and bbox", () => {
const report = reportWithFindings({
layout: {
...reportWithFindings().layout,
findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)],
},
});
expect(selectFindingCropRequests(report)).toEqual([
{ filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX },
]);
});
it("skips warnings/info and findings without a real bbox", () => {
const report = reportWithFindings({
layout: {
...reportWithFindings().layout,
findings: [
layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX),
layoutFindingOf("clipped_text", "error", ZERO_BBOX),
],
},
runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) },
});
expect(selectFindingCropRequests(report)).toEqual([]);
});
it("caps at 12 requests across sections", () => {
const findings = Array.from({ length: 15 }, (_, index) =>
checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index),
);
const report = reportWithFindings({
runtime: { ...emptySection(findings) },
});
const requests = selectFindingCropRequests(report);
expect(requests).toHaveLength(12);
expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0"));
expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11"));
});
it("sanitizes unusual characters out of the code when building a filename", () => {
expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png");
});
});
describe("check pipeline", () => {
const originalExitCode = process.exitCode;
@@ -833,6 +952,47 @@ describe("check pipeline", () => {
expect(absentWriter).not.toHaveBeenCalled();
});
it("captures finding crops for error findings with bboxes only when --snapshots is set", async () => {
const capture = vi.fn(
async (
_project: ProjectDir,
_options: CheckOptions,
_requests: CheckFindingCropRequest[],
) => ["snapshots/finding-00-clipped_text.png"],
);
const { report } = await runScenario(
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
{ snapshots: true },
{ captureFindingCrops: capture },
);
expect(capture).toHaveBeenCalledTimes(1);
expect(capture.mock.calls[0]?.[2]).toEqual([
{
filename: "finding-00-clipped_text.png",
time: 0.5,
bbox: { x: 10, y: 20, width: 300, height: 80 },
},
]);
expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]);
const withoutSnapshots = vi.fn(async () => ["unused.png"]);
await runScenario(
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
{ snapshots: false },
{ captureFindingCrops: withoutSnapshots },
);
expect(withoutSnapshots).not.toHaveBeenCalled();
const noErrors = vi.fn(async () => ["unused.png"]);
await runScenario(
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }),
{ snapshots: true },
{ captureFindingCrops: noErrors },
);
expect(noErrors).not.toHaveBeenCalled();
});
it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
const warningDriver = () =>
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) });
+6
View File
@@ -339,6 +339,12 @@ function printSnapshotSection(report: CheckReport): void {
} else {
console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
if (report.snapshots.findingFiles.length > 0) {
console.log(
` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`,
);
for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`);
}
}
}
+21 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js";
// --zoom's crop-region math (selector bbox + padding + clamp, exact region
// form, no-match error) is owned by and tested in
// ../capture/captureCompositionFrame.test.ts alongside its implementation.
describe("tailFrameTime", () => {
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
@@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
expect(appendedTail).toBe(false);
});
});
describe("parseZoomScale (--zoom-scale)", () => {
it("defaults to 3 when unset", () => {
expect(parseZoomScale(undefined)).toBe(3);
});
it("honors an explicit scale", () => {
expect(parseZoomScale("2")).toBe(2);
});
it("falls back to the default for invalid or non-positive input", () => {
expect(parseZoomScale("abc")).toBe(3);
expect(parseZoomScale("0")).toBe(3);
expect(parseZoomScale("-1")).toBe(3);
});
});
+51 -1
View File
@@ -4,9 +4,13 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { resolve, join, relative, isAbsolute, basename } from "node:path";
import {
captureRegionCrop,
openSettledCompositionPage,
parseZoomTarget,
resolveCropRegion,
runFfmpegOnce,
seekCompositionTimeline,
type ZoomTarget,
} from "../capture/captureCompositionFrame.js";
import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
@@ -104,8 +108,20 @@ export const examples: Example[] = [
["Capture 5 key frames from a composition", "snapshot capture"],
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"],
[
"Zoom into an exact pixel region at 2x density",
"snapshot --zoom 100,50,400,300 --zoom-scale 2",
],
];
/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3;
* falls back to the default for anything that doesn't parse as a positive number. */
export function parseZoomScale(value: unknown): number {
const parsed = parseFloat(String(value ?? ""));
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3;
}
/**
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
@@ -172,6 +188,8 @@ async function captureSnapshots(
outputDir?: string;
angle?: Camera;
includeEnd?: boolean;
zoom?: ZoomTarget;
zoomScale?: number;
},
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
@@ -425,7 +443,25 @@ async function captureSnapshots(
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
const framePath = join(snapshotDir, filename);
await page.screenshot({ path: framePath, type: "png" });
if (opts.zoom) {
// Clip screenshot at a raised deviceScaleFactor — never CSS zoom or
// viewport resizing — so the composition's own layout is untouched.
const canvas = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const region = await resolveCropRegion(page, opts.zoom, canvas);
if (!region) {
console.error(
` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`,
);
continue;
}
const buffer = await captureRegionCrop(page, region, opts.zoomScale ?? 3);
writeFileSync(framePath, buffer);
} else {
await page.screenshot({ path: framePath, type: "png" });
}
const rel = relative(projectDir, framePath);
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
}
@@ -480,6 +516,16 @@ export default defineCommand({
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
default: true,
},
zoom: {
type: "string",
description:
"Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.",
},
"zoom-scale": {
type: "string",
description: "Device-scale-factor density for --zoom crops (default: 3)",
default: "3",
},
describe: {
type: "string",
description:
@@ -508,6 +554,8 @@ export default defineCommand({
: String(args.describe);
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined;
const zoomScale = parseZoomScale(args["zoom-scale"]);
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
@@ -529,6 +577,8 @@ export default defineCommand({
outputDir: snapshotDir,
angle: camera,
includeEnd: args.end !== false,
zoom: zoomTarget,
zoomScale,
});
if (paths.length === 0) {