Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
df47df2
feat(cli): keyframes command — surface GSAP motion + 3D onion-skin --…
miguel-heygen Jun 19, 2026
919cb2f
docs(skill): require verifying 3D motion from at least 3 camera angles
miguel-heygen Jun 20, 2026
d5748b3
docs(skill): nest elements for independent motion channels (avoid las…
miguel-heygen Jun 20, 2026
03028bc
docs(skill): include the nested-elements layered-motion section (cont…
miguel-heygen Jun 20, 2026
b3acadb
feat(cli): surface composed/ancestor motion for nested elements
miguel-heygen Jun 20, 2026
d46e7ba
docs(skill): layered-motion patterns (fast channel own tween, ground-…
miguel-heygen Jun 20, 2026
66da2c9
docs(skill): one-shot-from-reference methodology + deliver-named-chan…
miguel-heygen Jun 20, 2026
d086e0c
docs(skill): match-the-reference (anti over-interpretation) guard + h…
miguel-heygen Jun 20, 2026
1f6ef88
docs(skill): subtractive self-verify — delete anything in your output…
miguel-heygen Jun 20, 2026
5d2e831
feat(cli): rename keyframes command to motion
miguel-heygen Jun 22, 2026
21516fd
feat(skill): promote V0/V1 eval lessons into hyperframes-motion
miguel-heygen Jun 25, 2026
4eea7a7
feat(skill): promote V1 eval lessons (verified) into hyperframes-motion
miguel-heygen Jun 25, 2026
1854150
feat(skill): promote V2 eval lessons (verified) into hyperframes-motion
miguel-heygen Jun 25, 2026
cb87722
fix(core): motion parser surfaces more of the authored motion
miguel-heygen Jun 25, 2026
0f06880
fix(core): lint accepts object-literal __timelines registration
miguel-heygen Jun 25, 2026
4c313f4
fix(core): warn loudly when timelines registered but none bind
miguel-heygen Jun 25, 2026
da6f479
fix(cli): motion --shot --selector falls back to animated descendants
miguel-heygen Jun 25, 2026
638a14a
docs(skill): correct render contract + --shot vs snapshot
miguel-heygen Jun 25, 2026
7dad5ab
fix(cli): info --json reports correct resolution + duration
miguel-heygen Jun 25, 2026
6c21971
fix(cli): inspect suppresses intended-clip/3D layout false positives
miguel-heygen Jun 25, 2026
c5d6b1a
feat(cli): snapshot guarantees a tail frame + adds --angle
miguel-heygen Jun 25, 2026
e8c6451
fix(core): motion surfaces staggered collection tweens honestly
miguel-heygen Jun 25, 2026
1ffaf5b
feat(cli): motion --shot --ghost — rendered onion-skin for canvas/WebGL
miguel-heygen Jun 25, 2026
055a8f7
refactor(cli): rename motion command to keyframes and fix surfacer bugs
miguel-heygen Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/cli/src/cli.commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const cliSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "cli.ts"), "utf8");
const helpSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "help.ts"), "utf8");

function commandLoaderBlock(): string {
const match = cliSource.match(/const commandLoaders = \{([\s\S]*?)\n\};/);
expect(match).toBeTruthy();
return match![1]!;
}

describe("CLI command registration", () => {
it("registers keyframes as the only keyframe inspection command", () => {
const loaders = commandLoaderBlock();

expect(loaders).toMatch(/\bkeyframes:\s*\(\)\s*=>\s*import\("\.\/commands\/keyframes\.js"\)/);
expect(loaders).not.toMatch(/\bmotion:\s*\(\)\s*=>/);
expect(loaders).not.toContain("./commands/motion.js");
});

it("shows keyframes in root help", () => {
expect(helpSource).toContain(
'["keyframes", "Inspect keyframes and render onion-shot diagnostics"]',
);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const commandLoaders = {
lint: () => import("./commands/lint.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { orientation, durationFromHtml } from "./info.js";

describe("orientation", () => {
it("is landscape when width > height", () => {
expect(orientation(1920, 1080)).toBe("landscape");
});

it("is portrait when height > width", () => {
expect(orientation(1080, 1920)).toBe("portrait");
});

it("is square when width === height", () => {
expect(orientation(1080, 1080)).toBe("square");
});
});

describe("durationFromHtml", () => {
it("reads data-duration from the root composition element", () => {
const html = `<div data-composition-id="comp" data-width="1920" data-height="1080" data-start="0" data-duration="6"></div>`;
expect(durationFromHtml(html, 5)).toBe(6);
});

it("reads data-duration regardless of attribute order", () => {
const html = `<div data-duration="8" data-composition-id="comp"></div>`;
expect(durationFromHtml(html, 5)).toBe(8);
});

it("falls back to the computed timeline duration when no data-duration", () => {
const html = `<div data-composition-id="comp"></div>`;
expect(durationFromHtml(html, 5)).toBe(5);
});
});
26 changes: 23 additions & 3 deletions packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";

/** Derive orientation label from actual pixel dimensions. */
export function orientation(width: number, height: number): "landscape" | "portrait" | "square" {
if (width > height) return "landscape";
if (height > width) return "portrait";
return "square";
}

/**
* Duration of the composition: prefer the root element's data-duration,
* fall back to the computed timeline end.
*/
export function durationFromHtml(html: string, fallback: number): number {
const match =
html.match(/data-composition-id[^>]*data-duration=["']([\d.]+)["']/) ||
html.match(/data-duration=["']([\d.]+)["'][^>]*data-composition-id/);
const value = match?.[1] ? parseFloat(match[1]) : NaN;
return Number.isFinite(value) ? value : fallback;
}

function totalSize(dir: string): number {
let total = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
Expand Down Expand Up @@ -56,6 +75,7 @@ export default defineCommand({
const width = widthMatch?.[1] ? parseInt(widthMatch[1], 10) : fallback.width;
const height = heightMatch?.[1] ? parseInt(heightMatch[1], 10) : fallback.height;
const resolution = `${width}x${height}`;
const duration = durationFromHtml(html, maxEnd);
const size = totalSize(project.dir);

const typeCounts: Record<string, number> = {};
Expand All @@ -71,10 +91,10 @@ export default defineCommand({
JSON.stringify(
withMeta({
name: project.name,
resolution: parsed.resolution,
resolution: orientation(width, height),
width,
height,
duration: maxEnd,
duration,
elements: parsed.elements.length,
tracks: tracks.size,
types: typeCounts,
Expand All @@ -89,7 +109,7 @@ export default defineCommand({

console.log(`${c.success("◇")} ${c.accent(project.name)}`);
console.log(label("Resolution", resolution));
console.log(label("Duration", `${maxEnd.toFixed(1)}s`));
console.log(label("Duration", `${duration.toFixed(1)}s`));
console.log(label("Elements", `${parsed.elements.length}${typeStr ? ` (${typeStr})` : ""}`));
console.log(label("Tracks", `${tracks.size}`));
console.log(label("Size", formatBytes(size)));
Expand Down
143 changes: 143 additions & 0 deletions packages/cli/src/commands/keyframes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { beforeAll, describe, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { collectShotSelectors, surfaceComposition } from "./keyframes.js";

beforeAll(() => ensureDOMParser());

const wrap = (script: string) =>
`<!doctype html><html><body><div id="root" data-composition-id="main" data-duration="4"><div id="dot" class="clip"></div></div><script>${script}</script></body></html>`;

describe("keyframes multi-stroke traces", () => {
it("composites ≥2 position strokes on one element into a single trace", () => {
const html = wrap(`
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: { "0%": { x: -100, y: -150 }, "100%": { x: 80, y: -120 } }, duration: 1 });
tl.to("#dot", { keyframes: { "0%": { x: 80, y: 120 }, "100%": { x: 85, y: 140 } }, duration: 1 });
window.__timelines = [tl];
`);
const { traces } = surfaceComposition(html, "index.html", "index.html");
expect(traces).toHaveLength(1);
expect(traces[0]!.target).toBe("#dot");
expect(traces[0]!.strokes).toHaveLength(2);
});

it("treats a 0-duration set() between strokes as a pen-up jump, not a drawn stroke", () => {
const html = wrap(`
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 0 } }, duration: 1 });
tl.set("#dot", { x: 200, y: 200 });
tl.to("#dot", { keyframes: { "0%": { x: 200, y: 200 }, "100%": { x: 250, y: 250 } }, duration: 1 });
window.__timelines = [tl];
`);
const { traces } = surfaceComposition(html, "index.html", "index.html");
expect(traces).toHaveLength(1);
// two DRAWN strokes; the set() is the pen-up gap and is excluded
expect(traces[0]!.strokes).toHaveLength(2);
});

it("leaves a single-stroke element untraced (normal per-tween output)", () => {
const html = wrap(`
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 200, y: -100 }, "100%": { x: 0, y: 0 } }, duration: 3 });
window.__timelines = [tl];
`);
const { traces, tweens } = surfaceComposition(html, "index.html", "index.html");
expect(traces).toHaveLength(0);
expect(tweens.length).toBeGreaterThan(0);
});
});

describe("keyframes composed-ancestor surfacing (nested elements)", () => {
const nested = (script: string) =>
`<!doctype html><html><body><div id="root" data-composition-id="main" data-duration="4"><div id="stage"><div id="hero"><div id="core" class="clip"></div></div></div></div><script>${script}</script></body></html>`;

it("annotates a child tween with its animated ANCESTOR's motion", () => {
const html = nested(`
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { keyframes: { "0%": { x: -300, y: 0 }, "100%": { x: 300, y: 0 } }, duration: 4 }, 0);
tl.to("#core", { keyframes: { "0%": { scale: 1 }, "100%": { scale: 1.5 } }, duration: 4 }, 0);
window.__timelines = [tl];
`);
const { tweens } = surfaceComposition(html, "index.html", "index.html");
const core = tweens.find((t) => t.target === "#core");
expect(core?.composedWith?.map((a) => a.selector)).toContain("#hero");
// and the ancestor's path EXTENT is summarised (range, not endpoints — so a
// closed loop still reveals its travel)
expect(core?.composedWith?.[0]!.summary).toMatch(/x -300\.\.300/);
});

it("does not annotate when the parent isn't animated", () => {
const html = nested(`
const tl = gsap.timeline({ paused: true });
tl.to("#core", { keyframes: { "0%": { scale: 1 }, "100%": { scale: 1.5 } }, duration: 4 }, 0);
window.__timelines = [tl];
`);
const { tweens } = surfaceComposition(html, "index.html", "index.html");
expect(tweens.find((t) => t.target === "#core")?.composedWith).toBeUndefined();
});
});

describe("keyframes runtime surfacing", () => {
it("surfaces CSS @keyframes and their animated selectors", () => {
const html = `<!doctype html><html><head><style>
.dot { animation: rise 1200ms ease-out both; }
@keyframes rise {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}
</style></head><body><div class="dot"></div></body></html>`;
const { cssKeyframes } = surfaceComposition(html, "index.html", "index.html");
expect(cssKeyframes).toHaveLength(1);
expect(cssKeyframes[0]!.name).toBe("rise");
expect(cssKeyframes[0]!.selectors).toContain(".dot");
expect(cssKeyframes[0]!.keyframes.map((kf) => kf.selector)).toEqual(["0%", "100%"]);
});

it("does not let a CSS comment before @keyframes leak into the next rule's selector", () => {
const html = `<!doctype html><html><head><style>
/* Grain animation */
@keyframes rise { 0% { opacity: 0; } 100% { opacity: 1; } }
.dot { animation: rise 1s both; }
</style></head><body><div class="dot"></div></body></html>`;
const { cssKeyframes } = surfaceComposition(html, "index.html", "index.html");
expect(cssKeyframes[0]!.selectors).toEqual([".dot"]);
});

it("surfaces Anime.js calls and explicit HyperFrames registration", () => {
const html = wrap(`
const tl = anime.createTimeline({ autoplay: false });
tl.add(".chip", { translateX: [0, 240], duration: 900 });
window.__hfAnime = window.__hfAnime || [];
window.__hfAnime.push(tl);
`);
const { anime } = surfaceComposition(html, "index.html", "index.html");
expect(anime).toHaveLength(1);
expect(anime[0]!.kind).toBe("timeline");
expect(anime[0]!.registered).toBe(true);
expect(anime[0]!.targets).toContain(".chip");
expect(anime[0]!.durations).toContain(900);
});

it("uses CSS and Anime targets as onion-shot candidates", () => {
const cssHtml = `<!doctype html><html><head><style>
.dot { animation: rise 1200ms ease-out both; }
@keyframes rise {
0% { transform: translateY(40px); }
100% { transform: translateY(0); }
}
</style></head><body><div class="dot"></div></body></html>`;
const animeHtml = wrap(`
const tl = anime.createTimeline({ autoplay: false });
tl.add(".chip", { translateX: [0, 240], duration: 900 });
window.__hfAnime = window.__hfAnime || [];
window.__hfAnime.push(tl);
`);

const selectors = collectShotSelectors([
surfaceComposition(cssHtml, "css.html", "css.html"),
surfaceComposition(animeHtml, "anime.html", "anime.html"),
]).map((item) => item.selector);

expect(selectors).toEqual(expect.arrayContaining([".dot", ".chip"]));
});
});
Loading