diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts
new file mode 100644
index 0000000000..bb66418b09
--- /dev/null
+++ b/packages/cli/src/commands/keyframes.test.ts
@@ -0,0 +1,48 @@
+import { beforeAll, describe, expect, it } from "vitest";
+import { ensureDOMParser } from "../utils/dom.js";
+import { surfaceComposition } from "./keyframes.js";
+
+beforeAll(() => ensureDOMParser());
+
+const wrap = (script: string) =>
+ `
`;
+
+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);
+ });
+});
diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts
index c346d265f3..3a59a9e641 100644
--- a/packages/cli/src/commands/keyframes.ts
+++ b/packages/cli/src/commands/keyframes.ts
@@ -40,10 +40,30 @@ interface SurfacedTween {
path: Array<{ x: number; y: number }> | null;
}
+/** One drawn stroke of a multi-stroke trace — a single position tween. */
+interface TraceStroke {
+ id: string;
+ start: number;
+ end: number;
+ keyframes: KeyframePoint[];
+ points: Array<{ x: number; y: number }>;
+}
+
+/** An element's position motion composited into ordered strokes. The gaps
+ * between strokes are pen-up jumps (a 0-duration `set`, or a discontinuity)
+ * and are NOT drawn — this is how one element traces shapes with holes or
+ * detached parts (a `?` dot, an icon counter, multi-letter words). */
+interface SurfacedTrace {
+ target: string;
+ strokes: TraceStroke[];
+}
+
interface SurfacedComposition {
composition: string;
source: string;
tweens: SurfacedTween[];
+ /** Multi-stroke traces: targets with ≥2 drawn position strokes, composited. */
+ traces: SurfacedTrace[];
}
// ── GSAP extraction ──────────────────────────────────────────────────────────
@@ -182,13 +202,24 @@ function surfaceTween(anim: GsapAnimation): SurfacedTween {
// ── ASCII motion path ────────────────────────────────────────────────────────
-/** Plot position points into a compact grid so an agent can SEE the motion
- * shape. Each keyframe is marked with its index (0–9, then a–z); the path is
- * traced with light dots. Coordinates are GSAP x/y offsets (px). */
-function asciiPath(points: Array<{ x: number; y: number }>, width = 48, height = 11): string[] {
- if (points.length === 0) return [];
- const xs = points.map((p) => p.x);
- const ys = points.map((p) => p.y);
+type Pt = { x: number; y: number };
+
+/** Core plotter: render one or more strokes into a shared-scale ASCII grid.
+ * Dots connect only WITHIN a stroke (never across a pen-up gap); keyframes are
+ * marked with a continuous index across strokes (0–9, a–z, A–Z), or — once a
+ * trace exceeds `denseAbove` points — only Start/End per stroke. `legend`
+ * builds the trailing caption from (dense, strokeCount). Coords are GSAP px. */
+function plotStrokes(
+ strokes: Pt[][],
+ denseAbove: number,
+ legend: (dense: boolean, strokeCount: number) => string,
+ width = 48,
+ height = 11,
+): string[] {
+ const all = strokes.flat();
+ if (all.length === 0) return [];
+ const xs = all.map((p) => p.x);
+ const ys = all.map((p) => p.y);
let minX = Math.min(...xs);
let maxX = Math.max(...xs);
let minY = Math.min(...ys);
@@ -206,47 +237,80 @@ function asciiPath(points: Array<{ x: number; y: number }>, width = 48, height =
const toCol = (x: number) => Math.round(((x - minX) / (maxX - minX)) * (cols - 1));
// Screen y grows downward — invert so up on screen = smaller gsap y.
const toRow = (y: number) => Math.round(((y - minY) / (maxY - minY)) * (rows - 1));
-
const grid: string[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => " "),
);
- // Sparse paths (≤36 pts) index each keyframe (0–9, a–z) so an agent can map a
- // mark to a keyframe to edit. Dense paths (gestures) only mark Start/End — the
- // shape is the signal; per-point exact values live in the keyframe list / JSON.
- const dense = points.length > 36;
- const mark = (i: number) => {
- if (dense) return i === 0 ? "S" : i === points.length - 1 ? "E" : "·";
- return i < 10 ? String(i) : String.fromCharCode(97 + (i - 10));
- };
-
- // Trace segments with dots first, then overwrite endpoints with index marks.
- for (let i = 0; i < points.length - 1; i++) {
- const c0 = toCol(points[i]!.x);
- const r0 = toRow(points[i]!.y);
- const c1 = toCol(points[i + 1]!.x);
- const r1 = toRow(points[i + 1]!.y);
- const steps = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0), 1);
- for (let s = 1; s < steps; s++) {
- const cc = Math.round(c0 + ((c1 - c0) * s) / steps);
- const rr = Math.round(r0 + ((r1 - r0) * s) / steps);
- if (grid[rr]![cc] === " ") grid[rr]![cc] = "·";
+ const dense = all.length > denseAbove;
+ // 0–9, a–z, then A–Z = 62 labels.
+ const markChar = (i: number) =>
+ i < 10
+ ? String(i)
+ : i < 36
+ ? String.fromCharCode(97 + (i - 10))
+ : String.fromCharCode(65 + (i - 36));
+
+ // Trace each stroke's own segments with dots — gaps between strokes stay blank.
+ for (const stroke of strokes) {
+ for (let i = 0; i < stroke.length - 1; i++) {
+ const c0 = toCol(stroke[i]!.x);
+ const r0 = toRow(stroke[i]!.y);
+ const c1 = toCol(stroke[i + 1]!.x);
+ const r1 = toRow(stroke[i + 1]!.y);
+ const steps = Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0), 1);
+ for (let s = 1; s < steps; s++) {
+ const cc = Math.round(c0 + ((c1 - c0) * s) / steps);
+ const rr = Math.round(r0 + ((r1 - r0) * s) / steps);
+ if (grid[rr]![cc] === " ") grid[rr]![cc] = "·";
+ }
}
}
- points.forEach((p, i) => {
- grid[toRow(p.y)]![toCol(p.x)] = mark(i);
- });
+ // Then overwrite endpoints with index marks (or S/E per stroke when dense).
+ let idx = 0;
+ for (const stroke of strokes) {
+ stroke.forEach((p, j) => {
+ grid[toRow(p.y)]![toCol(p.x)] = dense
+ ? j === 0
+ ? "S"
+ : j === stroke.length - 1
+ ? "E"
+ : "·"
+ : markChar(idx);
+ idx++;
+ });
+ }
const top = ` ┌${"─".repeat(cols)}┐`;
const body = grid.map((row) => ` │${row.join("")}│`);
const bottom = ` └${"─".repeat(cols)}┘`;
- const legend = dense ? "S→E, · path" : "marks 0..n = keyframe order";
- const axis = ` x ${Math.round(minX)}..${Math.round(maxX)} y ${Math.round(minY)}..${Math.round(maxY)} (gsap px; ${legend})`;
+ const axis = ` x ${Math.round(minX)}..${Math.round(maxX)} y ${Math.round(minY)}..${Math.round(maxY)} (gsap px; ${legend(dense, strokes.length)})`;
return [top, ...body, bottom, c.dim(axis)];
}
+/** Plot a single continuous position path (one tween). */
+function asciiPath(points: Pt[]): string[] {
+ return plotStrokes(points.length ? [points] : [], 36, (dense) =>
+ dense ? "S→E, · path" : "marks 0..n = keyframe order",
+ );
+}
+
+/** Plot a multi-stroke trace: all strokes share ONE scale, dots connect only
+ * within a stroke (never across a pen-up gap), marks run across strokes. */
+function asciiTrace(strokes: Pt[][]): string[] {
+ return plotStrokes(
+ strokes,
+ 62,
+ (dense, n) =>
+ `${n} strokes · pen-up gaps not drawn · ${dense ? "S→E per stroke, · path" : "marks run across strokes in order"}`,
+ );
+}
+
// ── Composition surfacing ────────────────────────────────────────────────────
-function surfaceComposition(html: string, label: string, source: string): SurfacedComposition {
+export function surfaceComposition(
+ html: string,
+ label: string,
+ source: string,
+): SurfacedComposition {
const script = inlineScriptText(html);
let animations: GsapAnimation[] = [];
try {
@@ -254,11 +318,38 @@ function surfaceComposition(html: string, label: string, source: string): Surfac
} catch {
animations = [];
}
- return {
- composition: label,
- source,
- tweens: animations.filter((a) => !isHoldMarker(a)).map(surfaceTween),
- };
+ const tweens = animations.filter((a) => !isHoldMarker(a)).map(surfaceTween);
+ return { composition: label, source, tweens, traces: groupTraces(tweens) };
+}
+
+// Group an element's DRAWN position strokes (to/from/fromTo/keyframes that carry
+// a path) into one ordered trace. A `set` with x/y is a pen-up jump — excluded
+// (not drawn). Only targets with ≥2 strokes become a composited trace; a single
+// stroke stays on the normal per-tween path so existing output is unchanged.
+function groupTraces(tweens: SurfacedTween[]): SurfacedTrace[] {
+ const byTarget = new Map();
+ for (const t of tweens) {
+ if (t.method === "set") continue;
+ if (!t.path || t.path.length < 2) continue;
+ const list = byTarget.get(t.target);
+ if (list) list.push(t);
+ else byTarget.set(t.target, [t]);
+ }
+ const traces: SurfacedTrace[] = [];
+ for (const [target, list] of byTarget) {
+ if (list.length < 2) continue;
+ const strokes = [...list]
+ .sort((a, b) => a.start - b.start)
+ .map((t) => ({
+ id: t.id,
+ start: t.start,
+ end: t.end,
+ keyframes: t.keyframes,
+ points: t.path!,
+ }));
+ traces.push({ target, strokes });
+ }
+ return traces;
}
function collectCompositions(indexPath: string): SurfacedComposition[] {
@@ -317,6 +408,21 @@ function printTween(t: SurfacedTween): void {
console.log();
}
+function printTrace(tr: SurfacedTrace): void {
+ const start = Math.min(...tr.strokes.map((s) => s.start));
+ const end = Math.max(...tr.strokes.map((s) => s.end));
+ const n = tr.strokes.length;
+ console.log(
+ ` ${c.accent(tr.target)}${c.dim(" position")} ${c.dim("trace")} ${c.dim(`${n} strokes`)} ${c.dim(`@${start}s→${end}s`)}`,
+ );
+ tr.strokes.forEach((s, i) => {
+ const kfLine = s.keyframes.map((k) => `${k.pct}% {${fmtProps(k.properties)}}`).join(" ");
+ console.log(` ${c.dim(`stroke ${i + 1}:`)} ${c.dim(kfLine)}`);
+ });
+ for (const line of asciiTrace(tr.strokes.map((s) => s.points))) console.log(line);
+ console.log();
+}
+
// ── Command ──────────────────────────────────────────────────────────────────
export default defineCommand({
@@ -332,6 +438,11 @@ export default defineCommand({
},
selector: { type: "string", description: "Only tweens matching this CSS selector" },
json: { type: "boolean", description: "Machine-readable JSON (for agents)", default: false },
+ shot: {
+ type: "string",
+ description:
+ "Screenshot the element with its motion-path overlaid (PNG path) — visual self-verify alongside the ASCII. Pair with --selector to pick the element.",
+ },
},
async run({ args }) {
ensureDOMParser();
@@ -340,23 +451,67 @@ export default defineCommand({
const raw = args.target?.trim();
let comps: SurfacedComposition[];
let projectName: string;
+ let projectDir: string | undefined;
if (raw && raw.endsWith(".html") && existsSync(raw) && statSync(raw).isFile()) {
comps = [surfaceComposition(readFileSync(raw, "utf-8"), basename(raw), raw)];
projectName = basename(raw);
+ projectDir = dirname(raw);
} else {
const project = resolveProject(raw);
comps = collectCompositions(project.indexPath);
projectName = project.name;
+ projectDir = project.dir;
}
if (args.selector) {
const sel = args.selector;
+ const matches = (target: string) => target.split(",").some((s) => s.trim() === sel);
comps = comps
.map((cmp) => ({
...cmp,
- tweens: cmp.tweens.filter((t) => t.target.split(",").some((s) => s.trim() === sel)),
+ tweens: cmp.tweens.filter((t) => matches(t.target)),
+ traces: cmp.traces.filter((tr) => matches(tr.target)),
}))
- .filter((cmp) => cmp.tweens.length > 0);
+ .filter((cmp) => cmp.tweens.length > 0 || cmp.traces.length > 0);
+ }
+
+ // --shot: render the composition headless, overlay the surfaced motion path
+ // on the real element, screenshot one frame for visual self-verification.
+ if (args.shot) {
+ const { captureMotionPathShot } = await import("./keyframesShot.js");
+ // Build one draw request per element: prefer multi-stroke traces, else
+ // each position tween's own path (each as a single stroke).
+ const requests: import("./keyframesShot.js").ShotRequest[] = [];
+ for (const cmp of comps) {
+ for (const tr of cmp.traces) {
+ requests.push({
+ selector: tr.target,
+ strokes: tr.strokes.map((s) => ({ points: s.points })),
+ });
+ }
+ const tracedIds = new Set(cmp.traces.flatMap((tr) => tr.strokes.map((s) => s.id)));
+ for (const t of cmp.tweens) {
+ if (t.method === "set" || tracedIds.has(t.id) || !t.path || !shouldPlotPath(t.path))
+ continue;
+ requests.push({ selector: t.target, strokes: [{ points: t.path }] });
+ }
+ }
+ if (!projectDir) {
+ console.log(c.dim("--shot needs a project directory (not a single .html file)."));
+ return;
+ }
+ if (requests.length === 0) {
+ console.log(c.dim("--shot: no position motion path to draw for the selected element(s)."));
+ return;
+ }
+ const saved = await captureMotionPathShot(projectDir, requests, resolve(args.shot));
+ console.log(`${c.success("◇")} motion-path screenshot saved ${c.accent(saved)}`);
+ console.log(
+ c.dim(
+ ` ${requests.length} element${requests.length === 1 ? "" : "s"} · open it to verify the path matches your target, then read the ASCII below.`,
+ ),
+ );
+ console.log();
}
if (args.json) {
@@ -374,9 +529,16 @@ export default defineCommand({
);
console.log();
for (const cmp of comps) {
- if (cmp.tweens.length === 0) continue;
+ if (cmp.tweens.length === 0 && cmp.traces.length === 0) continue;
console.log(c.bold(`${cmp.composition}`) + c.dim(` (${cmp.source})`));
- for (const t of cmp.tweens) printTween(t);
+ const tracedIds = new Set(cmp.traces.flatMap((tr) => tr.strokes.map((s) => s.id)));
+ const tracedTargets = new Set(cmp.traces.map((tr) => tr.target));
+ for (const tr of cmp.traces) printTrace(tr);
+ for (const t of cmp.tweens) {
+ if (tracedIds.has(t.id)) continue; // already shown as part of its trace
+ if (t.method === "set" && tracedTargets.has(t.target)) continue; // internal pen-up jump
+ printTween(t);
+ }
}
console.log(
c.dim("Tip: edit the keyframes: [...] / x/y values in source, then re-run to verify."),
diff --git a/packages/cli/src/commands/keyframesShot.ts b/packages/cli/src/commands/keyframesShot.ts
new file mode 100644
index 0000000000..e9891bd77b
--- /dev/null
+++ b/packages/cli/src/commands/keyframesShot.ts
@@ -0,0 +1,208 @@
+// Screenshot a composition's element with its keyframe motion-path overlaid, so
+// an agent can SELF-VERIFY the path visually (ground truth) alongside the ASCII
+// surface. Reuses the headless-Chrome + static-server pattern from layout.ts.
+//
+// One frame, not a video: the full path is drawn as a static per-stroke overlay
+// in the element's own x/y-offset space (home center + offset), the timeline is
+// seeked to the end so the element sits at its final pose, then screenshotted.
+
+import { writeFileSync } from "node:fs";
+
+export interface ShotStroke {
+ points: Array<{ x: number; y: number }>;
+}
+export interface ShotRequest {
+ /** CSS selector of the moving element to overlay (e.g. "#dot"). */
+ selector: string;
+ /** Ordered strokes (multi-stroke trace) or a single path as one stroke. */
+ strokes: ShotStroke[];
+}
+
+const STROKE_COLORS = [
+ "#5eead4",
+ "#fbbf24",
+ "#f472b6",
+ "#60a5fa",
+ "#a3e635",
+ "#fb923c",
+ "#e879f9",
+ "#34d399",
+ "#f87171",
+ "#a78bfa",
+ "#22d3ee",
+ "#facc15",
+ "#4ade80",
+ "#fb7185",
+ "#c084fc",
+ "#2dd4bf",
+ "#38bdf8",
+ "#fde047",
+];
+
+/** Render `projectDir`'s index headless, overlay each request's motion path on
+ * its element, screenshot to `outPath` (PNG). Returns the saved path. */
+export async function captureMotionPathShot(
+ projectDir: string,
+ requests: ShotRequest[],
+ outPath: string,
+): Promise {
+ const { ensureBrowser } = await import("../browser/manager.js");
+ const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js");
+ const puppeteer = await import("puppeteer-core");
+ const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
+
+ const html = await bundleToSingleHtml(projectDir);
+ const server = await serveStaticProjectHtml(
+ projectDir,
+ html,
+ "Failed to bind keyframes shot server",
+ );
+ let browserInstance: import("puppeteer-core").Browser | undefined;
+ try {
+ const browser = await ensureBrowser();
+ browserInstance = await puppeteer.default.launch({
+ headless: true,
+ executablePath: browser.executablePath,
+ args: [
+ "--no-sandbox",
+ "--disable-gpu",
+ "--disable-dev-shm-usage",
+ "--enable-webgl",
+ "--use-gl=angle",
+ "--use-angle=swiftshader",
+ ],
+ });
+ const page = await browserInstance.newPage();
+ await page.setViewport({ width: 1920, height: 1080 });
+ await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
+
+ // Size the viewport to the composition.
+ const size = await page.evaluate(() => {
+ const root = document.querySelector("[data-composition-id][data-width][data-height]");
+ const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0;
+ const h = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0;
+ return {
+ width: Number.isFinite(w) && w > 0 ? Math.min(w, 4096) : 1920,
+ height: Number.isFinite(h) && h > 0 ? Math.min(h, 4096) : 1080,
+ };
+ });
+ await page.setViewport(size);
+ await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
+ await page
+ .waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
+ timeout: 10000,
+ })
+ .catch(() => {});
+ try {
+ await page.evaluate(async () => {
+ const d = document as unknown as { fonts?: { ready?: Promise } };
+ if (d.fonts?.ready) await d.fonts.ready;
+ });
+ } catch {
+ // fonts API not present — proceed
+ }
+
+ // Seek to the END of the timeline so the element rests at its final pose.
+ await page.evaluate(() => {
+ const seekEnd = (tl: {
+ duration?: () => number;
+ totalDuration?: () => number;
+ pause?: () => void;
+ seek?: (t: number) => void;
+ progress?: (p: number) => void;
+ }) => {
+ try {
+ tl.pause?.();
+ const d = (tl.totalDuration?.() ?? tl.duration?.() ?? 0) as number;
+ if (typeof tl.seek === "function") tl.seek(Math.max(0, d - 0.001));
+ else tl.progress?.(0.999);
+ } catch {
+ // best-effort
+ }
+ };
+ const win = window as unknown as {
+ __timelines?: Record[0]>;
+ };
+ Object.values(win.__timelines ?? {}).forEach(seekEnd);
+ });
+ await new Promise((r) => setTimeout(r, 120));
+
+ // Draw the motion-path overlay for each request, in the element's own x/y
+ // offset space: home = element's layout center at translate(0,0), so a path
+ // point P maps to (home.x + P.x, home.y + P.y) in page pixels.
+ await page.evaluate(
+ (reqs: ShotRequest[], palette: string[]) => {
+ const NS = "http://www.w3.org/2000/svg";
+ const mk = (tag: string, attrs: Record) => {
+ const node = document.createElementNS(NS, tag);
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
+ return node;
+ };
+ const svg = mk("svg", {
+ style:
+ "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647",
+ viewBox: `0 0 ${window.innerWidth} ${window.innerHeight}`,
+ });
+ const defs = mk("defs", {});
+ defs.innerHTML = ``;
+ svg.appendChild(defs);
+ const line = (pts: string, col: string, w: number, o: number, glow: boolean) =>
+ svg.appendChild(
+ mk("polyline", {
+ points: pts,
+ fill: "none",
+ stroke: col,
+ "stroke-width": String(w),
+ "stroke-linejoin": "round",
+ "stroke-linecap": "round",
+ opacity: String(o),
+ ...(glow ? { filter: "url(#kfglow)" } : {}),
+ }),
+ );
+ const dot = (x: number, y: number, fill: string) =>
+ svg.appendChild(mk("circle", { cx: String(x), cy: String(y), r: "7", fill }));
+
+ // home = element layout center at translate(0,0); path point P → home + P.
+ const home = (sel: string) => {
+ const el = document.querySelector(sel) as HTMLElement | null;
+ if (!el) return null;
+ const m = new DOMMatrixReadOnly(getComputedStyle(el).transform);
+ const r = el.getBoundingClientRect();
+ return { x: r.left + r.width / 2 - m.m41, y: r.top + r.height / 2 - m.m42 };
+ };
+
+ let colorIdx = 0;
+ const drawReq = (req: ShotRequest) => {
+ const h = home(req.selector);
+ if (!h) return;
+ for (const stroke of req.strokes) {
+ const col = palette[colorIdx % palette.length] ?? "#5eead4";
+ colorIdx++;
+ const pts = stroke.points.map((p) => `${h.x + p.x},${h.y + p.y}`).join(" ");
+ if (stroke.points.length >= 2) {
+ line(pts, col, 16, 0.25, true); // soft glow
+ line(pts, col, 6, 0.95, false); // crisp core
+ }
+ const first = stroke.points[0];
+ const last = stroke.points[stroke.points.length - 1];
+ if (first) dot(h.x + first.x, h.y + first.y, "#22c55e"); // start
+ if (last && stroke.points.length > 1) dot(h.x + last.x, h.y + last.y, "#ef4444"); // end
+ }
+ };
+ reqs.forEach(drawReq);
+ document.body.appendChild(svg);
+ },
+ requests,
+ STROKE_COLORS,
+ );
+ await new Promise((r) => setTimeout(r, 60));
+
+ const buf = await page.screenshot({ type: "png" });
+ if (!buf) throw new Error("screenshot returned no data");
+ writeFileSync(outPath, buf as Uint8Array);
+ return outPath;
+ } finally {
+ await browserInstance?.close().catch(() => {});
+ await server.close().catch(() => {});
+ }
+}
diff --git a/skills/hyperframes-keyframes/SKILL.md b/skills/hyperframes-keyframes/SKILL.md
index 78df32a47e..1975214a6e 100644
--- a/skills/hyperframes-keyframes/SKILL.md
+++ b/skills/hyperframes-keyframes/SKILL.md
@@ -1,26 +1,35 @@
---
name: hyperframes-keyframes
-description: Read and edit GSAP keyframes and motion paths in a HyperFrames composition. Use whenever a task involves an element's MOTION over time — adding/removing/moving keyframes, refining a motion path, changing where or when something travels, debugging "why does it move there", or understanding an existing animation before editing it. Run `npx hyperframes keyframes` to surface every tween's keyframes + an ASCII motion-path so you can see and edit motion as data instead of guessing at raw numbers.
+description: "See and edit GSAP motion as data in a HyperFrames composition. Run `npx hyperframes keyframes` to surface every tween's keyframes + an ASCII drawing of the path, so you reason about an element's MOTION over time — add/move/remove keyframes, refine a path, trace a shape (logo / glyph / icon), debug 'why does it move there', or read an animation before editing. Supports multi-stroke traces (pen-up gaps) for shapes with holes or detached parts. Use whenever the task is about where/when something travels; for authoring new scenes from scratch see hyperframes-animation, for the dev-loop CLI see hyperframes-cli."
---
# HyperFrames Keyframes
-Editing motion by reading `keyframes: [{x:0},{x:-260}]` in source is guessing — you can't see the _shape_ a tween traces, only opaque numbers. `npx hyperframes keyframes` surfaces every GSAP tween, its keyframes (with absolute times), and an **ASCII motion-path drawing** so you can reason about motion, then edit precisely and verify.
+Editing motion by reading `keyframes: [{x:0},{x:-260}]` in source is guessing — you can't see the _shape_ a tween traces, only opaque numbers. `npx hyperframes keyframes` surfaces every GSAP tween, its keyframes (with absolute times), and an **ASCII drawing of the path** so you reason about motion visually, then edit precisely and verify by eye — all before you render.
+
+This is **read-then-edit-source**, not a mutation command — it never changes files. Pair it with `inspect` (layout over the timeline) and `render` to ship. For the composition contract (the single paused timeline, `data-duration`, determinism) see `hyperframes-core`; to author motion from scratch see `hyperframes-animation`.
## The loop
1. **Surface** — `npx hyperframes keyframes [dir|file]` (defaults to `./index.html` + sub-compositions).
-2. **Read** the path shape + keyframe list (or `--json` for exact data).
-3. **Edit** the `keyframes` / `x`/`y` values in the composition source.
-4. **Verify** — re-run `npx hyperframes keyframes` to confirm the new shape, then `npx hyperframes inspect` / `render`.
+2. **Read** the path shape + keyframe list against your intent (add `--json` for exact data).
+3. **Edit** the `keyframes` / `x`/`y` values in the composition `