The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.
Three coordinated fixes make it work end to end:
- Parser read: resolve querySelector / querySelectorAll / getElementById
variable targets (and inline lookup calls) back to their CSS selector,
so variable-targeted tweens are recognized.
- Parser write: replace the full re-serialize (preamble + tweens +
postamble) with in-place recast AST mutation. Edits now touch only the
targeted tween's vars/position node and reprint, preserving every
surrounding statement — gsap.set calls, element declarations, the IIFE
wrapper, comments and formatting. Previously the first edit would
discard all of that.
- Linter: build overlap/clip windows directly from the parser's
structured animations instead of a regex walk paired positionally with
the parsed list. The old pairing skipped variable targets and would
drift once the parser started returning them. Removes the now-dead
regex meta helpers.
- studio-api: extractGsapScriptBlock now searches inside <template>
content (sub-compositions wrap markup + the GSAP script in a template,
which linkedom's querySelectorAll doesn't descend into), and the
frontend matches tweens to the selected element by id OR selector
rather than id only (class-targeted elements have no id).
Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
* feat(studio): GSAP tween editing in Design panel
Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.
Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.
recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:
- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
reachable only via the @hyperframes/core/gsap-parser subpath, loaded
server-side by the studio-api mutation routes and the linter via dynamic
import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
bundles never trace recast.
Adds AST parser unit + stress coverage and e2e helpers for the panel.
* fix(lint): await async lintHyperframeHtml in all callers
lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.
Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
* docs: document feedback collection — cadence, data, opt-out
Adds guides/feedback.mdx covering: when CLI and Studio prompts
appear (render cadence, session cadence), what data is collected
(PostHog survey fields, doctor_summary shape), what is not
collected, the hyperframes feedback command for manual/agent
submission, agent runtime detection and structured hint,
config file fields, and all opt-out paths (HYPERFRAMES_NO_TELEMETRY,
DO_NOT_TRACK, CI guard, --quiet).
Also adds hyperframes feedback command entry to packages/cli.mdx
(Utilities tab, alongside telemetry) and registers guides/feedback
in the docs.json nav.
— Magi
* docs(feedback): fix cadence, agent env vars, docker gate, telemetry scope, why-we-ask
- Cadence: 1st/16th/31st (not 15th/30th/45th) per actual code
- Agent vars: CLAUDECODE/CLAUDE_CODE_ENTRYPOINT, CODEX_THREAD_ID/CODEX_CI,
TERM_PROGRAM=cursor, Copilot value checks; add Hermes/openclaw/Pi
- Remove docker gate claim (non-TTY only, not docker-specific)
- Telemetry disable only suppresses CLI prompt, not Studio bar
- Add why-we-ask opening section
- Remove Studio 'skip' action (CLI-only); fix 'counter resets' phrasing
- Fix 'values never read' — Cursor and Copilot do value comparisons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(feedback): remove invented Studio opt-out flags; document localStorage workaround
VITE_HYPERFRAMES_FEEDBACK_INTERVAL=0 falls through to default (n > 0 guard).
VITE_HYPERFRAMES_FEEDBACK feature flag doesn't exist. Bar is mounted
unconditionally. Document the localStorage key workaround instead and
note that a proper flag is a follow-up to hf#1101.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(feedback): fix localStorage workaround — only lastPromptedAt needs to be large
Setting both keys to the same value just delays 10 sessions before the bar
reappears. Setting only lastPromptedAt to 9999999 keeps count - lastAt
negative indefinitely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(studio): add VITE_HYPERFRAMES_NO_FEEDBACK build-time disable flag
Sets isFeedbackDisabled() guard in shouldShowFeedback() — when
VITE_HYPERFRAMES_NO_FEEDBACK=1, bar never shows regardless of session count.
Updates docs to document the flag and remove the localStorage workaround.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(cli): prompt for render satisfaction after successful renders
* feat: add text feedback, doctor context, and Studio render feedback UI
* feat(studio): replace render feedback with session-based Studio experience bar
Move the feedback prompt out of RenderQueueItem (where it triggered every
5th render) into a standalone StudioFeedbackBar mounted at the bottom of
the preview area. The new bar is session-gated (shows after the 5th studio
session), auto-dismisses after 20s, and respects a 30-day cooldown once
dismissed or submitted. Renames telemetry to trackStudioFeedback with a
"studio_experience" survey ID to reflect the broader scope.
* feat(studio): attach browser doctor summary to feedback events
* fix(studio): use recurring interval for feedback instead of one-time cooldown
* fix(cli): skip feedback prompt when an agent runtime is detected
* feat(cli): add hyperframes feedback command and agent render hint
- New `hyperframes feedback --rating <1-5> --comment "..."` command
for submitting anonymous render satisfaction feedback via telemetry.
- When an AI agent runtime is detected after a render, print a dimmed
hint to stdout so the agent can optionally call the command instead
of silently skipping the readline prompt.
- Export getDoctorSummary from telemetry/feedback.ts to share the
system-info collector between the interactive prompt and the CLI command.
- Register the command in cli.ts and help.ts under Settings.
* fix(studio): align feedback interval to every 15 sessions
* fix: show CLI feedback on first render, Studio every 10 sessions
* feat: add env flags to disable feedback prompts
* feat: env flags to configure feedback prompt frequency
* fix: address review — agent hint reachability, cadence gate, session debounce, deprecated API
When forward playback reaches loopEnd and the loop wraps back to
loopStart, the RAF tick was calling `adapter.seek(loopStart)` without
keepPlaying, then immediately `adapter.play()` to resume. With the
post-3e7b464b wrapTimeline contract (default seek pauses), this means
every loop boundary executes pause→seek→pause→play for GSAP and a
stop/start RAF ticker cycle for the static-seek adapter — purely
unnecessary churn.
Pass { keepPlaying: true } so seek skips the implicit pause; the
follow-up adapter.play() is then a no-op because the underlying
adapter never paused. Adds two tests covering the wrap-around branch
(previously uncovered) and the no-loop terminal path as a regression
guard.
Completes the keepPlaying rollout: #842 introduced the option for A/E
shortcuts, #863 extended it to the runtime player, #1089 aligned the
static-seek adapter, and this applies it to the last internal caller
that explicitly resumes after seek.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
* fix(studio): compensate GSAP translate when starting manual drag
When an element has an active GSAP transform with translate (x/y),
starting a drag via createManualOffsetDragMember would strip the
GSAP translate from element.style.transform during the probe phase
without accounting for it in the initial offset. This caused the
persisted manual offset to be wrong by exactly the GSAP translate
amount, producing a visible position shift after page reload.
Read the GSAP translate contribution (m41/m42 from the transform
matrix) and fold it into initialOffset before the probe runs. The
offset now compensates for the stripped translate, so the element's
visual position is preserved across the drag start, commit, and
subsequent reloads.
* fix(studio): show visual position in Layout panel and fix save-reload race
PropertyPanel: X/Y fields now display the visual position (manual offset
+ GSAP translate) instead of the raw CSS var offset. Editing a value
reverses the compensation so the correct raw offset is persisted. This
matches what the user sees in the preview during GSAP playback.
persistDomEditOperations: move domEditSaveTimestampRef update before the
patch API call. The server writes the file and emits an SSE file-change
event during the fetch — if the event arrived before the response, the
file watcher would trigger a spurious reloadPreview(), resetting
playback to t=0. Setting the timestamp upfront suppresses that race.
* fix(studio): apply same timestamp race fix to element delete, relocate helper
Move readGsapTranslateFromTransform to manualEditsDom.ts alongside its
sibling stripGsapTranslateFromTransform and re-export through the
manualEdits barrel. PropertyPanel and manualOffsetDrag now import from
the shared location instead of the drag module owning a display concern.
Move domEditSaveTimestampRef update before the remove-element fetch in
handleDomEditElementDelete — same SSE race as persistDomEditOperations.
* feat(core): add probeElementInSource for source-existence checks
* feat(core): add probe-element endpoint for source-existence checks
* feat(studio): gate editing capabilities on source existence
* fix(studio): enrich save_failure telemetry with target details
* feat(studio): async selection resolution with source probe
Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").
Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
`probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
when `projectId` is supplied and the element has a stable id/selector.
`existsInSource: false` flows into `resolveDomEditCapabilities`, which
disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
`resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
helpers to eliminate repeated boilerplate across remove/patch/probe handlers.
Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
`resolveDomSelectionFromPreviewPoint`,
`buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
`refreshDomEditSelectionFromPreview`, and
`refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
`buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
`handlePreviewCanvasPointerMove` made async (React ignores handler return
values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
`handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
return type widened to `Promise<DomEditSelection | null>`; pointer-down
handler falls back to `hoverSelectionRef.current` (always populated by a
prior hover) instead of awaiting the async move callback inline.
Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
and `hoverSelection` pre-seeded so pointer-down test works with the new
hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
`Promise.resolve()`; seek/selection hydration test made async with
`await act(async () => { await Promise.resolve(); })` to flush microtasks.
* feat(cli): add global error handlers for crash telemetry
Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.
* feat(cli): track per-command success/failure and duration
* test(core): add integration test for JS-created element probe scenario
* fix: address PR review feedback
- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc
* fix(cli): restore stack_trace in cli_error telemetry
* fix(cli): use captured module refs in exit handlers instead of dead import()
createStaticSeekPlaybackAdapter.seek now accepts the same options as the
PlaybackAdapter contract and aligns the default-pause semantics with
wrapTimeline (hardened in 3e7b464b). Without keepPlaying the adapter
clears its `playing` flag and cancels the RAF ticker, so on non-GSAP
compositions a scrub during playback no longer leaves the iframe
silently advancing while the public seek wrapper marks isPlaying=false.
Follow-up to #863 review: jrusso called out the type drift and invited
a separate PR; this also closes the asymmetry with wrapTimeline.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
elementRect.left/top from getBoundingClientRect() already reflects GSAP
transforms in viewport coordinates. Subtracting rootRect.left/top
cancels the transform, pinning overlays to the un-animated layout
position. Use elementRect directly so overlays track elements during
scroll (y: -500) and entrance (scale: 0.95) animations.
When GSAP applies transforms (scale, translate) to the root composition
element during playback, rootRect.width/height from getBoundingClientRect()
changes to reflect the transformed size. The overlay scale calculation
(rootScaleX/Y = iframeRect / rootRect) then produces wrong values,
causing overlays to appear at incorrect positions during animated
playback — especially visible during scroll animations (y transform)
and entrance animations (scale transform).
Fix: use the composition's declared data-width/data-height attributes
for scale calculation. These are the canonical dimensions that don't
change with GSAP transforms. Falls back to rootRect dimensions when
the attributes aren't present (non-composition elements).
When outPoint exceeds composition duration, rawLoopEnd > dur makes the
time >= loopEnd branch unreachable after the playhead clamp — the player
ticks forever. Clamp rawLoopEnd to dur in both forward and backward RAF
loops, matching the seek() clamping. Add test for the boundary behavior.
Trim blank lines to satisfy 600-line filesize gate.
The studio player's RAF loop in useTimelinePlayer notified the playhead
position via liveTime.notify(time) before checking the duration limit.
When adapter.getTime() returned a value past the composition's
data-duration (due to timing drift or delayed duration calculation),
the playhead would visually overshoot — showing e.g. 0:19 on a 0:10
composition.
The web player component already had this clamping (playback-state.ts
line 42, direct-timeline-clock.ts line 56), but the studio player's
forward loop was missing it.
Fix: clamp time to dur before notifying, matching the pattern already
used in the web player: Math.min(rawTime, dur) when dur > 0.
Address review feedback:
- AND with error_name === "EncodingError" for tighter filtering
- Add sampled composition_asset_error_filtered tracking event (fires on
1st occurrence, then every 100th) so filtered errors aren't completely
invisible in telemetry
Wrap all contentWindow/contentDocument access and addEventListener/removeEventListener
calls in try/catch across usePlaybackKeyboard, useAppHotkeys, and CompositionsTab.
Prevents SecurityError from propagating to the React error boundary (white screen).
Affects 1,885 crashes / 648 unique users in the last 7 days.
import.meta.env is undefined in Next.js Turbopack/Webpack, causing
"Cannot read properties of undefined" when the studio telemetry client
loads. Wrap accesses in try-catch so they gracefully fall back.
Also hardcode the PostHog API key and host — they're public write-only
values with no reason to be overridable via env.
Split PlayerControls.tsx into focused sub-components (SeekBar,
WorkAreaOverlay, MuteButton, LoopButton, FullscreenButton,
ShortcutsPanel, SpeedMenu) and extracted seek bar drag/progress
tracking into useSeekBarDrag hook.
Split manualEditsDom.ts patch-builder functions into
manualEditsDomPatches.ts with data-driven helpers to reduce
duplication and complexity.
Extracted per-type reapply helpers from reapplyPositionEditsAfterSeek
and factored out identity-matrix check from
stripGsapTranslateFromTransform.
Raised file-size limit from 500 to 600 lines, removed
.filesize-allowlist.
Two fixes for the 3M+ unhandled_promise_rejection events/day spike:
1. Filter: suppress "Error fetching ... 404" rejections from composition
code — these are asset-not-found content errors, not Studio bugs.
2. Rate-limit: cap both error and rejection telemetry at 50 per session.
After the cap, emit a single *_cap_reached event so we know capping
occurred without generating unlimited events.
3. Root cause: webAudioTransport now checks response.ok before decode
and caches failed URLs in _failedSrcs so repeat ticks don't re-fetch
the same 404 on every playback frame.
Also add playground/ to fallow ignorePatterns — local experiment
directory was tripping the audit gate.
Flip the fallback from false to true so the blocks panel is on for
everyone out of the box. Users can still disable it via
VITE_STUDIO_ENABLE_BLOCKS_PANEL=false if needed.
Create a Tooltip component with styled popover (dark bg, border,
shadow) that appears on hover with a 400ms delay. Applied to:
- Left sidebar tabs: Code, Comps, Assets, Catalog
- Right panel tabs: Design, Layers, Motion, Renders
Replaces native title attributes with proper styled tooltips.
Add title attributes to interactive elements that were missing them:
- PlayerControls: Play/Pause, playback speed, shortcuts panel, clear
in/out-point buttons, and jump-to-frame Go button
- StudioRightPanel: Design, Layers, Motion, and Renders tab buttons
Call tl.pause() both before AND after tl.seek() in the adapter.
GSAP's seek() can reactivate a timeline depending on internal state;
the second pause() guarantees it stays frozen at the seeked position.
When seeking via the slider or timeline scrub, explicitly pause all
<video> and <audio> elements inside the preview iframe. The GSAP
timeline pauses but iframe media elements can continue playing
independently, causing audio to keep going after a seek.
Clicking "Ask agent" now opens a modal that shows the full generated
prompt so the user can read it before copying. The modal has a "Copy
prompt" button that turns green on success. This replaces the silent
clipboard copy that gave no visibility into what was copied.
- Ask agent button turns green with checkmark on copy
- Add button shows tooltip "Add to composition at current time"
- Ask agent shows tooltip "Copy a prompt to paste into your AI agent"
- Tab tooltips: Code, Comps, Assets, Catalog each explain their purpose
- Search placeholder updated to "Search by name, category, or tag…"
Add button rules: VFX, Social, Scenes get Add + Ask agent. Captions,
Transitions, Effects, Data get Ask agent only.
Search now matches category names and tags, so searching "captions"
shows all caption blocks.
Components (hyperframes:component) get both "Add" and "Ask agent"
buttons since they work as drop-in overlays. Blocks (scenes, data,
VFX, transitions) show only "Ask agent" since they need agent-guided
customization.
The "Ask agent" button now copies the current composition state along
with the category-specific prompt: playback time, active composition
path, dimensions, and all elements visible at the current time with
their track, timing, and source paths. Gives the agent full context
to place and customize the block correctly.
When previewing a component (compositions/components/*), render the
main composition player as a backdrop behind the transparent component
overlay. This lets users see captions, vignettes, and other overlays
in context instead of against a black void.
Remove the Add button and drag-and-drop from catalog cards — blocks
and components need agent-guided customization, not blind insertion.
Each card now shows a single "Ask agent" button that copies a rich,
category-specific prompt to clipboard with context about what the
block does and how to customize it (captions: transcribe + style,
transitions: place at cut point, data: replace values, etc.).
Each catalog card now shows two hover buttons:
- "Add" — inserts the block/component into the composition at the
current playhead position (existing behavior, now with + icon)
- "Agent prompt" — copies a contextual prompt to clipboard tailored
to the block's category (captions, transitions, VFX, etc.) so the
user can paste it into their AI agent for guided customization
Blocks and components now start at the current playhead position
instead of being appended after all existing content. If the new
element extends beyond the root composition's data-duration, the
root is automatically extended to fit.
insertTimelineAssetIntoSource now detects the parent indent level and
adds the new element with matching child indentation. Block attributes
are written one-per-line for readability.
Blocks were using their own native dimensions (e.g. 1920x1080) instead
of the host composition dimensions (e.g. 1280x720), causing them to
overflow the viewport and break the layout. The block's iframe scales
its content to fit the container, so using host dimensions is correct.
Replace fragile regex z-index parsing with getComputedStyle on the
preview iframe elements — the same source of truth the inspector uses.
Rename "Layer" to "Z-index" in the design panel for clarity.
The DomEditOverlay sits at z-10 with pointer-events:auto over the
preview, intercepting all drag events before they reach NLEPreview's
viewport. Move block drop handling from NLEPreview up to the wrapper
div in NLELayout that contains both the preview and the overlay, so
drag-and-drop from the Catalog panel onto the preview area works
regardless of inspector state.
VITE_STUDIO_* env vars set in the user's shell had no effect when
running `hyperframes preview` because the pre-built studio bundle had
them baked at Vite build time.
The embedded Hono server now collects VITE_STUDIO_* vars from
process.env and injects them as a `window.__HF_STUDIO_ENV__` script
tag into index.html. The client merges this runtime object on top of
the baked `import.meta.env`, so flags like
VITE_STUDIO_ENABLE_BLOCKS_PANEL=1 work as expected at runtime.
- Rename "Blocks" tab to "Catalog"
- Replace fullscreen hover popup with inline preview in main area
- Fix z-index: newly added blocks/components use max existing z-index + 1
instead of element count, ensuring they appear on top
Blocks dragged from the Blocks panel and dropped onto the composition
preview now land at the position where they were dropped, instead of
always being placed at (0, 0).
The preview viewport converts screen coordinates to composition space
using the stage element's bounding rect, accounting for zoom and pan.
A visual drop indicator (dashed border overlay) appears while dragging
over the preview.
When the Code tab is active and a user clicks an element in the preview,
the code editor now auto-scrolls to the corresponding HTML source. This
removes the need for Alt+click — the existing click-to-source mechanism
fires automatically when the Code tab is already open.
- Add getTab() to LeftSidebarHandle for reading the active tab
- Add getSidebarTab getter to useDomEditSession
- Add effect that calls openSourceForSelection on selection change
when Code tab is active
Follow-up to the playhead-preservation fix: clean up the now-unreachable
crossfade infrastructure that was only triggered by refreshKey changes.
- Remove retiringKey state, retiringTimerRef, handleNewPlayerLoad
- Remove the retiring Player render block and conditional onLoad/style
- Drop refreshKey from getPreviewPlayerKey signature and NLEPreviewProps
- Stop passing refreshKey from NLELayout to NLEPreview
- Update NLELayout comment to reflect current iframe.src reload model
- Simplify getPreviewPlayerKey test