* fix(studio): soft-reload GSAP property edits without iframe reload
GSAP property value edits (opacity, x, scale, etc.) now update the live
timeline inside the preview iframe without triggering a full iframe
reload. This preserves the WebGL context and shader transition cache,
eliminating the loading overlay that appeared on every property edit.
Implementation:
- New gsapSoftReload.ts: kills the old GSAP timeline, re-executes the
updated script, calls __hfForceTimelineRebind(), and re-seeks to the
current time. Falls back to full reload on failure.
- useGsapScriptCommits: passes softReload: true for property value edits
via the existing (previously unused) softReload flag on commitMutation.
- hyper-shader.ts: exposes __hfSuppressSceneMutations on the window so
the soft-reload can suppress the MutationObserver during re-execution.
- hyper-shader.ts: getDocumentScriptSignature now excludes pure GSAP
animation scripts from the cache key hash, so full reloads (undo,
external changes) don't invalidate transition caches when only
animation values changed.
* fix(studio): wrap soft-reload script in IIFE to avoid const redeclaration
The new script ran in the same global scope as the old one, causing
Identifier tl has already been declared errors from const/let
re-declarations. Wrapping in an IIFE creates a new lexical scope.
Also remove the old script element before inserting the new one.
* fix(studio): return scriptText from mutation API, drop client-side HTML parsing
The mutation API already has the extracted GSAP script text (newScript)
after rewriting. Return it as scriptText in the response so
applySoftReload receives the script directly instead of parsing HTML
client-side. This avoids DOMParser compatibility issues across test
environments and is more reliable than regex-based extraction.
* fix(studio): align soft-reload script heuristic with server-side parser
The client's findGsapScriptElement only matched gsap.timeline and
__timelines. The server's extractGsapScriptBlock also matches .to( and
.set(. Aligned the client heuristic to prevent silent fallback to full
reload for compositions that use tl.to() without gsap.timeline in the
same script.
* fix(studio): address hf#1129 review — multi-script guard, scope docs
- Return false (fallback to full reload) when multiple GSAP scripts
exist in the document, since it's ambiguous which one to replace
- Add docstring scoping the optimization to root-document scripts
(template-wrapped sub-compositions fall back to full reload)
- Add code comment explaining the IIFE scope constraint
- Add test for the multi-script guard
* fix(studio): align cache key filter with soft-reload script heuristic
isGsapAnimationOnlyScript now also matches .to( and .set( patterns,
matching findGsapScriptElement. Scripts using only tl.to() without
gsap.timeline were excluded from soft-reload but still busted the
shader cache on full-reload paths (undo, external changes).
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle
- opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits
- `visibility` renders as a boolean toggle; only available to add in `set` tweens
- ease curve section: use aspect-ratio container so control circles are not oval
- MetricField scroll only fires when the input is focused (was triggering on scroll-over)
- preview overlay clipped to its container (overflow-hidden) — no bleed into panels
- `fromTo` method label updated to "From → To" (was "Animate", same as `to`)
- repeated click at same position cycles through stacked/overlapping elements (#1124, #1125)
resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks
advance through all selectable layers at that coordinate
- fallow-ignore-next-line complexity on pre-existing complex functions surfaced by
branching from fix/gsap-fromto-panel rather than main
Closes#1124, #1125
* fix(studio): address Vai+Rames follow-up notes on hf#1122
- extract buildTweenSummary to gsapAnimationHelpers.ts (now testable)
- add tests for all buildTweenSummary branches including fromTo
- extract requireAnimation/requireFromToAnimation helpers in files.ts,
eliminating the parse→find→guard pattern repeated across three switch
cases and removing the fallow-ignore-next-line complexity bypass
- add 400 guard: add mutation with fromProperties on non-fromTo method
now returns 400 instead of silently dropping fromProperties
- add test for the 400 guard
* fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1
* fix(studio): show all .html files as compositions in sidebar
The Comps sidebar only listed index.html and files under a compositions/
subdirectory. Any other .html file in the project root was invisible and
could not be loaded as a composition preview.
Broadened the filter in useFileManager and the activeCompPath guard in
App.tsx to treat every .html file as a selectable composition.
Also excluded App.tsx from the filesize pre-commit check — the file is
already 652 lines (decomposition tracked in PR #724).
* fix(studio): detect compositions by data-composition-id, not path convention
The previous approach filtered compositions by path convention (index.html
or compositions/ subdirectory). Any .html file outside that convention was
invisible in the Comps sidebar.
The server now scans each .html file for data-composition-id and returns
a compositions[] field in the project API response. The client uses this
server-provided list instead of filtering locally. This means any .html
file that is a real HyperFrames composition shows up regardless of where
it lives in the project tree.
* fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview
Updated the property panel button label from "Ask agent" to "Copy prompt
to AI agent". Updated the modal title to match. Added a collapsible
"Context included in prompt" details section to the modal that shows the
element metadata that will be included when copying.
* fix(studio): wire contextPreview to agent modal
Passes composition path, source file, selector, tag, and text content
to the AskAgentModal so the context preview section is visible.
* fix(core): seek timeline to current time after initial bind
When bindRootTimelineIfAvailable captured a GSAP timeline for the first
time, it paused it but never seeked to state.currentTime. This left
fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0)
even after the user scrubbed past the tween's end. The polling rebind
path already seeked to previousTime — the initial bind was the only path
that skipped it.
* feat(core): add gsap_timeline_not_registered lint rule
Warns when a composition creates gsap.timeline() but never registers it
in window.__timelines. Without registration, the runtime cannot discover
the timeline, and animations will not play during preview or render.
Skips the warning for sub-compositions (template-based) which inherit
the parent's timeline context.
* fix(studio): address hf#1126 review feedback
- Extract buildAgentContextPreview into domEditingAgentPrompt.ts and
import it in App.tsx, removing the inline computation that pushed
App.tsx past the 600-line CI gate
- Switch isCompositionFile from sync readFileSync to async readFile with
Promise.all, and use a regex test instead of string includes
- Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts
into gsapAnimationConstants.ts (single source of truth)
- Add regression test for the totalTime initial-bind seek fix in
init.test.ts — verifies the captured timeline receives a totalTime
call on initial bind
* refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption
Extracted inspector state, studio context construction, and drag overlay
into useStudioContextValue.ts. Deduplicated block handler args via a
shared blockCtx memo. App.tsx drops from 657 to 588 lines.
Removed the App.tsx exemption from lefthook.yml — the file now passes
the 600-line gate without special-casing. Added domEditing.ts barrel to
fallowrc ignoreExports (re-exports not traceable by static analysis).
* fix(studio): surface fromTo from-state in GSAP design panel
Closes#1121.
The core already parsed, serialized, and mutated fromProperties end to end
(gsapParser.ts, applyUpdatesToCall, buildTweenStatementCode). The panel
never wired it in — AnimationCard only read animation.properties, so
fromTo start values were invisible and silently un-editable.
Changes:
- files.ts: add update-from-property / add-from-property /
remove-from-property mutation types; pass fromProperties through the
add case; add fromTo to the method union
- useGsapScriptCommits: updateGsapFromProperty, addGsapFromProperty,
removeGsapFromProperty; addGsapAnimation extended to fromTo with
{ opacity:0 } → { opacity:1 } defaults
- gsapAnimationConstants: fromTo added to ADD_METHODS / ADD_METHOD_LABELS
("From → To") so it can be authored from the panel
- AnimationCard: From section with per-row edit/remove and + From property
picker (orange accent to distinguish from To section); buildTweenSummary
includes from-state description for fromTo; PropertyRow and
AddPropertyTrigger extracted to eliminate the structural duplication
between From and To rows
- GsapAnimationSection / PropertyPanel / useDomEditSession /
DomEditContext / StudioRightPanel: thread the three new callbacks
through the full prop/context chain
* test(studio): add API-level tests for fromProperties mutation routes
Covers the three new mutation types introduced in the fromTo panel fix:
- update-from-property: asserts value written and sibling keys preserved
- update-from-property: asserts 400 for non-fromTo animation
- add-from-property: asserts new key merged without clobbering existing keys
- remove-from-property: asserts targeted key removed, others intact
- remove-from-property: asserts 400 for non-fromTo animation
- add with method "fromTo": asserts fromProperties written to source
All exercised at the HTTP route layer via the same Hono app harness
as the existing gsap-mutations tests.
Two perf fixes caught in #1118 review:
1. Cache guard: probeAndCacheVolumeKeyframes now short-circuits when
the element is already in volumeKeyframeCache. Without the guard
every bindMediaMetadataListeners call (every 30 RAF ticks) re-probed
all bound elements — N elements × full-composition timeline seeks at
60 Hz regardless of whether keyframes were already known.
bindRootTimelineIfAvailable still clears the cache on a new timeline
capture so keyframes stay fresh when the composition is rebound.
2. PCM cursor: audioVolumeEnvelope.ts had the incremental segment
cursor (O(N+M) overall) before #1118 extracted the interpolation into
interpolateVolumeGain. The shared function restarts from segment=0 on
each call — fine for the preview path (one call per RAF tick) but
O(N×M) for the PCM path (one call per sample: 48 kHz × duration).
Napkin math: a 10-min render went from ~30M to ~460M ops. Restored
the inline incremental scan in the engine bake loop; engine now only
imports normaliseEnvelope from core.
Preview audio with GSAP volume fades (e.g. data-volume="0" with a
gsap.to("#bgm", {volume:0.25, ...})) played ~1s then silenced. Root
cause: syncRuntimeMedia used fallbackAuthorVolume (data-volume) on the
first tick after a clip became active, clobbering the GSAP-seeked value.
The single-clock transport seeks GSAP before syncRuntimeMedia runs, so
el.volume already holds the animated value — we just need to trust it.
Fix — three layers, matching the renderer's approach (PR #1117):
1. First-tick tracking: on the first tick a clip is active
(previousRuntimeVolume===undefined), use currentElementVolume (GSAP's
seeked value) instead of fallbackAuthorVolume. In production the
transport always seeks GSAP before syncRuntimeMedia, so el.volume is
already at the correct animated position.
2. Probed keyframes: new probeElementVolumeKeyframes() runs the same
offline probe the renderer uses (discoverAudioVolumeAutomationFromTimeline)
directly in the browser. init.ts calls probeAndCacheElementVolume() when
an element is bound and a timeline is available. When keyframes are present,
syncRuntimeMedia drives volume from the interpolated envelope — no
GSAP-change tracking needed, no first-tick edge case, same data source
as the renderer.
3. Shared utilities: normaliseEnvelope(), interpolateVolumeGain(), and
probeAndCacheElementVolume() extracted to mediaVolumeEnvelope.ts and
exported from @hyperframes/core/media-volume-envelope. The engine's
audioVolumeEnvelope.ts imports from there — no duplicate logic between
the renderer and the new preview path.
Fallow audit exits non-zero on inherited complexity/duplication in init.ts
functions that shifted line numbers (applyClipLayout, transportTick, etc.),
unchanged by this PR — same known false-positive pattern noted in #1117.
Lint, format, typecheck, and unit tests all pass.
53 core/media tests pass (3 updated to pre-set el.volume to match the
runtime's bindMediaMetadataListeners — corrects a missing setup step).
audioVolumeEnvelope tests (6) still pass.
Follow-up to #1115. Makes the Design-panel editor recognise every target
shape real compositions use. The panel stays behind STUDIO_GSAP_PANEL_ENABLED
(default off) — no flag change here.
- Array targets: tl.to([a, b], {...}) resolves to a CSS group selector
(".a, .b"). The source array is never rewritten — the joined string is for
display/matching only; edits still touch just the vars object.
- Chained calls: tl.to(a, ...).to(b, ...) — the matcher now walks the member
chain to its timeline root, so every link is captured (previously only the
first). Deletion is chain-aware: it splices out the single targeted link and
re-points the chain instead of dropping the whole statement.
- gsap.utils.toArray("sel") resolves like querySelectorAll, inline or via a
variable binding.
- Lexical scoping: element-variable resolution is now per-scope (walks the
enclosing function/program chain) instead of a flat map. Fixes silent
wrong-resolution when two IIFEs reuse a variable name, and unlocks
multi-scene files. (Addresses review: flat-binding-scope.)
- forEach/map callback params (items.forEach(el => tl.to(el, …))) and items[i]
indexing resolve to the collection's selector, so loop-generated tweens are
editable.
- Panel matching: an element matches a tween when its id/selector is any member
of a comma-group target, so either element of an array/toArray tween surfaces
the shared animation.
- Review items: mutation parse failures now console.warn instead of swallowing
silently; buildTweenStatementCode no longer emits duration on `set`; the
id-only serialize-side filter is renamed getAnimationsForElementId to
disambiguate from the panel's id-or-selector matcher; added fromTo round-trip
and variable-target overlap-lint tests.
Genuinely runtime-only targets (template-literal selectors, unbounded loops)
still skip gracefully — they can't be resolved or matched statically.
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.
The runtime had a maxTimelineDurationSeconds field defaulting to 1800
(30 minutes) that clamped the TransportClock duration. Any seek beyond
this cap was silently clamped, so GSAP tweens starting past ~1700s
never received their totalTime() call and stayed at their pre-tween
state (e.g. opacity:0).
The data-duration attribute is the authored source of truth. The loop-
inflation guard (timelineLooksLoopInflated) already handles the infinite
repeat:-1 case this cap was meant to protect against.
Closes#1107
* fix(core): guard timeline method calls for non-conformant objects
User compositions can register timeline-like objects on window.__timeline
where .duration is a number property (not a function) and .pause/.play
may be missing entirely. The runtime player called these unconditionally,
causing ~166 "duration is not a function" and ~38 "pause is not a function"
errors per day.
Add safeNum() and safeVoid() helpers that check typeof before calling,
falling back to reading numbers as properties and silently skipping
missing void methods. Applied consistently across all timeline method
call sites in player.ts.
* fix(core): add observability for non-conformant timeline properties
* 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()
Fix the 4th unguarded resolveStartForElement call site in
resolveMediaWindowDurationSeconds that inflated the timeline duration
floor for pip compositions. Extract resolveMediaStartSeconds helper
to consolidate the data-hf-auto-start guard across all call sites.
Narrow the raw data-start read to media elements without
data-hf-auto-start (explicitly authored global coordinates). Elements
with auto-injected data-start="0" remain composition-local via the
resolver. Apply consistently across all three consumers:
- visibility loop (init.ts)
- refreshRuntimeMediaCache start/duration (init.ts)
- resolveMediaWindowEndSeconds (timeline.ts)
Add regression test for auto-injected data-start="0" inside a
late-starting host to prove it doesn't regress.
For video and audio elements, data-start is authored in global (composition-root)
time — the same contract used by the render pipeline's discoverMediaFromBrowser,
which reads the raw attribute directly. Previously, the visibility loop called
resolveStartForElement which adds the nearest ancestor composition's global start
on top, causing a double-offset that kept pip-wired media permanently hidden when
the host composition did not start at t=0.
Example: a pip video with data-start="45.40" inside a host composition that also
starts at data-start="45.40" resolved to 90.80, so the video was always hidden
during its actual [45.40, 52.46] window.
Non-media elements (divs, sections, etc.) continue to use the accumulating
resolver because their data-start values are local to their composition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Function constructor (3bb0d1ef) was a security hardening to prevent
</script> injection, but it broke sub-composition DOM proxy scoping.
This restores the inline IIFE (preserving closure scope) while adding
</script> → <\/script> escaping to maintain the injection prevention.
Updates tests to match the new IIFE output shape.
Closes#1074
The Function constructor (3bb0d1ef) breaks sub-composition scripts that
call document.getElementById() — the constructor creates functions with
global scope, losing access to the composition-scoped DOM proxy. Native
method calls on proxy-returned elements throw "Illegal invocation".
Reverts to the inline IIFE that preserves the closure over __hfScoped*
variables. The original motivation (handling </script> in source) is
already handled by the compiler's script bundling path.
Closes#1074
The capturedTimeline guard broke CSS/WAAPI/Lottie compositions that
have no GSAP timeline — __renderReady was never set, causing the
parity harness to timeout after 30s.
renderSeek works with or without a GSAP timeline (adapter-only
seeking), so the correct invariant is "timeline binding was
attempted" not "a timeline was found." Set __renderReady
unconditionally in all three paths, after bindRootTimelineIfAvailable
has run.