Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
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),
motion: () => import("./commands/motion.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
36 changes: 34 additions & 2 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,17 @@
};
}

// An ancestor (up to and including `stopAt`) that clips its overflow makes any
// text spilling past it invisible — that clipping IS the layout mechanism
// (odometer/ticker reels, masked windows), not a defect to report.
function clippedByAncestor(element, stopAt) {
for (let current = element; current; current = current.parentElement) {
if (current !== element && clipsOverflow(getComputedStyle(current))) return true;
if (current === stopAt) break;
}
return false;
}

function textOverflowIssues(element, root, rootRect, time, tolerance) {
const textRect = textRectFor(element);
if (!textRect) return [];
Expand All @@ -320,7 +331,11 @@
const container = nearestConstraint(element, root, rootRect);
const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect());
const containerOverflow = overflowFor(textRect, containerRect, tolerance);
if (containerOverflow && !hasAllowOverflowFlag(element)) {
if (
containerOverflow &&
!hasAllowOverflowFlag(element) &&
!clippedByAncestor(element, container)
) {
const style = getComputedStyle(element);
issues.push({
code: "text_box_overflow",
Expand Down Expand Up @@ -520,12 +535,29 @@
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
}

// The nearest ancestor establishing a 3D rendering context, or null. Elements
// sharing one are depth-sorted in 3D, so a "covering" hit is legitimate
// perspective (e.g. the back face of a preserve-3d cube), not a 2D overlap.
function preserve3dContext(element) {
for (let current = element; current; current = current.parentElement) {
const ts = getComputedStyle(current).transformStyle;
if (ts === "preserve-3d") return current;
}
return null;
}

function sharedPreserve3d(a, b) {
const ctx = preserve3dContext(a);
return !!ctx && ctx === preserve3dContext(b);
}

// The opaque element painted over (x, y), or null when the topmost element
// there is related to the text or non-opaque.
// there is related to the text, non-opaque, or sharing a 3D context with it.
function occluderAt(element, x, y) {
if (typeof document.elementFromPoint !== "function") return null;
const hit = document.elementFromPoint(x, y);
if (!isForeignElement(element, hit)) return null;
if (sharedPreserve3d(element, hit)) return null;
return isOpaqueOccluder(hit) ? hit : null;
}

Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/commands/motion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { beforeAll, describe, expect, it } from "vitest";
import { ensureDOMParser } from "../utils/dom.js";
import { surfaceComposition } from "./motion.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("motion 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("motion 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();
});
});
Loading