feat: allow clip animation + ship <hyperframes-player> web component (#209)

## Summary

Two independent initiatives that improve agent DX and expand HyperFrames' reach.

### Initiative 1: Fix the Clip Animation Footgun

- `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element
- All other properties (opacity, transform, x, y, scale, etc.) are allowed silently
- This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1)

### Initiative 2: `<hyperframes-player>` Web Component

- New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped
- Iframe-based web component with Shadow DOM for perfect isolation
- Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events
- Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide
- Full docs page at `docs/packages/player.mdx`

## Before / After

### Clip animation lint

**Before (10/10 agents hit this):**

```
✗ gsap_animates_clip_element: GSAP animation targets a clip element.
  Selector "#title" resolves to element <div id="title" class="clip">.
  The framework manages clip visibility — animate an inner wrapper instead.
  Fix: Wrap content in a child <div> and target that with GSAP.
```

**After (only errors on actual conflicts):**

```
# This passes lint — no error:
tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0);

# This still errors — actual conflict with runtime:
tl.to("#title", { visibility: "hidden" }, 3);
✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element.
  Fix: Remove the visibility/display tween. Use opacity for fade effects.
```

### Embeddable player

**Before:** No way to embed a composition in a web page.
**After:**

```html
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
<hyperframes-player src="./composition/index.html" controls></hyperframes-player>
```

```js
const player = document.querySelector('hyperframes-player');
player.play();
player.pause();
player.seek(2.5);
player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration));
```

## Test plan

- [x] 427 core tests pass (20 GSAP lint tests with smart detection)
- [x] 7 player tests pass (formatTime + element registration)
- [x] TypeScript compiles cleanly (core + player)
- [x] Lint: GSAP animating clip with safe props → 0 errors
- [x] Lint: GSAP animating clip with `visibility` → 1 error (correct)
- [x] Player builds to 3.3KB gzipped ESM
- [x] Lockfile updated for CI
- [x] Docs page added at `docs/packages/player.mdx`
This commit is contained in:
Miguel Ángel
2026-04-06 19:59:39 +02:00
committed by GitHub
parent baa3d813be
commit 5655dabff6
18 changed files with 1332 additions and 25 deletions
+113 -11
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("GSAP rules", () => {
it("reports error when GSAP targets a clip element by id", () => {
it("does NOT error when GSAP animates opacity on a clip element (by id)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -19,13 +19,10 @@ describe("GSAP rules", () => {
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("inner wrapper");
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element by class", () => {
it("does NOT error when GSAP targets a clip element with safe properties (by class)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -42,8 +39,7 @@ describe("GSAP rules", () => {
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".my-card");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a child of a clip element", () => {
@@ -86,7 +82,7 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
it("does NOT error when GSAP targets a clip element with safe properties (class-only, no id)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -100,12 +96,118 @@ describe("GSAP rules", () => {
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP animates opacity on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Title</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#title", { opacity: 0, y: -50, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP animates transform props on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="box" class="clip" data-start="0" data-duration="5" data-track-index="0">
<div>Box</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { scale: 1.2, x: 100, rotation: 45, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("ERRORS when GSAP animates visibility on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<p>Overlay</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { visibility: "hidden", duration: 0.3 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".scene-card");
expect(finding?.elementId).toBeUndefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("visibility");
});
it("ERRORS when GSAP animates display on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card" class="clip" data-start="0" data-duration="5" data-track-index="0">
<p>Card</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#card", { display: "none", duration: 0.3 }, 3.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#card");
expect(finding?.message).toContain("display");
});
it("ERRORS when GSAP tween mixes safe properties with visibility on a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { opacity: 0, visibility: "hidden", duration: 0.3 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("visibility");
});
it("warns when tl.to animates x on an element with CSS translateX", () => {
+8 -3
View File
@@ -284,19 +284,24 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
}
// gsap_animates_clip_element
// gsap_animates_clip_element — only error when GSAP animates visibility/display
for (const win of gsapWindows) {
const sel = win.targetSelector;
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
if (!clipInfo) continue;
const conflictingProps = win.properties.filter(
(p) => p === "visibility" || p === "display",
);
if (conflictingProps.length === 0) continue;
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
findings.push({
code: "gsap_animates_clip_element",
severity: "error",
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`,
selector: sel,
elementId: clipInfo.id || undefined,
fixHint: "Wrap content in a child <div> and target that with GSAP.",
fixHint:
"Remove the visibility/display tween, or move the content into a child <div> and target that instead.",
snippet: truncateSnippet(win.raw),
});
}