feat(cli): declarative motion verification in inspect (#1437) (#1459)

Extend `inspect` to verify motion intent against the same seeked timeline
the renderer uses, catching render-≠-preview bugs that layout sampling can't:
entrance reveals the seek skips, broken stagger order, off-frame drift, and
frozen shots.

A `*.motion.json` sidecar next to the composition opts in (auto-discovered,
no flag, no authoring-framework changes); without one, inspect is unchanged.
inspect seeks a dense grid over the asserted selectors, builds an
element × time matrix of {rect, opacity, visible} plus per-scope liveness
signatures, and evaluates four assertions in Node:

  appearsBy    -> motion_appears_late
  before       -> motion_out_of_order
  staysInFrame -> motion_off_frame
  keepsMoving  -> motion_frozen

A selector matching nothing is reported as motion_selector_missing rather
than silently passing. Findings reuse the LayoutIssue shape and flow through
the existing dedupe/collapse/limit/format pipeline and JSON envelope; they
are errors by default, so a failed assertion fails the run.

The motion pass runs in the same Chrome session as the layout audit (no extra
launch) and only when a sidecar is present.
This commit is contained in:
Miguel Ángel
2026-06-15 16:29:11 -04:00
committed by GitHub
parent 1ab7dcfe47
commit 9175eced45
14 changed files with 1270 additions and 30 deletions
+1
View File
@@ -25,6 +25,7 @@
// path, never imported, so they have no import-graph referrer.
"packages/cli/src/commands/layout-audit.browser.js",
"packages/cli/src/commands/contrast-audit.browser.js",
"packages/cli/src/commands/motion-sample.browser.js",
// Worker entry points loaded dynamically by their *Pool.ts companions.
"packages/producer/src/services/pngDecodeBlitWorker.ts",
"packages/producer/src/services/shaderTransitionWorker.ts",
+26 -1
View File
@@ -19,7 +19,7 @@ npx hyperframes <command>
- Preview compositions with live hot reload (`preview`)
- Render compositions to MP4 locally or in Docker (`render`)
- Lint compositions for structural issues (`lint`)
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text (`inspect`)
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text, plus verify motion intent against the seeked timeline (`inspect`)
- Capture key frames as PNG screenshots (`snapshot`)
- Check your environment for missing dependencies (`doctor`)
@@ -580,6 +580,31 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
npx hyperframes layout [dir] --json
```
#### Motion verification
`inspect` also checks **motion intent** against the same seeked timeline the renderer uses — catching the render-≠-preview bugs that layout sampling can't, like an entrance reveal the seek skips, a broken stagger order, an element that drifts off-frame mid-tween, or a shot that freezes. Drop a `*.motion.json` sidecar next to the composition and `inspect` evaluates it automatically (no flag, no authoring changes); without a sidecar, `inspect` behaves exactly as before.
```json
{
"duration": 6,
"assertions": [
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
{ "kind": "before", "a": "#headline", "b": "#cta" },
{ "kind": "staysInFrame", "selector": ".card" },
{ "kind": "keepsMoving", "withinSelector": ".scene" }
]
}
```
| Assertion | Checks |
|-----------|--------|
| `appearsBy(selector, bySec)` | the element is visible (opacity ≥ 0.5) no later than `bySec` — catches reveals the seek lands past (`motion_appears_late`) |
| `before(a, b)` | `a` first appears strictly before `b` — catches broken stagger order (`motion_out_of_order`) |
| `staysInFrame(selector)` | once visible, the element's box never leaves the canvas — catches off-frame drift (`motion_off_frame`) |
| `keepsMoving(withinSelector?)` | no fully-static window longer than `maxStaticSec` (default 2s) — catches frozen shots (`motion_frozen`) |
`duration`, `keepsMoving.withinSelector`, and `keepsMoving.maxStaticSec` are optional. Findings are reported in the same shape and JSON envelope as layout findings, are **errors by default** (a failed assertion fails the run), and a selector that matches nothing is reported as `motion_selector_missing` rather than silently passing.
### `snapshot`
Capture key frames from a composition as PNG screenshots — verify visual output without a full render:
+5
View File
@@ -97,6 +97,11 @@ async function main() {
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
}
const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js");
if (existsSync(motionSampleScript)) {
cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js"));
}
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
console.log("[build-copy] done");
+4
View File
@@ -10,6 +10,10 @@ export const examples: Example[] = [
"Also sample at tween boundaries to catch transient overlaps",
"hyperframes inspect --at-transitions",
],
[
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
"hyperframes inspect --json",
],
["Run the compatibility alias", "hyperframes layout --json"],
];
+180 -26
View File
@@ -18,11 +18,22 @@ import {
summarizeLayoutIssues,
type LayoutIssue,
} from "../utils/layoutAudit.js";
import {
ambiguousIssue,
collectSamplingTargets,
evaluateMotion,
type MotionFrame,
} from "../utils/motionAudit.js";
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SEEK_SETTLE_MS = 120;
// All new envelope fields are optional (?); additive changes don't bump this.
const INSPECT_SCHEMA_VERSION = 1;
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
const MOTION_FPS = 20;
const MOTION_MAX_SAMPLES = 300;
export const examples: Example[] = [
["Inspect visual layout across the current composition", "hyperframes layout"],
@@ -33,6 +44,10 @@ export const examples: Example[] = [
"Also sample at tween boundaries to catch transient overlaps",
"hyperframes layout --at-transitions",
],
[
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
"hyperframes layout --json",
],
];
interface LayoutAuditResult {
@@ -41,6 +56,14 @@ interface LayoutAuditResult {
transitionSamples: number[];
transitionSamplesDropped: number;
rawIssues: LayoutIssue[];
motionSamples: number;
}
function buildMotionSampleTimes(duration: number): number[] {
if (!Number.isFinite(duration) || duration <= 0) return [];
const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
const step = duration / (count - 1);
return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
}
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
@@ -205,6 +228,7 @@ async function runLayoutAudit(
maxTransitionSamples?: number;
timeout: number;
tolerance: number;
motion?: MotionSpec;
},
): Promise<LayoutAuditResult> {
const { ensureBrowser } = await import("../browser/manager.js");
@@ -259,25 +283,14 @@ async function runLayoutAudit(
transitionSamplesDropped = transitions.dropped;
}
const samples = mergeSampleTimes(baseSamples, transitionSamples);
if (samples.length === 0) {
return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues: [] };
}
await page.addScriptTag({ content: loadLayoutAuditScript() });
const issues = await collectLayoutIssues(page, samples, opts.tolerance);
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[]));
let motionSamples = 0;
if (opts.motion) {
const motion = await runMotionPass(page, opts.motion, duration);
issues.push(...motion.issues);
motionSamples = motion.sampleCount;
}
return {
@@ -286,6 +299,7 @@ async function runLayoutAudit(
transitionSamples,
transitionSamplesDropped,
rawIssues: dedupeLayoutIssues(issues),
motionSamples,
};
} finally {
await chromeBrowser?.close().catch(() => {});
@@ -293,17 +307,143 @@ async function runLayoutAudit(
}
}
function loadLayoutAuditScript(): string {
const candidates = [
join(__dirname, "layout-audit.browser.js"),
join(__dirname, "commands", "layout-audit.browser.js"),
];
function loadBrowserScript(name: string): string {
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
for (const candidate of candidates) {
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
}
throw new Error(`Missing browser script ${name}`);
}
throw new Error("Missing layout audit browser script");
function loadLayoutAuditScript(): string {
return loadBrowserScript("layout-audit.browser.js");
}
async function collectLayoutIssues(
page: import("puppeteer-core").Page,
samples: number[],
tolerance: number,
): Promise<LayoutIssue[]> {
if (samples.length === 0) return [];
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 },
);
issues.push(...(sampleIssues as LayoutIssue[]));
}
return issues;
}
/** Reject selectors matching multiple elements — first-match-only sampling silently passes for siblings. */
async function findAmbiguousSelectors(
page: import("puppeteer-core").Page,
selectors: string[],
): Promise<LayoutIssue[]> {
if (selectors.length === 0) return [];
const multiMatch = await page.evaluate(
(sels: string[]) =>
sels.filter((sel) => {
try {
return document.querySelectorAll(sel).length > 1;
} catch {
return false;
}
}),
selectors,
);
return multiMatch.map(ambiguousIssue);
}
async function collectMotionFrames(
page: import("puppeteer-core").Page,
times: number[],
selectors: string[],
livenessScopes: string[],
): Promise<MotionFrame[]> {
const frames: MotionFrame[] = [];
for (const time of times) {
await seekTo(page, time);
const sample = await page.evaluate(
(options: { selectors: string[]; livenessScopes: string[] }) => {
const win = window as unknown as {
__hyperframesMotionSample?: (o: { selectors: string[]; livenessScopes: string[] }) => {
data: MotionFrame["data"];
liveness: Record<string, string>;
};
};
return win.__hyperframesMotionSample?.(options) ?? { data: {}, liveness: {} };
},
{ selectors, livenessScopes },
);
frames.push({ time, data: sample.data, liveness: sample.liveness });
}
return frames;
}
/**
* Motion verification (#1437): sample the asserted selectors on a dense grid
* against the same seeked timeline the renderer uses, then evaluate the spec's
* assertions in Node. Reuses the live page from the layout audit — no extra
* Chrome launch. Findings reuse the LayoutIssue shape.
*/
async function runMotionPass(
page: import("puppeteer-core").Page,
spec: MotionSpec,
duration: number,
): Promise<{ issues: LayoutIssue[]; sampleCount: number }> {
const times = buildMotionSampleTimes(spec.duration ?? duration);
if (times.length === 0) return { issues: [], sampleCount: 0 };
const { selectors, livenessScopes } = collectSamplingTargets(spec.assertions);
const ambiguous = await findAmbiguousSelectors(page, selectors);
if (ambiguous.length > 0) return { issues: ambiguous, sampleCount: 0 };
const canvas = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
const frames = await collectMotionFrames(page, times, selectors, livenessScopes);
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
}
/** Read + validate the motion sidecar; print the error and exit on a bad spec. */
function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
const parsed = readMotionSpec(specPath);
if (parsed.ok) return parsed.spec;
const message = `Invalid motion spec ${specPath}: ${parsed.errors.join("; ")}`;
if (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,
),
);
} else {
console.error(`${c.error("✗")} ${message}`);
}
process.exit(1);
}
function parseAt(value: unknown): number[] | undefined {
@@ -319,7 +459,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
return defineCommand({
meta: {
name: commandName,
description: "Inspect rendered composition layout for text and container overflow",
description:
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
@@ -386,11 +527,21 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
const strict = !!args.strict;
const collapseStatic = args["collapse-static"] !== false;
// Motion verification (#1437): an optional `*.motion.json` sidecar opts the
// composition into seeked-timeline assertion checks. Absent → layout-only.
const motionSpecPath = findMotionSpec(project.dir);
const motionSpec = motionSpecPath
? resolveMotionSpec(motionSpecPath, !!args.json)
: undefined;
if (!args.json) {
const baseLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`;
const sampleLabel = atTransitions ? `${baseLabel} + transition boundaries` : baseLabel;
const motionLabel = motionSpec
? ` + motion spec (${motionSpec.assertions.length} assertion(s))`
: "";
console.log(
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`,
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel}${motionLabel})`,
);
}
@@ -402,6 +553,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
maxTransitionSamples,
timeout,
tolerance,
motion: motionSpec,
});
if (!args.json && result.transitionSamplesDropped > 0) {
console.log(
@@ -429,6 +581,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
tolerance,
strict,
collapseStatic,
motionSpec: motionSpec ? motionSpecPath : undefined,
motionSamples: motionSpec ? result.motionSamples : undefined,
...summary,
totalIssueCount: limited.totalIssueCount,
truncated: limited.truncated,
@@ -0,0 +1,121 @@
// In-page motion sampler for `hyperframes inspect` motion verification (#1437).
// Runs inside the seeked, paused page (via page.evaluate). For each asserted
// selector it returns this frame's { rect, opacity, visible }; for each liveness
// scope it returns a bucketed signature of all visible elements, so the Node-side
// evaluator can detect frozen windows by comparing signatures across frames.
(function () {
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
function round(value) {
return Math.round(value * 100) / 100;
}
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 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;
}
// Mirrors layout-audit.browser.js isVisibleElement.
// fallow-ignore-next-line complexity
function isVisibleElement(element) {
if (IGNORE_TAGS.has(element.tagName)) 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 sampleElement(element) {
const rect = element.getBoundingClientRect();
return {
rect: toRect(rect),
opacity: round(opacityChain(element)),
visible: isVisibleElement(element),
};
}
function compositionRoot() {
return document.querySelector("[data-composition-id]") || document.body;
}
// ponytail: bucket position to 2px and opacity to 0.08 so the RFC's "moves ≥2px /
// opacity ≥0.08" thresholds fall out of bucketing. Boundary-straddling moves are
// approximate — good enough for liveness; tighten only if false negatives show up.
function elementSignature(element) {
const rect = element.getBoundingClientRect();
const bx = Math.round(rect.left / 2);
const by = Math.round(rect.top / 2);
const bw = Math.round(rect.width / 2);
const bh = Math.round(rect.height / 2);
const bo = Math.round(opacityChain(element) / 0.08);
return bx + "," + by + "," + bw + "," + bh + "," + bo;
}
function livenessSignature(root) {
if (!root) return "";
const parts = [];
// ponytail: O(DOM) × MOTION_MAX_SAMPLES (300) — fine for typical compositions;
// narrow selector (e.g. "[id],[class]") if heavy-DOM compositions slow this down.
const all = root.querySelectorAll("*");
for (const element of all) {
if (!isVisibleElement(element)) continue;
parts.push(elementSignature(element));
}
return parts.join("|");
}
function safeQuery(selector) {
try {
return document.querySelector(selector);
} catch {
return null;
}
}
function sampleSelectors(selectors) {
const data = {};
for (const selector of selectors) {
// Multi-match selectors are rejected before this point by findAmbiguousSelectors
// in layout.ts; querySelector is safe here.
const element = safeQuery(selector);
data[selector] = element ? sampleElement(element) : null;
}
return data;
}
function sampleLiveness(scopes) {
const liveness = {};
for (const scope of scopes) {
const root = scope === "*" ? compositionRoot() : safeQuery(scope);
liveness[scope] = livenessSignature(root);
}
return liveness;
}
window.__hyperframesMotionSample = function motionSample(options) {
const { selectors = [], livenessScopes = [] } = options || {};
return { data: sampleSelectors(selectors), liveness: sampleLiveness(livenessScopes) };
};
})();
@@ -0,0 +1,131 @@
// @vitest-environment happy-dom
// fallow-ignore-file code-duplication
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, "motion-sample.browser.js"), "utf-8");
interface Geo {
rect?: { left: number; top: number; width: number; height: number };
opacity?: string;
display?: string;
visibility?: string;
}
interface SampleResult {
data: Record<
string,
{ rect: { left: number; right: number }; opacity: number; visible: boolean } | null
>;
liveness: Record<string, string>;
}
function installGeometry(byId: Record<string, Geo>): void {
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const geo = byId[(element as Element).id] ?? {};
return {
display: geo.display ?? "block",
visibility: geo.visibility ?? "visible",
opacity: geo.opacity ?? "1",
} as unknown as CSSStyleDeclaration;
});
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
const geo = byId[this.id]?.rect ?? { left: 0, top: 0, width: 0, height: 0 };
return {
left: geo.left,
top: geo.top,
right: geo.left + geo.width,
bottom: geo.top + geo.height,
width: geo.width,
height: geo.height,
} as DOMRect;
});
}
function installScript(): void {
// eslint-disable-next-line no-new-func
new Function(script)();
}
function sample(options: { selectors?: string[]; livenessScopes?: string[] }): SampleResult {
const fn = (window as unknown as { __hyperframesMotionSample: (o: unknown) => SampleResult })
.__hyperframesMotionSample;
return fn(options);
}
describe("motion-sample.browser", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (window as unknown as { __hyperframesMotionSample?: unknown }).__hyperframesMotionSample;
});
it("samples a present, visible selector and returns null for an absent one", () => {
document.body.innerHTML = `
<div data-composition-id="main"><div id="headline">Hi</div></div>
`;
installGeometry({
headline: { rect: { left: 100, top: 50, width: 300, height: 80 }, opacity: "1" },
});
installScript();
const result = sample({ selectors: ["#headline", "#missing"] });
expect(result.data["#headline"]).toMatchObject({ visible: true, opacity: 1 });
expect(result.data["#headline"]?.rect).toMatchObject({ left: 100, right: 400 });
expect(result.data["#missing"]).toBeNull();
});
it("reflects inherited ancestor opacity", () => {
document.body.innerHTML = `
<div data-composition-id="main"><div id="wrap"><div id="headline">Hi</div></div></div>
`;
installGeometry({
wrap: { rect: { left: 0, top: 0, width: 400, height: 200 }, opacity: "0.5" },
headline: { rect: { left: 100, top: 50, width: 300, height: 80 }, opacity: "0.6" },
});
installScript();
const result = sample({ selectors: ["#headline"] });
expect(result.data["#headline"]?.opacity).toBeCloseTo(0.3, 5);
});
it("produces a different liveness signature when an element moves and an identical one when static", () => {
document.body.innerHTML = `<div data-composition-id="main"><div id="box">x</div></div>`;
installGeometry({ box: { rect: { left: 100, top: 100, width: 50, height: 50 } } });
installScript();
const before = sample({ livenessScopes: ["*"] }).liveness["*"];
vi.restoreAllMocks();
installGeometry({ box: { rect: { left: 100, top: 100, width: 50, height: 50 } } });
installScript();
const stillStatic = sample({ livenessScopes: ["*"] }).liveness["*"];
vi.restoreAllMocks();
installGeometry({ box: { rect: { left: 300, top: 100, width: 50, height: 50 } } });
installScript();
const moved = sample({ livenessScopes: ["*"] }).liveness["*"];
expect(stillStatic).toBe(before);
expect(moved).not.toBe(before);
});
it("scopes liveness to a withinSelector and returns empty for a missing scope", () => {
document.body.innerHTML = `
<div data-composition-id="main"><div id="scene"><div id="box">x</div></div></div>
`;
installGeometry({
scene: { rect: { left: 0, top: 0, width: 500, height: 500 } },
box: { rect: { left: 10, top: 10, width: 50, height: 50 } },
});
installScript();
const result = sample({ livenessScopes: ["#scene", "#nope"] });
expect((result.liveness["#scene"] ?? "").length).toBeGreaterThan(0);
expect(result.liveness["#nope"]).toBe("");
});
});
+8 -1
View File
@@ -15,7 +15,14 @@ export type LayoutIssueCode =
| "canvas_overflow"
| "container_overflow"
| "content_overlap"
| "text_occluded";
| "text_occluded"
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
| "motion_appears_late"
| "motion_out_of_order"
| "motion_off_frame"
| "motion_frozen"
| "motion_selector_missing"
| "motion_selector_ambiguous";
export type LayoutIssueSeverity = "error" | "warning" | "info";
+190
View File
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import {
collectSamplingTargets,
evaluateMotion,
type FrameSample,
type MotionFrame,
} from "./motionAudit.js";
import type { LayoutIssue } from "./layoutAudit.js";
import type { MotionAssertion } from "./motionSpec.js";
const CANVAS = { width: 1920, height: 1080 };
function expectOne(issues: LayoutIssue[]): LayoutIssue {
expect(issues).toHaveLength(1);
const issue = issues[0];
if (!issue) throw new Error("expected exactly one issue");
return issue;
}
function rect(left: number, top: number, width: number, height: number) {
return { left, top, right: left + width, bottom: top + height, width, height };
}
function visible(r = rect(100, 100, 200, 80), opacity = 1): FrameSample {
return { rect: r, opacity, visible: true };
}
const hidden: FrameSample = { rect: rect(0, 0, 0, 0), opacity: 0, visible: false };
/** Build frames at the given times; `at(time)` supplies per-selector samples + liveness. */
function frames(
times: number[],
at: (time: number) => {
data?: Record<string, FrameSample | null>;
liveness?: Record<string, string>;
},
): MotionFrame[] {
return times.map((time) => {
const { data = {}, liveness = {} } = at(time);
return { time, data, liveness: { "*": "x", ...liveness } };
});
}
describe("appearsBy", () => {
const assertion: MotionAssertion = { kind: "appearsBy", selector: "#h", bySec: 0.5 };
it("passes when visible by the deadline", () => {
const f = frames([0.1, 0.3, 0.6], (t) => ({ data: { "#h": t >= 0.3 ? visible() : hidden } }));
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
});
it("flags a late entrance with both times", () => {
const f = frames([0.3, 0.83, 1.2], (t) => ({ data: { "#h": t >= 0.83 ? visible() : hidden } }));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_appears_late");
expect(issue.message).toContain("0.83s");
expect(issue.message).toContain("0.5s");
});
it("flags an element that never reaches visible opacity", () => {
const f = frames([0.3, 0.6], () => ({
data: { "#h": { rect: rect(0, 0, 10, 10), opacity: 0.2, visible: true } },
}));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_appears_late");
expect(issue.message).toContain("never");
});
it("flags a selector that matches nothing", () => {
const f = frames([0.3, 0.6], () => ({ data: { "#h": null } }));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_selector_missing");
});
});
describe("before", () => {
const assertion: MotionAssertion = { kind: "before", a: "#a", b: "#b" };
it("passes when a appears before b", () => {
const f = frames([0.2, 0.4], (t) => ({
data: { "#a": visible(), "#b": t >= 0.4 ? visible() : hidden },
}));
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
});
it("flags reversed order", () => {
const f = frames([0.2, 0.4], (t) => ({
data: { "#a": t >= 0.4 ? visible() : hidden, "#b": visible() },
}));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_out_of_order");
});
it("treats a simultaneous appearance as out of order (strict before)", () => {
const f = frames([0.2, 0.4], () => ({ data: { "#a": visible(), "#b": visible() } }));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_out_of_order");
});
});
describe("staysInFrame", () => {
const assertion: MotionAssertion = { kind: "staysInFrame", selector: ".card" };
it("passes when the box stays inside the canvas", () => {
const f = frames([0, 1, 2], () => ({ data: { ".card": visible(rect(100, 100, 200, 80)) } }));
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
});
it("flags drift past the right edge", () => {
const f = frames([0, 1, 2], (t) => ({
data: { ".card": visible(rect(t >= 2 ? 1850 : 100, 100, 200, 80)) },
}));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_off_frame");
expect(issue.time).toBe(2);
});
it("ignores off-canvas position before the element is first visible", () => {
const f = frames([0, 1], (t) => ({
data: {
".card":
t < 1
? { rect: rect(5000, 0, 100, 100), opacity: 0, visible: false }
: visible(rect(100, 100, 200, 80)),
},
}));
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
});
});
describe("keepsMoving", () => {
it("passes when the signature changes every frame", () => {
const assertion: MotionAssertion = { kind: "keepsMoving" };
const f = frames([0, 1, 2, 3], (t) => ({ liveness: { "*": `sig-${t}` } }));
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
});
it("flags a static window longer than the threshold", () => {
const assertion: MotionAssertion = { kind: "keepsMoving", maxStaticSec: 2 };
// frozen 1s..4s (3s static) then moves
const f = frames([0, 1, 2, 3, 4, 5], (t) => ({
liveness: { "*": t >= 1 && t <= 4 ? "frozen" : `m-${t}` },
}));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_frozen");
expect(issue.time).toBe(1);
});
it("scopes liveness to withinSelector", () => {
const assertion: MotionAssertion = {
kind: "keepsMoving",
withinSelector: ".scene",
maxStaticSec: 1,
};
// .scene frozen the whole time, whole-canvas "*" moving — only the scope matters
const f = frames([0, 1, 2, 3], (t) => ({ liveness: { "*": `m-${t}`, ".scene": "frozen" } }));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_frozen");
expect(issue.selector).toBe(".scene");
});
it("flags a missing withinSelector instead of reporting it frozen", () => {
const assertion: MotionAssertion = { kind: "keepsMoving", withinSelector: ".nope" };
const f = frames([0, 1, 2], () => ({ liveness: { "*": "moving" } }));
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
expect(issue.code).toBe("motion_selector_missing");
});
});
describe("evaluateMotion edge cases", () => {
it("returns nothing for an empty frame set", () => {
expect(evaluateMotion([], [{ kind: "appearsBy", selector: "#h", bySec: 1 }], CANVAS)).toEqual(
[],
);
});
});
describe("collectSamplingTargets", () => {
it("collects selectors and liveness scopes without duplicates", () => {
const targets = collectSamplingTargets([
{ kind: "appearsBy", selector: "#h", bySec: 0.5 },
{ kind: "before", a: "#h", b: "#cta" },
{ kind: "staysInFrame", selector: ".card" },
{ kind: "keepsMoving", withinSelector: ".scene" },
{ kind: "keepsMoving" },
]);
expect(targets.selectors.sort()).toEqual(["#cta", "#h", ".card"]);
expect(targets.livenessScopes.sort()).toEqual(["*", ".scene"]);
});
});
+265
View File
@@ -0,0 +1,265 @@
import type { LayoutIssue, LayoutRect } from "./layoutAudit.js";
import type { MotionAssertion } from "./motionSpec.js";
/** Opacity at/above which an element counts as "appeared" (RFC: opacity ≥ threshold). */
const APPEAR_OPACITY = 0.5;
/** Pixels an element may exceed the canvas edge before it counts as off-frame. */
const FRAME_TOLERANCE = 1;
/** Default longest allowed fully-static window for keepsMoving, in seconds. */
const DEFAULT_MAX_STATIC_SEC = 2;
export interface FrameSample {
rect: LayoutRect;
opacity: number;
visible: boolean;
}
/** One seeked frame of the dense motion grid. */
export interface MotionFrame {
time: number;
/** Per asserted selector: its sample this frame, or null when it matched nothing. */
data: Record<string, FrameSample | null>;
/** Liveness signature per scope ("*" = whole canvas; otherwise a withinSelector). */
liveness: Record<string, string>;
}
export interface Canvas {
width: number;
height: number;
}
const ZERO_RECT: LayoutRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
export function ambiguousIssue(selector: string): LayoutIssue {
return {
code: "motion_selector_ambiguous",
severity: "error",
time: 0,
selector,
message: `${selector} matches multiple elements — use a more specific selector so the assertion targets exactly one`,
rect: ZERO_RECT,
fixHint:
"Use #id or :nth-child() instead of a class selector when multiple elements share the same class.",
};
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
function everMatched(frames: MotionFrame[], selector: string): boolean {
return frames.some((frame) => frame.data[selector] != null);
}
/** First frame where the selector is visible at/above the appear threshold. */
function firstAppear(
frames: MotionFrame[],
selector: string,
): { time: number; rect: LayoutRect } | null {
for (const frame of frames) {
const sample = frame.data[selector];
if (sample && sample.visible && sample.opacity >= APPEAR_OPACITY) {
return { time: frame.time, rect: sample.rect };
}
}
return null;
}
function missingIssue(selector: string, time: number): LayoutIssue {
return {
code: "motion_selector_missing",
severity: "error",
time,
selector,
message: `${selector} matched no element in any sampled frame — check the selector`,
rect: ZERO_RECT,
fixHint: "Verify the selector exists in the composition and is spelled correctly.",
};
}
function appearsBy(frames: MotionFrame[], selector: string, bySec: number): LayoutIssue[] {
if (!everMatched(frames, selector)) return [missingIssue(selector, 0)];
const appear = firstAppear(frames, selector);
if (appear && appear.time <= bySec) return [];
return [
{
code: "motion_appears_late",
severity: "error",
time: appear ? appear.time : bySec,
selector,
message: appear
? `appears at ${round(appear.time)}s but should be visible by ${round(bySec)}s (check its entrance reveal fires under seek)`
: `never reaches visible opacity but should be visible by ${round(bySec)}s (check its entrance reveal fires under seek)`,
rect: appear ? appear.rect : ZERO_RECT,
fixHint:
"The renderer seeks a paused timeline; a forward-only reveal can be skipped. Ensure the entrance is applied at this time, not only played through.",
},
];
}
function before(frames: MotionFrame[], a: string, b: string): LayoutIssue[] {
const issues: LayoutIssue[] = [];
if (!everMatched(frames, a)) issues.push(missingIssue(a, 0));
if (!everMatched(frames, b)) issues.push(missingIssue(b, 0));
if (issues.length > 0) return issues;
const appearA = firstAppear(frames, a);
const appearB = firstAppear(frames, b);
const timeA = appearA ? appearA.time : Number.POSITIVE_INFINITY;
const timeB = appearB ? appearB.time : Number.POSITIVE_INFINITY;
if (timeA < timeB) return [];
const label = (t: number) => (Number.isFinite(t) ? `${round(t)}s` : "never");
return [
{
code: "motion_out_of_order",
severity: "error",
time: Number.isFinite(timeA) ? timeA : 0,
selector: a,
message: `${a} should appear before ${b}, but ${a} appears at ${label(timeA)} and ${b} at ${label(timeB)} — reorder the entrances`,
rect: appearA ? appearA.rect : ZERO_RECT,
fixHint: `Make ${a}'s entrance land before ${b}'s on the timeline.`,
},
];
}
function isOffFrame(r: LayoutRect, canvas: Canvas): boolean {
return (
r.left < -FRAME_TOLERANCE ||
r.top < -FRAME_TOLERANCE ||
r.right > canvas.width + FRAME_TOLERANCE ||
r.bottom > canvas.height + FRAME_TOLERANCE
);
}
// Note: off-frame check uses sample.visible (opacity ≥ 0.2 from the browser sampler);
// the first-appear anchor uses APPEAR_OPACITY (0.5). Elements fading in between those
// thresholds are tracked for position but don't start the window — intentional.
function staysInFrame(frames: MotionFrame[], selector: string, canvas: Canvas): LayoutIssue[] {
if (!everMatched(frames, selector)) return [missingIssue(selector, 0)];
const appear = firstAppear(frames, selector);
if (!appear) return [];
for (const frame of frames) {
const sample = frame.data[selector];
if (frame.time < appear.time || !sample || !sample.visible) continue;
if (!isOffFrame(sample.rect, canvas)) continue;
const r = sample.rect;
return [
{
code: "motion_off_frame",
severity: "error",
time: frame.time,
selector,
message: `${selector} drifts off the ${canvas.width}×${canvas.height} canvas at ${round(frame.time)}s (box ${r.left},${r.top}${r.right},${r.bottom})`,
rect: r,
fixHint:
"Clamp the element's motion so its box stays within the canvas for the whole shot.",
},
];
}
return [];
}
function keepsMoving(
frames: MotionFrame[],
within: string | undefined,
maxStaticSec: number,
): LayoutIssue[] {
// "*" is reserved for whole-canvas scope; motionSpec.ts rejects it as a user-supplied withinSelector.
const scope = within ?? "*";
if (within && frames.every((frame) => !frame.liveness[scope])) {
return [missingIssue(within, 0)];
}
const issues: LayoutIssue[] = [];
const first = frames[0];
if (!first) return issues;
let runStart = first.time;
let runSig = first.liveness[scope] ?? "";
const flush = (endTime: number) => {
const span = endTime - runStart;
if (span > maxStaticSec) {
issues.push({
code: "motion_frozen",
severity: "error",
time: runStart,
selector: within ?? "composition",
message: `nothing moves${within ? ` within ${within}` : ""} between ${round(runStart)}s and ${round(endTime)}s (${round(span)}s static) — should keep moving`,
rect: ZERO_RECT,
fixHint:
"Add or extend motion so no shot freezes for this long, or shorten the static hold.",
});
}
};
let lastTime = runStart;
for (const frame of frames.slice(1)) {
lastTime = frame.time;
const sig = frame.liveness[scope] ?? "";
if (sig !== runSig) {
flush(frame.time);
runStart = frame.time;
runSig = sig;
}
}
flush(lastTime);
return issues;
}
/**
* Evaluate motion assertions against the dense `element × time` matrix.
* Pure — no browser. Findings reuse the LayoutIssue shape and flow through
* inspect's existing dedupe/collapse/limit/format pipeline.
*/
export function evaluateMotion(
frames: MotionFrame[],
assertions: MotionAssertion[],
canvas: Canvas,
): LayoutIssue[] {
if (frames.length === 0) return [];
return assertions.flatMap((assertion) => {
switch (assertion.kind) {
case "appearsBy":
return appearsBy(frames, assertion.selector, assertion.bySec);
case "before":
return before(frames, assertion.a, assertion.b);
case "staysInFrame":
return staysInFrame(frames, assertion.selector, canvas);
case "keepsMoving":
return keepsMoving(
frames,
assertion.withinSelector,
assertion.maxStaticSec ?? DEFAULT_MAX_STATIC_SEC,
);
}
});
}
/**
* Selectors and liveness scopes the in-page sampler must read for a spec.
* Selectors feed the per-element matrix; scopes feed keepsMoving liveness.
*/
export function collectSamplingTargets(assertions: MotionAssertion[]): {
selectors: string[];
livenessScopes: string[];
} {
const selectors = new Set<string>();
const scopes = new Set<string>();
for (const assertion of assertions) {
switch (assertion.kind) {
case "appearsBy":
case "staysInFrame":
selectors.add(assertion.selector);
break;
case "before":
selectors.add(assertion.a);
selectors.add(assertion.b);
break;
case "keepsMoving":
scopes.add(assertion.withinSelector ?? "*");
break;
}
}
return { selectors: [...selectors], livenessScopes: [...scopes] };
}
+168
View File
@@ -0,0 +1,168 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findMotionSpec, parseMotionSpec, readMotionSpec, type MotionSpec } from "./motionSpec.js";
const RFC_SPEC = {
duration: 6,
assertions: [
{ kind: "appearsBy", selector: "#headline", bySec: 0.5 },
{ kind: "before", a: "#headline", b: "#cta" },
{ kind: "staysInFrame", selector: ".card" },
{ kind: "keepsMoving", withinSelector: ".scene" },
],
};
function expectOk(result: ReturnType<typeof parseMotionSpec>): MotionSpec {
if (!result.ok) throw new Error(`expected ok, got errors: ${result.errors.join(", ")}`);
return result.spec;
}
describe("parseMotionSpec", () => {
it("parses the RFC four-assertion spec", () => {
const spec = expectOk(parseMotionSpec(RFC_SPEC));
expect(spec.duration).toBe(6);
expect(spec.assertions).toHaveLength(4);
expect(spec.assertions[0]).toEqual({ kind: "appearsBy", selector: "#headline", bySec: 0.5 });
expect(spec.assertions[3]).toEqual({ kind: "keepsMoving", withinSelector: ".scene" });
});
it("allows a missing duration", () => {
const spec = expectOk(
parseMotionSpec({ assertions: [{ kind: "staysInFrame", selector: ".card" }] }),
);
expect(spec.duration).toBeUndefined();
});
it("rejects an unknown assertion kind", () => {
const result = parseMotionSpec({ assertions: [{ kind: "onBeat", selector: "#x" }] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("unknown assertion kind");
});
it("reports per-field errors for missing required fields", () => {
const result = parseMotionSpec({
assertions: [
{ kind: "appearsBy", selector: "#h" },
{ kind: "before", a: "#a" },
{ kind: "staysInFrame" },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toHaveLength(3);
expect(result.errors[0]).toContain("bySec");
expect(result.errors[1]).toContain('"b"');
expect(result.errors[2]).toContain("selector");
}
});
it("rejects a non-object spec and an empty assertion list", () => {
expect(parseMotionSpec(42).ok).toBe(false);
expect(parseMotionSpec({ assertions: [] }).ok).toBe(false);
expect(parseMotionSpec({}).ok).toBe(false);
});
it("rejects a non-positive maxStaticSec", () => {
const result = parseMotionSpec({
assertions: [{ kind: "keepsMoving", maxStaticSec: 0 }],
});
expect(result.ok).toBe(false);
});
it("rejects an unsupported spec version", () => {
const result = parseMotionSpec({
version: 2,
assertions: [{ kind: "staysInFrame", selector: ".card" }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("version");
});
it("rejects NaN as duration", () => {
const result = parseMotionSpec({
duration: NaN,
assertions: [{ kind: "staysInFrame", selector: ".card" }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("duration");
});
it('rejects "*" as withinSelector', () => {
const result = parseMotionSpec({
assertions: [{ kind: "keepsMoving", withinSelector: "*" }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain('"*"');
});
});
describe("findMotionSpec", () => {
it("returns null when no sidecar is present", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-none-"));
writeFileSync(join(dir, "main.html"), "<div></div>");
expect(findMotionSpec(dir)).toBeNull();
});
it("finds the single sidecar", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-one-"));
writeFileSync(join(dir, "anything.motion.json"), "{}");
expect(findMotionSpec(dir)).toBe(join(dir, "anything.motion.json"));
});
it("prefers the sidecar matching a composition html basename", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-many-"));
writeFileSync(join(dir, "aaa.motion.json"), "{}");
writeFileSync(join(dir, "main.motion.json"), "{}");
writeFileSync(join(dir, "main.html"), "<div></div>");
expect(findMotionSpec(dir)).toBe(join(dir, "main.motion.json"));
});
it("throws when multiple sidecars each match a different composition", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-ambig-"));
writeFileSync(join(dir, "hero.motion.json"), "{}");
writeFileSync(join(dir, "landing.motion.json"), "{}");
writeFileSync(join(dir, "hero.html"), "<div></div>");
writeFileSync(join(dir, "landing.html"), "<div></div>");
expect(() => findMotionSpec(dir)).toThrow("ambiguous motion sidecars");
});
});
describe("readMotionSpec", () => {
it("returns error for a nonexistent file", () => {
const result = readMotionSpec("/tmp/__nonexistent_motion_spec__.json");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("could not read");
});
it("returns error for a file with invalid JSON", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-bad-"));
const path = join(dir, "bad.motion.json");
writeFileSync(path, "not json {{");
const result = readMotionSpec(path);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("could not read");
});
it("returns error for a file with a valid JSON but invalid spec", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-invalid-"));
const path = join(dir, "invalid.motion.json");
writeFileSync(path, JSON.stringify({ assertions: [] }));
const result = readMotionSpec(path);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors[0]).toContain("no assertions");
});
it("parses a valid sidecar file", () => {
const dir = mkdtempSync(join(tmpdir(), "motion-valid-"));
const path = join(dir, "main.motion.json");
writeFileSync(
path,
JSON.stringify({ assertions: [{ kind: "staysInFrame", selector: ".card" }] }),
);
const result = readMotionSpec(path);
expect(result.ok).toBe(true);
if (result.ok) expect(result.spec.assertions).toHaveLength(1);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, join } from "node:path";
/**
* Declarative motion-verification spec (issue #1437). A sidecar JSON file
* (`*.motion.json`) sits next to the composition; `inspect` evaluates these
* assertions against the same seeked timeline the renderer uses.
*/
export type MotionAssertion =
| { kind: "appearsBy"; selector: string; bySec: number }
| { kind: "before"; a: string; b: string }
| { kind: "staysInFrame"; selector: string }
| { kind: "keepsMoving"; withinSelector?: string; maxStaticSec?: number };
export interface MotionSpec {
version?: number;
duration?: number;
assertions: MotionAssertion[];
}
export type MotionSpecParse = { ok: true; spec: MotionSpec } | { ok: false; errors: string[] };
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSelector(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isPositive(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
type Validator = (raw: Record<string, unknown>, at: string) => MotionAssertion | string;
const VALIDATORS: Record<string, Validator> = {
appearsBy: (raw, at) => {
if (!isSelector(raw.selector))
return `${at} (appearsBy): "selector" must be a non-empty string`;
if (typeof raw.bySec !== "number" || !Number.isFinite(raw.bySec) || raw.bySec < 0)
return `${at} (appearsBy): "bySec" must be a number >= 0`;
return { kind: "appearsBy", selector: raw.selector, bySec: raw.bySec };
},
before: (raw, at) => {
if (!isSelector(raw.a)) return `${at} (before): "a" must be a non-empty string`;
if (!isSelector(raw.b)) return `${at} (before): "b" must be a non-empty string`;
return { kind: "before", a: raw.a, b: raw.b };
},
staysInFrame: (raw, at) => {
if (!isSelector(raw.selector))
return `${at} (staysInFrame): "selector" must be a non-empty string`;
return { kind: "staysInFrame", selector: raw.selector };
},
keepsMoving: (raw, at) => {
if (raw.withinSelector !== undefined && !isSelector(raw.withinSelector))
return `${at} (keepsMoving): "withinSelector" must be a non-empty string when present`;
if (raw.withinSelector === "*")
return `${at} (keepsMoving): "withinSelector" cannot be "*" — omit it for whole-composition liveness`;
if (raw.maxStaticSec !== undefined && !isPositive(raw.maxStaticSec))
return `${at} (keepsMoving): "maxStaticSec" must be a number > 0 when present`;
const assertion: Extract<MotionAssertion, { kind: "keepsMoving" }> = { kind: "keepsMoving" };
if (isSelector(raw.withinSelector)) assertion.withinSelector = raw.withinSelector;
if (isPositive(raw.maxStaticSec)) assertion.maxStaticSec = raw.maxStaticSec;
return assertion;
},
};
function validateAssertion(raw: unknown, index: number): MotionAssertion | string {
const at = `assertions[${index}]`;
if (!isObject(raw)) return `${at}: must be an object`;
const validator = typeof raw.kind === "string" ? VALIDATORS[raw.kind] : undefined;
if (!validator) return `${at}: unknown assertion kind ${JSON.stringify(raw.kind)}`;
return validator(raw, at);
}
export function parseMotionSpec(raw: unknown): MotionSpecParse {
if (!isObject(raw)) return { ok: false, errors: ["spec must be a JSON object"] };
if (raw.version !== undefined && raw.version !== 1)
return {
ok: false,
errors: [`spec version ${raw.version} is not supported — upgrade the hyperframes CLI`],
};
if (!Array.isArray(raw.assertions))
return { ok: false, errors: ['spec must have an "assertions" array'] };
if (
raw.duration !== undefined &&
(typeof raw.duration !== "number" || !Number.isFinite(raw.duration) || raw.duration <= 0)
)
return { ok: false, errors: ['"duration" must be a positive number when present'] };
const assertions: MotionAssertion[] = [];
const errors: string[] = [];
raw.assertions.forEach((entry, index) => {
const result = validateAssertion(entry, index);
if (typeof result === "string") errors.push(result);
else assertions.push(result);
});
if (errors.length > 0) return { ok: false, errors };
if (assertions.length === 0) return { ok: false, errors: ["spec has no assertions"] };
const spec: MotionSpec = { assertions };
if (typeof raw.duration === "number") spec.duration = raw.duration;
return { ok: true, spec };
}
/**
* Locate a `*.motion.json` sidecar in the project dir. When several exist,
* prefer the one whose basename matches a composition html file; otherwise
* take the first alphabetically. Throws when multiple sidecars each match a
* different composition the bundler and this resolver would diverge silently.
* Returns null when none is present.
*/
export function findMotionSpec(projectDir: string): string | null {
if (!existsSync(projectDir)) return null;
const entries = readdirSync(projectDir);
const sidecars = entries.filter((name) => name.endsWith(".motion.json")).sort();
if (!sidecars[0]) return null;
if (sidecars.length === 1) return join(projectDir, sidecars[0]);
const htmlBases = new Set(
entries.filter((name) => name.endsWith(".html")).map((name) => basename(name, ".html")),
);
const matched = sidecars.filter((name) => htmlBases.has(basename(name, ".motion.json")));
if (matched.length > 1) {
throw new Error(
`ambiguous motion sidecars in ${projectDir}: ${matched.join(", ")} each match a composition — remove the sidecars you do not need, or use one composition per project`,
);
}
return join(projectDir, matched[0] ?? sidecars[0]);
}
export function readMotionSpec(path: string): MotionSpecParse {
let raw: unknown;
try {
raw = JSON.parse(readFileSync(path, "utf-8"));
} catch (err) {
return { ok: false, errors: [`could not read ${basename(path)}: ${(err as Error).message}`] };
}
return parseMotionSpec(raw);
}
+2 -2
View File
@@ -21,9 +21,9 @@ Everything runs through `npx hyperframes` unless project instructions specify a
- CI / cross-host repro: `npx hyperframes render --docker --strict --output out.mp4`
- Cloud (long / large): `npx hyperframes lambda render ./my-project --width 1920 --height 1080 --wait` (see Lambda below)
Run lint, validate, and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `validate` loads the composition in headless Chrome and reports runtime console errors plus WCAG contrast issues. `inspect` seeks through the timeline and reports text spilling out of bubbles/containers or off the canvas.
Run lint, validate, and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `validate` loads the composition in headless Chrome and reports runtime console errors plus WCAG contrast issues. `inspect` seeks through the timeline and reports text spilling out of bubbles/containers or off the canvas — and, when a `*.motion.json` sidecar is present, verifies motion intent (entrances firing under seek, stagger order, in-frame, liveness) against that same seeked timeline.
For motion-heavy work, prefer snapshot-driven iteration — see `references/lint-validate-inspect.md` for the discipline.
For motion-heavy work, prefer snapshot-driven iteration and a `*.motion.json` sidecar — see `references/lint-validate-inspect.md` for the discipline and motion-verification spec.
## Agent Conventions
@@ -10,6 +10,7 @@ When the composition is animation-driven, run the checks before you reach for `p
- Capture `snapshot` at meaningful timeline states; look at the PNGs.
- Inspect snapshots _before_ tuning automated warnings — your eye catches what the auditor misses.
- Treat layout warnings as defects unless a snapshot proves the overflow is intentional, in which case mark it with `data-layout-allow-overflow`.
- State motion intent in a `*.motion.json` sidecar so `inspect` checks it automatically — entrances firing under seek, stagger order, in-frame, liveness. This is the closest automated proxy for "watch the MP4" and catches render-≠-preview bugs the eye misses (see **Motion verification** below).
## lint
@@ -82,6 +83,33 @@ Errors should be fixed before rendering. Warnings are surfaced for agent review;
`npx hyperframes layout` remains available as a compatibility alias for the same visual inspection pass.
### Motion verification (`*.motion.json` sidecar)
`inspect` also checks **motion intent** against the same seeked timeline the renderer uses — the closest automated proxy for "render the MP4 and watch it". It catches render-≠-preview bugs layout sampling can't: an entrance reveal the seek lands past, a broken stagger order, an element drifting off-frame mid-tween, a frozen shot.
Drop a `*.motion.json` sidecar next to the composition (matching the html basename when several compositions share a dir). `inspect` discovers it automatically — no flag, no authoring-framework changes. With no sidecar, `inspect` behaves exactly as before.
```json
{
"duration": 6,
"assertions": [
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
{ "kind": "before", "a": "#headline", "b": "#cta" },
{ "kind": "staysInFrame", "selector": ".card" },
{ "kind": "keepsMoving", "withinSelector": ".scene" }
]
}
```
| Assertion | Fails (code) when |
| ------------------------------ | --------------------------------------------------------------------------- |
| `appearsBy(selector, bySec)` | not visible (opacity ≥ 0.5) by `bySec``motion_appears_late` |
| `before(a, b)` | `a` does not first appear strictly before `b``motion_out_of_order` |
| `staysInFrame(selector)` | once visible, its box leaves the canvas — `motion_off_frame` |
| `keepsMoving(withinSelector?)` | a fully-static window exceeds `maxStaticSec` (default 2s) — `motion_frozen` |
`duration`, `withinSelector`, and `maxStaticSec` are optional. Findings are **errors by default** (a failed assertion fails the run, like a layout error — `--strict` still gates warnings) and appear in the same human and `--json` output as layout findings. A selector that matches nothing is reported as `motion_selector_missing` rather than silently passing — so a typo'd selector fails loudly. Use this in the feedback loop instead of eyeballing the render: assert what the motion is supposed to do, and let `inspect` tell you when the seek diverges from intent.
## snapshot
```bash