Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.
Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls
Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks
Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).
Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.
Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.
Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
which fallow's static analysis doesn't follow)
Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
- Zoom anchors to cursor position instead of always zooming toward center.
The resolvePreviewWheelZoom function now accepts cursorX/cursorY (offset
from viewport center) and uses the standard zoom-to-point formula to
adjust pan so the content point under the cursor stays fixed.
- Add visible "Reset" button (bottom-right) showing current zoom % when
not at fit zoom. Driven by settledZoom state that updates after the
200ms settle debounce, so no re-renders during active zoom gestures.
- Fix border-expands-inward bug: scaleIframeToFit in the player now uses
offsetWidth/offsetHeight instead of getBoundingClientRect. The latter
returns values inflated by ancestor CSS zoom, causing double-scaling
that made the iframe appear smaller than its container.
- Fix zoom HUD appearing during pan: split applyZoom (shows HUD) from
applyPan (silent) so trackpad/middle-mouse panning no longer flashes
the zoom percentage overlay.
- Fix stale closure performance regression: replace stageSize in effect
dependency arrays with stageSizeRef pattern. The old deps caused wheel
and pointer handlers to re-register on every viewport resize.
- Widen pan clamp range (Math.abs instead of Math.max(0,...)) so content
can float within the viewport when zoomed below fit — required for
zoom-to-cursor to work correctly at any zoom level.
Closes#900
## Summary
- `seek()` only called `seekAll()` under parent audio ownership, leaving the `<audio>` proxy playing while the timeline froze at the new seek target.
- The periodic `mirrorTime` drift-correction (`parent-media.ts`) would then yank `currentTime` back to the timeline position every ~80ms of accumulated drift, producing an audible stutter loop while the video frame stayed frozen.
- Fix: make `seek()` symmetric with `pause()` — pause the parent proxy before seeking it.
## Repro
1. Use the player in an environment where the runtime posts `media-autoplay-blocked` (mobile / autoplay-restricted contexts), promoting audio ownership to `"parent"`.
2. Start playback with audio.
3. Click anywhere on the scrubber while playing.
4. Before: audio stutters in a short loop while the video frame is frozen.
5. After: audio cleanly pauses at the new seek position.
## Test plan
- [x] Added regression test \`seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)\` in \`hyperframes-player.test.ts\`.
- [x] \`pnpm --filter @hyperframes/player test\` — 110/110 pass.
- [ ] Manual repro on a device where ownership flips to \`parent\`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)