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
+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);
}