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
+150
View File
@@ -0,0 +1,150 @@
import { PLAY_ICON, PAUSE_ICON } from "./styles.js";
export interface ControlsCallbacks {
onPlay: () => void;
onPause: () => void;
onSeek: (fraction: number) => void;
}
export function formatTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
export function createControls(
parent: ShadowRoot | HTMLElement,
callbacks: ControlsCallbacks,
): {
updateTime: (current: number, duration: number) => void;
updatePlaying: (playing: boolean) => void;
show: () => void;
hide: () => void;
destroy: () => void;
} {
const controls = document.createElement("div");
controls.className = "hfp-controls";
// Keep overlay interactions from falling through to the host-level click toggle.
controls.addEventListener("click", (e) => {
e.stopPropagation();
});
const playBtn = document.createElement("button");
playBtn.className = "hfp-play-btn";
playBtn.type = "button";
playBtn.innerHTML = PLAY_ICON;
playBtn.setAttribute("aria-label", "Play");
const scrubber = document.createElement("div");
scrubber.className = "hfp-scrubber";
const progress = document.createElement("div");
progress.className = "hfp-progress";
progress.style.width = "0%";
scrubber.appendChild(progress);
const time = document.createElement("span");
time.className = "hfp-time";
time.textContent = "0:00 / 0:00";
controls.appendChild(playBtn);
controls.appendChild(scrubber);
controls.appendChild(time);
parent.appendChild(controls);
let isPlaying = false;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
playBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (isPlaying) callbacks.onPause();
else callbacks.onPlay();
});
const handleScrubAt = (clientX: number) => {
const rect = scrubber.getBoundingClientRect();
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
callbacks.onSeek(fraction);
};
let scrubbing = false;
scrubber.addEventListener("mousedown", (e) => {
e.stopPropagation();
scrubbing = true;
handleScrubAt(e.clientX);
});
const onMouseMove = (e: MouseEvent) => {
if (scrubbing) handleScrubAt(e.clientX);
};
const onMouseUp = () => {
scrubbing = false;
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
scrubber.addEventListener(
"touchstart",
(e) => {
scrubbing = true;
const touch = e.touches[0];
if (touch) handleScrubAt(touch.clientX);
},
{ passive: true },
);
const onTouchMove = (e: TouchEvent) => {
if (scrubbing) {
const touch = e.touches[0];
if (touch) handleScrubAt(touch.clientX);
}
};
const onTouchEnd = () => {
scrubbing = false;
};
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("touchend", onTouchEnd);
const startHideTimer = () => {
if (hideTimeout) clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
if (isPlaying) controls.classList.add("hfp-hidden");
}, 3000);
};
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
host.addEventListener("mousemove", () => {
controls.classList.remove("hfp-hidden");
startHideTimer();
});
host.addEventListener("mouseleave", () => {
if (isPlaying) controls.classList.add("hfp-hidden");
});
return {
updateTime(current: number, duration: number) {
const pct = duration > 0 ? (current / duration) * 100 : 0;
progress.style.width = `${pct}%`;
time.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
},
updatePlaying(playing: boolean) {
isPlaying = playing;
playBtn.innerHTML = playing ? PAUSE_ICON : PLAY_ICON;
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
if (playing) startHideTimer();
else controls.classList.remove("hfp-hidden");
},
show() {
controls.style.display = "";
},
hide() {
controls.style.display = "none";
},
destroy() {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", onTouchEnd);
if (hideTimeout) clearTimeout(hideTimeout);
},
};
}