window.open with noopener always returns null to the opener per spec,
which caused the POPUP_BLOCKED fallback to always trigger. Removed
noopener so the opener gets a window reference for popup-blocked detection.
Note: without noopener, popup windows share the opener's event loop
in same-origin contexts, limiting parallelism. True multi-process
parallelism requires cross-origin isolation or future APIs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After seek, force offsetHeight reflow on all nested iframes (not just
the main document). Sub-compositions have their own GSAP timelines in
separate documents — without reflowing them, transform values (x, y)
captured by SnapDOM can be stale between frames, causing text/element
position jumps.
Also reorders the yield-before-reflow (let runtime propagate seek to
sub-compositions first, then reflow everything at once).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Seek to 0.1s first to trigger sub-composition iframe loading
- Wait for fonts.ready in all sub-composition iframes (not just main)
- Increase font timeout to 5s for CDN fonts
- Add more settle ticks (10x10ms) for complex GSAP nested timelines
- Double-seek to 0 after warmup for clean first frame
- Increase per-frame yield from 0ms to 4ms for better GSAP sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Vite middleware was reading request bodies as UTF-8 strings,
corrupting binary data from the browser renderer's blob upload.
Changed to Buffer.concat() for binary-safe body reading.
Also adds error handling for failed upload responses.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Opens N browser tabs (via window.open with noopener) for true
multi-process parallelism. Each tab gets its own Chromium renderer
process with independent main thread, bypassing the single-thread
SnapDOM bottleneck.
Architecture:
- TurboPool coordinator opens N minimized offscreen tabs
- Each tab loads composition via SnapdomFrameSource independently
- Frames captured as PNG, sent via BroadcastChannel to coordinator
- Coordinator decodes to ImageBitmap, feeds reorder buffer + encoder
- Concurrency: min(cores/2, 6), capped at 6 tabs
- Falls back to normal IframePool if popups blocked
Also:
- Add turbo toggle (lightning bolt) to Studio render UI
- Add /api/projects/:id/renders/upload endpoint for saving
browser-rendered videos to project renders directory
- Export isTurboSupported() from @hyperframes/renderer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Multi-tab parallel capture using noopener windows + BroadcastChannel
for true multi-process SnapDOM parallelism.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use async spawn for agent-browser wait so Node event loop stays active
for the upload HTTP server to process browser POST requests
- Add CORS headers to upload server responses
- Add frameSource: 'snapdom' to test page (headless Chrome auto-detects
tab-capture which hangs waiting for screen share permission)
- Copy source assets to output dir (CSS, fonts not inlined by compiler)
- Use compile-test.ts for compilation (injects runtime + __hf bridge)
- Write serve.json with cleanUrls:false to prevent .html→extensionless redirects
- Increase upload timeout from 10s to 300s for long compositions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Discover media elements from DOM when __player bridge doesn't expose hf.media
(scans both <video> and <audio> elements with data-start/data-duration)
- Resolve media src to absolute URLs so audio mixer fetch() works cross-origin
- Seek original video elements directly instead of clone+canvas overlay approach
(SnapDOM natively captures <video> via drawImage)
- Force synchronous reflow after GSAP seek to prevent stale computed styles
- Add warmup phase: wait for fonts.ready + settle ticks before first capture
- Replace all requestAnimationFrame with setTimeout for background tab support
- Fix interleaved capture/encoding progress by deferring encoding reports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary
- Upgrades `root_composition_missing_html_wrapper` from **warning** to **error** — a bare `<div data-composition-id>` as `index.html` without `<!DOCTYPE html>/<html>/<body>` causes browsers to quirks-mode, the preview server to fail, and the bundler to silently skip runtime injection
- Improves the error message to explain _why_ this is bad, and includes a snippet of the offending root element
- Skips `<template>`\-wrapped compositions (already caught by the separate `standalone_composition_wrapped_in_template` rule)
- Adds 8 tests covering the exact screenshot scenario, proper HTML, sub-compositions, plain HTML, and template wrappers
## Test plan
- [x] All 441 existing tests pass (`vitest run`)
- [x] 8 new tests for `root_composition_missing_html_wrapper` and `standalone_composition_wrapped_in_template`
- [x] TypeScript build clean (`tsc --noEmit`)
- [x] oxlint + oxfmt pass
- [x] Run `npx hyperframes lint` on a bare composition `index.html` and verify it now reports an error
Single-iframe capture keeps Chrome responsive during long renders.
With 8 parallel iframes, SnapDOM overwhelms the main thread and locks
the browser. Concurrency=1 trades speed for reliability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- iframe-pool: setTimeout(0) yield after each captured frame
- SnapDOM source: requestAnimationFrame after seek() before capture
to ensure layout/paint is processed
This prevents Chrome from becoming unresponsive during long renders,
keeping CDP, DevTools, and UI interactions functional.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The tts command was implemented (PR #201) but never added to the root-level
help display or documentation. This adds it to:
- help.ts GROUPS (AI & Integrations) so it appears in `hyperframes --help`
- docs/packages/cli.mdx with usage examples and flag reference
- CLAUDE.md "Adding CLI Commands" checklist: new steps 4-5 require adding
commands to help.ts groups and docs, preventing future omissions
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SnapDOM frame source now detects window.__player (studio runtime) in
addition to window.__hf and creates a bridge automatically. This lets
the renderer work with studio preview URLs directly.
- Studio useBrowserRender hook: force snapdom frame source and provide
explicit worker URL for Vite dev compatibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a "Browser" export button next to the existing server-side Export
button in the RenderQueue panel. Uses the @hyperframes/renderer package
to render compositions entirely client-side via WebCodecs, with progress
tracked in the existing job list and automatic file download on completion.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
drawElementImage: pixel-perfect capture via html-in-canvas API using
<canvas layoutsubtree> with iframe child. Achieves 42 dB PSNR vs 30 dB
for SnapDOM. Requires Chrome --enable-blink-features=CanvasDrawElement.
Tab-capture: experimental getDisplayMedia-based capture.
Also adds regression parity scripts for compiling test compositions and
comparing PSNR against golden reference renders.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Capture doc.body instead of doc.documentElement in SnapDOM to fix 75% viewport mismatch
- Make audio track conditional (hasAudio flag) to prevent corrupt empty audio tracks
- Add publishConfig for npm publishing
- Wire ProgressTracker for estimatedTimeRemaining and captureRate in progress callbacks
- Recompute video overlay canvas position per-frame for GSAP-animated elements
- Add AbortSignal to captureAll for cancel propagation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace requestAnimationFrame with setTimeout in __hf protocol polling
(rAF doesn't fire for off-screen iframes)
- Add workerUrl option to EncoderOptions and RenderConfig for configuring
the encoding worker URL (needed for bundled deployments)
- Add QA test page with self-contained CSS-animated composition
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix audio format: replace interleaveChannels with concatPlanarChannels
- Fix MIME type: derive video/mp4 vs video/webm from outputFormat in finalize
- Add isSupported() guard at top of render() for early failure
- Fix durationMs to report wall-clock render time (totalMs)
- Fix audio filter to use strict equality (hasAudio === true)
- Remove unused progress type from WorkerOutMessage union
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements mixAudio() with TDD: decodes audio sources, applies volume/timing
via GainNode, and renders a mixed PCM AudioBuffer using OfflineAudioContext.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the encoding pipeline for @hyperframes/renderer: worker message
protocol types, Web Worker entry point (VideoEncoder + MediaBunny muxer),
and the main-thread Encoder class that manages the worker lifecycle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements SnapdomFrameSource class that loads compositions in hidden iframes,
seeks via window.__hf.seek(), and captures DOM frames using @zumer/snapdom.
Adds jsdom dev dependency for the test environment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13-task plan covering package scaffold, SnapDOM frame source, WebCodecs
encoding worker, MediaBunny muxer, OfflineAudioContext audio mixer,
parallel iframe capture, video frame injector, and main orchestrator.
56 total steps with TDD approach.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Design spec for @hyperframes/renderer — a new browser-only package that
renders compositions to MP4 using WebCodecs + MediaBunny + OffscreenCanvas
workers, with SnapDOM as interim frame source and html-in-canvas as the
future pixel-perfect path. Replaces the need for Puppeteer + FFmpeg.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary
- **Replaces the studio's hand-rolled iframe + scaling in** **`Player.tsx`** with the `<hyperframes-player>` web component, eliminating duplicated ResizeObserver, dimension detection, and stage-size message handling
- **Adds a public** **`iframeElement`** **getter** to the player web component so the studio's `useTimelinePlayer` can still access the inner iframe for clip manifest parsing, timeline probing, and DOM inspection
- **Updates player package exports** to resolve from source for workspace consumers (matching `@hyperframes/core` pattern), while npm-published consumers still get built `dist/` files
### Why a separate player package?
1. **Zero dependencies, any framework** — 12KB vanilla web component vs 940KB React+Zustand+CodeMirror studio
2. **CDN-ready** — single `<script>` tag, no build pipeline needed
3. **Embeddable by third parties** — users embed compositions in their own sites without the studio
4. **Single source of truth** — studio now uses the player instead of duplicating its scaling/detection logic
## Test plan
- [x] `pnpm --filter @hyperframes/player typecheck` passes
- [x] `pnpm --filter @hyperframes/studio typecheck` passes
- [x] `pnpm --filter @hyperframes/studio build` passes
- [x] `pnpm --filter @hyperframes/studio test` passes (2 pre-existing failures, unrelated)
- [x] E2E: Standalone player loads composition, detects 4s GSAP timeline, controls work, play/pause works
- [x] E2E: Studio preview renders via `<hyperframes-player>`, `iframeElement` bridge works, playback controls sync correctly
## Summary
- Add comprehensive README for `@hyperframes/player` covering installation, usage, full API reference (attributes, properties, methods, events), sizing, and distribution formats
- Bump version from 0.2.2 to 0.2.4 to align with monorepo release
## Test plan
- [x] Verify README renders correctly on GitHub
- [x] Confirm package.json version matches monorepo (0.2.4)
## Summary
- The Vite dev server proxied studio renders to a separate producer server (port 9847) that needed to be started manually
- When the producer wasn't running, renders silently failed — red dot, no error message, no way to know what went wrong
- Replaced the proxy with direct in-process rendering via `@hyperframes/producer` — same code path as the CLI and embedded preview mode
- Removed ~70 lines of SSE proxy streaming code, replaced with the same ~20-line in-process pattern used everywhere else
## DX improvement
**Before:** `pnpm dev` + `npx tsx packages/producer/src/public-server.ts` (two terminals, easy to forget)
**After:** `pnpm dev` (renders work immediately)
## Testing
Verified manually: open studio via `pnpm dev`, navigate to a project, click Export — renders complete with live progress updates, no separate server needed.
## Summary
- Audio (and video) sounds broken/choppy on first play in the studio preview, but works fine on second play
- Root cause: `<audio>` elements default to `preload="metadata"`, which only fetches enough data to determine duration — not enough for smooth playback. When `el.play()` fires, the browser hasn't buffered the audio data yet
- The runtime now eagerly sets `preload="auto"` and calls `load()` during init, ensuring media is fully buffered before the user clicks play
- `syncRuntimeMedia` now defers `play()` on unbuffered media by registering a `canplay` listener, instead of silently swallowing the failure
## Testing
Verified with agent-browser against a 26s narration composition (soulscape-film):
```
# After fix — audio element state at init:
preload: "auto"
readyState: 4 (HAVE_ENOUGH_DATA)
buffered: 26.07s (entire file)
duration: 26.07s
```
Audio is fully buffered before any play attempt, so first-play works identically to subsequent plays.
## Files changed
- `packages/core/src/runtime/init.ts` — set `preload="auto"` + `load()` in `bindMediaMetadataListeners`
- `packages/core/src/runtime/media.ts` — defer `play()` on unbuffered media via `canplay` listener
- `packages/core/src/runtime/media.test.ts` — updated test + added unbuffered media test case
Add a FIFO semaphore to limit concurrent renders in the producer server,
preventing Chrome CPU contention that causes beginFrame failures.
- New Semaphore utility class (packages/producer/src/utils/semaphore.ts)
- Both blocking render and SSE renderStream handlers acquire/release the semaphore
- SSE stream sends a "queued" event when request must wait
- New GET /render/queue endpoint exposes active/queued render counts
- Configurable via HandlerOptions.maxConcurrentRenders or PRODUCER_MAX_CONCURRENT_RENDERS env var (default: 2)
- New --max-concurrent-renders CLI flag (1-10)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary
- When 2-3 renders run in parallel on Linux (beginFrame mode), Chrome's `HeadlessExperimental.beginFrame` fails with "Another frame is pending" due to CPU contention
- Extracts `sendBeginFrame` helper with exponential backoff retry (50ms–800ms, 5 attempts) — used by both the main capture path and the hasDamage=false fallback
- After retries exhaust, throws an actionable error instead of a raw protocol error
## Testing
### Environment
- Linux (Ubuntu 20.04), 8 cores
- `chrome-headless-shell` 146.0.7680.153 (beginFrame mode active)
- Test composition: 1920×1080, 5s duration, 30fps, 150 frames, 3 GSAP-animated elements
### Before fix (main)
Ran 3 parallel renders of the same composition simultaneously:
| Render | Result | Details |
|--------|--------|---------|
| R1 | Completed | 304 KB, 6.6s |
| R2 | **FAILED** | `Protocol error (HeadlessExperimental.beginFrame): Another frame is pending` at frame 120/150 |
| R3 | Completed | 304 KB, 6.7s |
The error is non-deterministic — it hits whichever worker loses the CDP frame contention race under CPU pressure.
### After fix (this branch)
Same 3 parallel renders:
| Render | Result | Details |
|--------|--------|---------|
| R1 | Completed | 304 KB, 7.3s |
| R2 | Completed | 304 KB, 7.3s |
| R3 | Completed | 304 KB, 7.3s |
All 3 succeeded. The slight increase in wall time (6.6s → 7.3s) is consistent with occasional retries absorbing transient contention without failing.
### Code review
- Both `beginFrame` call sites in `beginFrameCapture` (main capture path + hasDamage=false fallback) use the shared `sendBeginFrame` helper
- Backoff ceiling is 1.55s per frame (50+100+200+400+800ms), acceptable for transient contention
- beginFrame mode is Linux-only (`chrome-headless-shell` + `--enable-begin-frame-control`); macOS uses screenshot mode so the retry code path isn't exercised there
## Summary
- Parent-relative paths (e.g. `src="../file.wav"`) silently drop media from rendered MP4
- The compiler rewrites external paths to `hf-ext/` and copies files to the compiled directory, but both the audio mixer and video frame extractor only resolved against `projectDir` — never finding them
- Now checks `compiledDir` first (matching the file server's resolution order), then falls back to `projectDir`
- Fixes both `<audio>` and `<video>` elements with external paths
## Real-world context
Reported in Slack by Abhai — a TTS comparison video using `<audio src="../tts-voxcpm2.wav">` (audio file in parent directory, composition in subdirectory) rendered successfully but the output MP4 had no audio stream. The render completed without any error, silently dropping the audio.
## Testing
### Environment
- Linux (Ubuntu 20.04), ffmpeg 4.2
- Test composition: `subdir/index.html` with `<audio id="bg-audio" src="../test-audio.wav">`, WAV file at parent directory
### Before fix (main)
```
[AUDIO-DEBUG] element.src=hf-ext/tmp/hf-test-231/test-audio.wav
baseDir=/tmp/hf-test-231/subdir
[AUDIO-DEBUG] resolved srcPath=/tmp/hf-test-231/subdir/hf-ext/tmp/hf-test-231/test-audio.wav
exists=false
```
- Audio mixer tries `join(projectDir, "hf-ext/...")` → file doesn't exist at that path
- Output: **9.8 KB, video stream only** (confirmed via ffprobe)
- No error logged — audio silently dropped
### After fix (this branch)
```
[AUDIO-DEBUG] element.src=hf-ext/tmp/hf-test-231/test-audio.wav
baseDir=/tmp/hf-test-231/subdir
compiledDir=/tmp/.../compiled
[AUDIO-DEBUG] fromCompiled=/tmp/.../compiled/hf-ext/tmp/hf-test-231/test-audio.wav
exists=true
[AUDIO-DEBUG] resolved srcPath=/tmp/.../compiled/hf-ext/tmp/hf-test-231/test-audio.wav
exists=true
[AUDIO-RESULT] success=true, hasAudio=true
```
- Audio mixer checks `join(compiledDir, "hf-ext/...")` first → file found
- Output: **44.6 KB, video + audio streams** (confirmed via ffprobe)
### ffprobe comparison
| Branch | File size | Streams |
|--------|-----------|---------|
| `main` | 9.8 KB | `video (h264)` only |
| `fix` | 44.6 KB | `video (h264)` + `audio (aac)` |
### Path resolution flow
1. Compiler sees `<audio src="../test-audio.wav">`
2. Compiler resolves to absolute path, maps it to `hf-ext/tmp/.../test-audio.wav`
3. Compiler copies file to `compiled/hf-ext/tmp/.../test-audio.wav`
4. Audio mixer gets `element.src = "hf-ext/tmp/.../test-audio.wav"`
5. **main**: tries `join(projectDir, src)` → not found → silent drop
6. **fix**: tries `join(compiledDir, src)` first → found → audio mixed in
### Repro
```bash
mkdir -p /tmp/test/subdir
ffmpeg -f lavfi -i "sine=frequency=440:duration=2" /tmp/test/test-audio.wav -y
# Create subdir/index.html with <audio src="../test-audio.wav" ...>
cd /tmp/test/subdir && npx hyperframes render
ffprobe -v error -show_streams output.mp4 # video only on main, video+audio on fix
```
Add two new skill reference files that address measured LLM composition failures:
- fonts.md: Typography principles — banned fonts, guardrails for violations
(pairing two sans-serifs, defaulting to 400/700 weight), and guidance the LLM
genuinely doesn't apply without being told (register switching, tension as
meaning, easing direction as emotion). Includes Google Fonts API discovery
script with 7-category multi-strategy query.
- motion-principles.md: Motion design principles — guardrails for same-ease and
same-speed defaults, y-axis entrance monotony, and guidance for build/breathe/
resolve scene structure, hard cuts as intentional transitions, visual
composition rules for video-not-web density.
Both files validated against baseline evals: 3 compositions created without
guidance confirmed the LLM reaches for banned fonts (Inter, Cormorant Garamond,
Playfair Display, Roboto Condensed), uses power2.out on 45-72% of tweens,
enters 80%+ of elements from y-axis, and pairs multiple sans-serifs.
Also:
- Fix validate.ts $& replacement bug (runtime source containing $& caused
String.prototype.replace to re-insert the matched <script src=""> tag)
- Clean up font loading guidance across skills (compiler embeds automatically)
- Update house-style.md to reference fonts.md
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three new lint rules that catch structural issues causing compositions
to fail silently in preview:
- root_composition_missing_data_start: Root composition needs data-start="0"
for the runtime to begin playback
- standalone_composition_wrapped_in_template: index.html should not be
wrapped in <template> (only sub-compositions use that)
- root_composition_missing_html_wrapper: index.html needs <!DOCTYPE html>
and <html> wrapper for the bundler
Also adds rawSource to LintContext so rules can inspect pre-template-stripped
HTML, and isSubComposition to linter options so rules can distinguish root
from sub-composition files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): smart port selection with instance reuse
Replace the simple 10-port retry loop with best-in-class port handling:
- Multi-host port testing (127.0.0.1, 0.0.0.0, ::1, ::) catches ports
occupied by SSH forwarding or other interfaces invisible to localhost
- HTTP probe (/__hyperframes_config) detects existing HyperFrames
preview servers — reuses same-project instances instead of spawning
duplicates, skips different-project instances
- PID detection via lsof for actionable "Port N in use by PID X" logs
- Expanded scan range from 10 to 100 ports
- Added --force-new flag to bypass instance detection
- Async PID detection (execFile, no shell) and parallel host testing
Fixes the "10 ports are all in use" error that occurs when zombie
preview servers accumulate or devbox port forwarding occupies ports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add --list and --kill-all flags to preview command
- `hyperframes preview --list` scans the port range and displays all
active HyperFrames preview servers with their project name, directory,
and PID
- `hyperframes preview --kill-all` kills all active preview servers
- Port scanning uses parallel batched probes (20 at a time) for speed
Gives users visibility into zombie preview servers and a one-command
way to clean them up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: apply HyperFrames design system to Mintlify theme
Update docs config and add custom CSS to match the HyperFrames brand:
- Switch theme from mint to maple, replace cyan palette with warm neutrals
- Add Inter (body/headings) and IBM Plex Mono (code) fonts
- Add custom.css with full light/dark mode CSS variables
- Default to light mode appearance
- Replace box-shadow hover effects with border-color (flat aesthetic)
- Add DESIGN.md to repo root as design system reference
- Fix docs CI to also trigger on DOCS_GUIDELINES.md pushes to main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: replace HeyGen logo with HyperFrames text wordmark
Replace 41KB HeyGen SVG logos with lightweight (~400B) text-based SVGs
rendering "HyperFrames" in Inter semibold with tight tracking, matching
the wordmark style on hyperframes.heygen.com.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: use ABC Solar Display font for logo wordmark
Match the exact font rendering from hyperframes.heygen.com:
- Load ABC Solar Display Bold from HeyGen static assets CDN
- SVGs use 15.2px/600w/-0.15 letter-spacing (matches computed styles)
- Dark mode fill matches rgb(240,240,240) from the website
- Add @font-face in custom.css for site-wide availability
- Fix lefthook: remove css from oxfmt glob (oxfmt doesn't support CSS)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: convert logo SVGs to outlined paths
SVG <text> elements don't render custom fonts when loaded as <img>
(browser security restriction). Convert the ABC Solar Display glyphs
to SVG paths extracted from the font outlines — renders identically
everywhere with zero font dependency. Remove @font-face for the
display font from custom.css since it's no longer needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: constrain logo height to match website sizing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "docs: constrain logo height to match website sizing"
This reverts commit 89e9cb598e.
* docs: switch body font from Inter to TT Norms Pro
Use TT Norms Pro (from HeyGen static assets CDN) to match
hyperframes.heygen.com. Loads weights 400-700 via @font-face
with Inter as fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>