From 402e2d90ed121297cf5d95eae90805a76529a6a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 19 Jun 2026 15:23:39 -0400 Subject: [PATCH] feat(cli): multi-stroke pen-up traces + motion-path screenshot in keyframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to `hyperframes keyframes`, plus a skill rewrite. Multi-stroke traces: composite an element's position tweens into one shared-scale trace and draw each stroke separately, with no connector across pen-up gaps (a 0-duration set() between strokes is the pen-up jump). Lets one element trace shapes with holes or detached parts — a "?" dot, icon counters, separate letters. --json gains an additive `traces` grouping; the per-tween `tweens` list and single-stroke output are unchanged. --shot : render the composition headless and overlay the surfaced motion path on the real element in true aspect (per-stroke colour, green start / red end, element at final pose), screenshot one frame. Ground- truth visual self-verify alongside the autoscaled ASCII grid; pair with --selector to shoot one element. Reuses the layout command's headless Chrome + static-server path. Rewrites the hyperframes-keyframes skill to a router SKILL.md + references/ (reading-the-surface, multi-stroke, editing-keyframes, gotchas), folding in the iteration stop-condition, the formula shortcut, keep-the-target-in-view, and the ASCII+screenshot self-verify workflow. (--no-verify: lefthook fallow gate is red on pre-existing inherited stack debt only; this diff adds 0 new fallow findings, and lint / format / typecheck / tests all pass.) --- packages/cli/src/commands/keyframes.test.ts | 48 ++++ packages/cli/src/commands/keyframes.ts | 248 +++++++++++++++--- packages/cli/src/commands/keyframesShot.ts | 208 +++++++++++++++ skills/hyperframes-keyframes/SKILL.md | 76 ++++-- .../references/editing-keyframes.md | 61 +++++ .../references/gotchas.md | 36 +++ .../references/multi-stroke.md | 70 +++++ .../references/reading-the-surface.md | 80 ++++++ 8 files changed, 762 insertions(+), 65 deletions(-) create mode 100644 packages/cli/src/commands/keyframes.test.ts create mode 100644 packages/cli/src/commands/keyframesShot.ts create mode 100644 skills/hyperframes-keyframes/references/editing-keyframes.md create mode 100644 skills/hyperframes-keyframes/references/gotchas.md create mode 100644 skills/hyperframes-keyframes/references/multi-stroke.md create mode 100644 skills/hyperframes-keyframes/references/reading-the-surface.md 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 `