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
+114
View File
@@ -0,0 +1,114 @@
export const PLAYER_STYLES = /* css */ `
:host {
display: block;
position: relative;
overflow: hidden;
background: #000;
contain: layout style;
}
.hfp-container {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.hfp-iframe {
position: absolute;
top: 50%;
left: 50%;
border: none;
pointer-events: none;
}
.hfp-poster {
position: absolute;
inset: 0;
object-fit: contain;
z-index: 1;
pointer-events: none;
}
.hfp-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
z-index: 10;
pointer-events: auto;
opacity: 1;
transition: opacity 0.3s ease;
user-select: none;
}
.hfp-controls.hfp-hidden {
opacity: 0;
pointer-events: none;
}
.hfp-play-btn {
background: none;
border: none;
color: #fff;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
z-index: 10;
}
.hfp-play-btn:hover {
opacity: 0.8;
}
.hfp-play-btn svg,
.hfp-play-btn svg * {
pointer-events: none;
}
.hfp-scrubber {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.hfp-scrubber:hover {
height: 6px;
}
.hfp-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #fff;
border-radius: 2px;
pointer-events: none;
}
.hfp-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
opacity: 0.9;
}
`;
export const PLAY_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><polygon points="4,2 16,9 4,16"/></svg>`;
export const PAUSE_ICON = `<svg width="24" height="24" viewBox="0 0 18 18" fill="currentColor"><rect x="3" y="2" width="4" height="14"/><rect x="11" y="2" width="4" height="14"/></svg>`;