The DOM-selection to timeline sync routes through the canonical resolveTimelineIdForSelection
(source-file, ancestor, active-comp fallback) instead of a narrow domId/id match that
mismatched sub-composition clips.
The preview-sync equality check compares selection as sets both ways and includes the
anchor, so duplicate resolutions no longer mask an unsynced member.
Group move/resize rejects the gesture when any selected member forbids the op (e.g. a
locked clip), so a group never edits a clip that individually cannot move, and a
persist failure now propagates so the optimistic preview rolls back.
Snapping excludes every moving member, not just the grabbed clip. The marquee hit-test
uses the real pixels-per-second (was floored at 1, wrong below 1x zoom), and a
sub-threshold marquee click scrubs the playhead like a plain lane click.
Extract resolveTimelineIdForSelection so DOM-to-timeline id mapping lives in one place
with a single sourceFile / activeCompPath / index.html fallback, fixing a sub-composition
selection that previously diverged between callers.
Extract shared start-trim delta helpers used by both single-clip and group resize, and
remove the never-called refreshDomEditGroupSelectionsFromPreview.
handleDomZIndexReorderCommit no longer swallows per-entry save failures: it settles every
patch, and on any rejection rolls back the eager DOM z-index/position and the optimistic
store zIndex before rejecting, so a failed save cannot leave the UI showing a stacking order
that never persisted or let an ordered-after timing write proceed.
Also removes the dead targetTrack parameter threaded through the timeline edit helpers;
vertical placement is owned by the z-index intent.
Ninth PR of the template-variables stack: the promote-a-property gesture.
Select an element on the canvas/timeline, open the Variables tab, and the
panel offers per-property bind actions.
- "Bind selected" card in the Variables panel, built from the selection:
image/media source (img/video/audio), text, text color, background, and
font. Each action declares a variable whose default is the element's
CURRENT value (promoting never changes the render — computed rgb colors
convert to hex, the first computed font family becomes the font default)
and writes the declarative binding the runtime resolves: data-var-src /
data-var-text attributes or `<prop>: var(--id)` styles. Declare + bind
run as one batched schema edit (one undo step); binding to an
already-declared id skips the declare and just binds.
- guarded to selections from the composition the session models — a
selection in another source file never writes bindings into this one.
- core: extract readVariablesForElement into runtime/variableScope.ts,
shared by color grading and the declarative bindings (was duplicated).
- fix(studio-server): buildSubCompositionHtml's extractElementAttrs
rebuilt html/body attributes without HTML-escaping values, shredding
quote-bearing attributes — data-composition-variables (a JSON array)
came out as mangled bogus attributes, so getVariables() silently
returned {} on every /preview/comp/* page (no declared defaults, no
runtime bindings). Pre-existing bug surfaced by live-testing this
feature; regression test added.
Verified end-to-end in a live session: select headline → Bind text color
→ declaration + var(--headline-color) written to disk → override in the
panel → runtime applies the custom prop and the element renders the
override.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A drag that both moved a clip in time and restacked it fired two writers on
the same file via separate queues (a targeted z-index patch and a full-file
timing overwrite), so the overwrite could clobber the just-persisted z-index
and the restack silently vanished after reload. The move now awaits the
z-index commit before persisting timing, giving the file one ordered writer
per gesture. Adds a regression test that gates the commit and asserts the
timing write waits.
CI file-size check and fallow audit caught two issues the --no-verify
commits skipped: Timeline.tsx sat at 602 (max 600) and
postRootDurationToPreview was an unused export (only called in-file).
Trim a comment to hit 600; drop the export.
Extending a clip past the video end used to force the server-fallback
path that fully remounts the preview iframe (the SDK fast path can't
express the root composition's data-duration, and the runtime bakes+drops
data-duration at load so it can't be patched live). On a large comp that
remount is a visible hitch.
Add a runtime control-bridge action set-root-duration -> clock.setDuration,
so the studio can grow the transport length in place. On an extend the
studio now posts it (and patches the clip's own timing live) instead of
reloading; it only reloads when a GSAP source rewrite actually happened
(the gsap-mutation endpoints now report a mutated flag). Non-animated
extends — the common case — commit as fast as a normal edit.
Verified: bridge dispatch + studio no-reload/post-message paths unit-
tested; core/studio/studio-server typecheck + suites green; the built
runtime artifact carries the handler; E2E confirms the extend no longer
remounts the preview and still persists.
Dragging a timeline clip (or its right resize edge) past the end of the
video now extends the composition duration on drop, instead of clamping
the clip at the current end. Extend-only and undoable.
- Relax the move/resize horizontal clamps that pinned a clip's end at the
current duration; effectiveDuration now folds in the active drag/resize
preview so the ruler and track width grow live as you drag past the end.
- On drop, extend the root composition data-duration (and the store) when
the clip's new end exceeds it, via a shared extendRootDurationInSource
helper extracted from the block installer (now the single owner of that
logic). An extending edit routes through the server persist path since
the SDK setTiming op can't express the root composition's own duration.
applyTimelineStackingReorder resolved each z-index change by looking the clip up
in the top-level timelineElements list, but sub-composition children live only
in the expanded list, so the lookup missed them and the reorder silently bailed.
Carry the element's locator (domId/selector/sourceFile) on each z-index change
so the commit resolves the live element directly from the preview DOM. Verified
E2E: dragging a clip inside a sub-composition restacks it and patches the
sub-comp source, while the parent composition is untouched.
Adds timelineEditingHelpers.test.ts (locator-based commit + audio no-op).
applyTimelineStackingReorder resolves the live clip from the preview IFRAME,
then gated it with `element instanceof HTMLElement` against the MAIN window's
constructor. Cross-realm instanceof is always false, so every timeline z-index
commit silently bailed ("element not live in iframe") — the drag resolved the
right z but never wrote it. Use the element's own-realm HTMLElement constructor
(matching timelineDOM.ts). Verified end-to-end: dragging a card down now lowers
its z-index and reorders the row, leaving sibling z-indexes untouched.
Unit tests missed this because the happy-dom test iframe shares a realm; caught
via a real-browser Puppeteer E2E drag.
Re-architect the timeline row model from data-track-index rows to stacking
layers. Rows represent stacking layers per context: explicit-z clips merge onto
one track when they share a z and don't overlap in time; auto-z clips stay one
row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a
stable layer id, not data-track-index.
Vertical drag always writes z-index, never track: drop onto a layer joins it
(same z), between layers interpolates a new z, past the ends creates a new
front/back layer. data-track-index is never rewritten; #958 holds. Adds
hasExplicitZIndex capture (computed z != auto).
L1: hasExplicitZIndex on the element model
L2: buildStackingTimelineLayers (layer-based rows)
L3: layer-aware vertical drag (join / interpolate / new-extreme)
The element stacking key (element.key ?? id) was recomputed in four places
(reorder-intent generation, row ordering, the commit-time sibling lookup via a
threaded keyOf param, and resolveTimelineMove). Any drift would silently break
the sibling lookup and no-op the reorder. Route all of them through the existing
getTimelineElementIdentity owner, share one toStackingOrderItem mapper between
row ordering and reorder intent, and drop the keyOf parameter.
Also enforce the audio side-effect invariant in the single mutation owner
(applyTimelineStackingReorder): dragging an audio clip has no visual layer to
restack, so it never writes z-index. Covered by a new hook test.
Timeline rows now order by scoped stacking (z-index per stacking context)
instead of data-track-index, and dragging a clip up/down commits a targeted
z-index change through the same shared path the layers panel uses. Both panels
stay consistent and moving a clip actually changes front/back. data-track-index
is demoted to time-overlap layout only; no bulk z-index injection (#958 intact).
Also restores beat-snapping on keyframe retiming (re-wires snapKeyframePctToBeat,
orphaned when keyframe dragging was removed) which surfaced while unifying the
model. Extracts pure track-ordering logic to timelineTrackOrder.ts, the stacking
reorder commit + deleteSelectedKeyframes to timelineEditingHelpers.ts, to keep
StudioApp / the timeline hook / Timeline under the studio 600-LOC cap.
U3: scoped stacking row order in the timeline
U4: vertical drag commits z-index via the shared reorder commit
U8: restore keyframe beat-snap on retime
Crop is now part of the element selection instead of a separate mode. Selecting
a croppable element shows edge handles just outside each side and, once cropped,
the full content with the cropped-away area dimmed plus a center reposition
handle to pan the crop window. Dragging the body moves the element, edge handles
crop, the center handle pans; corners stay free for the resize handle. Removes
the crop-mode toggle (toolbar + property-panel buttons), the cropMode/
cropAvailable player-store state, and the double-click-to-crop gesture. The
clip-path inset model is unchanged.
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling
WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload
Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
## What
Brief description of the change.
## Why
Why is this change needed?
## How
How was this implemented? Any notable design decisions?
## Test plan
How was this tested?
- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
* test(studio): add design-panel QA fixture and triage matrix
Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.
* fix(studio): make canvas selection hit intended elements
- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling
* fix(studio): close remaining selection-layer review findings
- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
playback paused if it was already playing
* fix(studio-server): child-scoped patch operations with batch abort
- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)
* fix(studio): per-child patch op builders and persist-seam harness
- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml
* fix(studio): fail closed on unresolved text-field child index
buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
* fix(studio): surface persist failures with toast and guarded revert
- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted
* fix(studio): revert data-attribute and html-attribute commits on persist failure
commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.
* fix(studio): close coupled persist-hook review findings
Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.
* test(studio): cover persist-failure hook behavior
Regression tests for the persist failure paths: unresolvable targets,
no-op warns, rejected requests, revert races, structural-edit refusal,
and read/write failure toasts.
* test(studio): cover attribute-commit revert on persist failure
Regression tests for the data-attribute and html-attribute revert paths
added in #1910: unresolvable target, rejected request, success (no revert),
and a stale-failure-vs-newer-success race guarded by the per-attribute
version counter.
* test(studio): cover the patch-rejection and text-commit revert fixes
Adds the two persist-hook cases R2 flagged as untested: the
!patchResponse.ok HTTP-error path (previously only exercised via a
network-throw, which bypassed this branch) and handleDomTextCommit's
server-failure path. Also strengthens the prepareContent-write-failure
test to assert the already-persisted base patch is recorded, not
reverted, matching the coupled persist-hook fix.
* test(studio): add design-panel QA fixture and triage matrix
Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.
* fix(studio): make canvas selection hit intended elements
- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling
* fix(studio): close remaining selection-layer review findings
- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
playback paused if it was already playing
* fix(studio-server): child-scoped patch operations with batch abort
- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)
* fix(studio): per-child patch op builders and persist-seam harness
- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml
* fix(studio): fail closed on unresolved text-field child index
buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
* fix(studio): surface persist failures with toast and guarded revert
- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted
* fix(studio): revert data-attribute and html-attribute commits on persist failure
commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.
* fix(studio): close coupled persist-hook review findings
Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.
* test(studio): add design-panel QA fixture and triage matrix
Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.
* fix(studio): make canvas selection hit intended elements
- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling
* fix(studio): close remaining selection-layer review findings
- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
playback paused if it was already playing
* fix(studio-server): child-scoped patch operations with batch abort
- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)
* fix(studio): per-child patch op builders and persist-seam harness
- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml
* fix(studio): fail closed on unresolved text-field child index
buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
* test(studio): add design-panel QA fixture and triage matrix
Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.
* fix(studio): make canvas selection hit intended elements
- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling
* fix(studio): close remaining selection-layer review findings
- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
playback paused if it was already playing
commitWholePropertyOffset reduced the tween's keyframe list to find the
"nearest" stop without an initial value. When a to()/from() tween had been
collapsed to a zero-duration immediateRender hold (what removeAllKeyframes
leaves behind), synthesizeFlatTweenKeyframes correctly treats it as a
static hold and returns null, leaving an empty keyframe list — so the
reduce threw "Reduce of empty array with no initial value". Reachable by
resizing such an element with auto-keyframe recording off.
With no keyframe shape to preserve, persist the flat value directly via an
update-properties mutation instead.
Dragging a motion-path keyframe node committed correctly, but the soft
reload that refreshes the preview re-seeked the freshly rebuilt GSAP
timeline using the iframe's raw __player.getTime(), which can lag the
studio's authoritative currentTime right after a keyframe drag parks the
playhead. The stale seek left the element (and its selection/motion-path
overlay) rendered at an unrelated position after the edit.
applySoftReload now takes the caller's currentTime instead of trusting the
iframe's own clock, and the re-seek runs before __hfForceTimelineRebind so
its internal force-render picks up the correct time.
Adds a control-bar toggle (next to the Add Keyframe diamond) that, when off,
makes a manual drag/resize/rotate/panel edit on an already-keyframed element
shift the whole tween by the edit's delta instead of inserting or updating a
keyframe at the playhead. The animation's shape is preserved, just moved.
Wired into every path that can auto-record a keyframe:
- canvas drag-to-move (tryGsapDragIntercept, reuses the existing Alt-drag
"shift whole path" behavior)
- canvas resize/rotate (tryGsapResizeIntercept, tryGsapRotationIntercept)
- design-panel property edits (useAnimatedPropertyCommit)
- motion-path keyframe-node dragging (MotionPathOverlay), the path a plain
click-drag on a keyframed element's canvas shape actually takes, since the
element renders exactly at its current keyframe's position
The shared shift helper reuses synthesizeFlatTweenKeyframes for materializing
a flat tween instead of hand-rolling it, and lives in its own file
(gsapWholePropertyOffsetCommit.ts) to keep gsapDragCommit.ts under the
600-line cap, mirroring the existing gsapDragPositionCommit.ts split.
Fixes#1808
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes#1838
The sdk_resolver_shadow tripwire flagged element_not_found for nodes a
composition <script> creates at runtime (caption word/group spans, etc.).
These have no static data-hf-id, so the SDK session (a static parse) cannot
model them by design; the divergence is noise, not a resolver bug.
- Runtime-node filter: suppress element_not_found when the resolved hf-id is
absent from the on-disk source. An id PRESENT in source but missing from the
session stays flagged (the genuine v0.6.110-class resolver divergence).
- Add sessionElementCount to all element_not_found / animation_not_found emits
(0 = empty/broken session, >0 = element-specific).
- Add sourceHfIdCount to emitted element_not_found: =1 = static node the parse
dropped (foreign-content exclusion / sub-comp gap), >1 = duplicate-id
resolver ambiguity.
Scoped to the DOM-edit path. Telemetry-only; no disk writes, no edit change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes#1782)
Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture
to retime an existing keyframe while preserving its value and easing (delete+re-add
bakes computed values and drops the explicit ease). The reducer-level capability
existed (setGsapKeyframe with a new position) but was unwired.
Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and
recast, in parity) that re-keys a keyframe to a new percentage, carrying its
properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to
Playhead' entry on the keyframe context menu through both hosts (canvas
MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the
playhead's tween-relative percentage.
Tests: parser correctness + recast/acorn parity (value+ease preserved, collision
overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt
clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow
health gate (parity-twin + wiring-layer duplication; extracted helper).
* feat(studio): restore drag-to-retime on timeline keyframes
Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic
move-keyframe foundation so it's reliable. #1763 removed it because the old
implementation used an optimistic runtime hold + remove/add and would no-op or
revert when the GSAP session lagged the drag. This version:
- previews visual-only (the dragged diamond follows the pointer; nothing touches
the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves
value + ease) — no optimistic hold, no lag race.
- pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion,
clamp [0,100], no-op when drop==origin (unit-tested).
- wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline →
TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the
dragged keyframe's animation via resolveKeyframeTarget.
tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate
(same parity/wiring duplication as the rest of the branch).
* feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize
Drag-to-retime now handles every case:
- interior keyframe clamps strictly between its left/right neighbors (can't
cross/reorder),
- last keyframe dragged past the tween end extends the animation's duration,
- first keyframe dragged before the start shifts position earlier + grows
duration,
- single-keyframe tweens resize either direction.
Boundary extends remap the other keyframes to preserve their absolute times
(value + per-keyframe ease copied through) via the atomic replace-with-keyframes
mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits
on drop — no optimistic runtime hold.
Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) +
keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea
resolves the tween window + clip timing and dispatches move vs resize.
tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass
(22 new helper tests). Flat keyframe-less tweens still move within window;
boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate.
* fix(studio): address #1784 review — keyframe retime correctness + resize fidelity
Round 2 from Via + Rames:
- (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache
lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on
any tween shorter than its clip (Move to Playhead + the inherited Delete silently
no-op'd). Menu now passes clip-%.
- boundary resize preserved author intent: new record-preserving parser op
resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf
ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes
which dropped them.
- resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent
(retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget.
- moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal
(<0.05), collision only vs a different keyframe.
- soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate
resolves stale position-encoded ids).
Tests: parser parity (small move + resize round-trip fidelity), studio-server
resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths.
735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed
fallow gate (branch-wide parity/wiring duplication).
* feat(player,studio): favicon-blade play icon with pause<->play morph
Replace the play triangle with the right-hand blade from the HyperFrames favicon
and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one
path's d between the blade and two pause bars (gsap added as a studio dep). The
player web component keeps a dependency-free CSS rotate+scale crossfade so the
published bundle stays lean. Both honor prefers-reduced-motion.
* fix(cli): discover local-studio (Vite) preview over IPv6 loopback
The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but
the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so
`preview --selection/--context` reported preview-not-running against a local-studio
preview (e.g. inside the monorepo / bun run dev). Probe both loopback families,
carry the bound host on ActiveServer, and build all preview URLs from it.
Adds an IPv6-only discovery regression test.
* fix(studio): wire the Add-keyframe (K) shortcut
The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was
never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so
K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar
(enabled when a keyframeable element is selected) wired to the toolbar's add action;
register it in the capture phase and stopImmediatePropagation only for keys it
actually handles, so K adds a keyframe in that context while JKL playback keeps
working everywhere else.
* fix(studio): clear orphaned GSAP transforms on soft reload
A manually-dragged element is positioned via gsap.set, which writes an inline
transform. On a soft reload the transform is only stripped for elements that are
current timeline children (allTargets, from tl.getChildren().targets()). An
element positioned by a standalone gsap.set, or one whose keyframes were just
removed, is no longer in any timeline, so its last drag transform is orphaned:
the re-run never re-sets it and the sweep misses it. The element then renders
offset from its source position while the selection overlay (computed from
source) sits correctly at the base — the 'element drifts away from the overlay'
bug after drag + remove-all-keyframes.
Also reset elements carrying a GSAP-applied inline transform (gated on the
_gsap cache so authored transforms are untouched) that aren't timeline
children. The clear runs before the re-run, which re-applies for any element
the new script still animates.
* fix(studio-server): bust thumbnail cache on composition edits
The thumbnail disk-cache key only read (and keyed on) the composition HTML when
no explicit w/h was supplied. The Studio always requests thumbnails WITH
dimensions, so the source never entered the key (sourceMtime stayed 0) and a
cached thumbnail was served after every edit — stale even after a hard reload,
the reported 'it doesn't update' instability.
Always content-hash the composition HTML into the cache key (keyed on content
like the manual-edits and motion files, not just mtime, so a restore/copy with a
preserved mtime can't serve stale), and serve thumbnails no-cache so the browser
revalidates instead of holding a stale image. Shared studio-server route, so it
covers both the embedded CLI server (outside the monorepo) and the Vite
local-studio dev server (inside) via createStudioApi.
* fix(parsers): remove-all-keyframes holds position static instead of re-animating
removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that
KEPT the original duration, so removing all keyframes re-animated the element
from its base toward the last keyframe value. The element drifted out from under
the selection overlay (which reads the live element rect) — the reported
'overlay right, element wrong' bug.
Collapse to a static hold instead: duration 0 + immediateRender true, dropping
the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and
the recast writer (removeAllKeyframesFromScript), kept in parity. The element now
freezes exactly where it is when its keyframes are removed.
* fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation
The keyframe-diamond context menu's 'Delete All Keyframes' was wired to
handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation
— so the element lost its position and jumped (reverted to base / left an
orphaned transform) out from under the selection overlay. Wire it to
handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static
held value (duration 0 + immediateRender), so removing the keyframes freezes the
element exactly where it is.
* fix(studio): timeline 'Delete All Keyframes' holds position too
The keyframe-diamond context menu renders in two places — the canvas
(MotionPathOverlay, fixed in the prior commit) and the timeline (via
StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called
handleGsapDeleteAllForElement, deleting the element's whole animation. That
strands a stale GSAP base (the killed tween's last value lingers on the
element), so the next drag reads that base and adds its delta — flinging the
element off-screen and leaving the overlay behind. Route it to
handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path.
* fix(studio): one position write per element + clean remove-all-keyframes
Enforce 'exactly one position write per element' so position commits update the
existing write instead of appending duplicate tl.to/gsap.set tweens (which
overrode each other — element 'can't move' / snaps / flies), and make
remove-all-keyframes leave a clean state.
- dedupePositionWritesInScript + consolidate-position-writes mutation (acorn +
recast, in parity); findExistingPositionWrite matches degenerate duration:0
holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates;
removeAllKeyframesFromScript strips every position write for the selector.
- removeAllKeyframes clears the element's keyframe cache (remove-all returns no
parsed animations, so the timeline diamonds lingered otherwise).
- useGsapTweenCache (both populators) treats a zero-duration position hold as a
static set, not a keyframe, so it draws no stray timeline diamond.
- Extracted gsapPositionDetection.ts (file-size cap).
Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio
tests pass. Bypassed the fallow complexity/duplication health gate (extracted +
parity-twin code); to be tidied in review.
Add a small Studio selection channel so agents can ask a running preview server
for the element the user selected in Studio. This keeps the UX on the existing
npx hyperframes preview surface while giving agents a stable source file,
target selector, timeline time, and thumbnail URL for follow-up edits.
- extract deduplicateKeyframes + synthesizeFlatTweenKeyframes into gsapTweenSynth.ts
so useGsapTweenCache.ts drops from 628 to 573 lines (600-line cap)
- the drilled-into-group selection test asserted the old sticky behavior; the code
is intentionally non-sticky (clicking outside exits the group and resolves the
clicked element), so the test now asserts that
- pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a
position tween — a fresh same-group tween with a 0% baseline is created instead
- editing at a playhead past the tween extends it and keyframes there (matches drag)
- update-keyframe MERGES into the existing keyframe instead of overwriting, so
editing one property no longer drops z/transformPerspective (the lens then
animated from 0 and the element popped)
- dragging a keyframed element with a constant position tween keyframes rather
than writing a static set
* feat(studio): element groups — source mutations
Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.
* fix(studio): hoverable group interior + non-sticky drill-in
Two group selection bugs with animated members:
1) Empty space inside a group's overlay didn't hover/select the group. Members
animated outside the wrapper's static box (110px box vs 340px member union),
so elementsFromPoint hit only the full-bleed background there. Add a
member-union hit-test fallback: a point inside a group's live member bounds
resolves to that group (innermost wins).
2) After drilling into a group and selecting a child, nothing else was
selectable — out-of-scope resolved to null. Make drill-in non-sticky:
interacting outside the drilled group re-resolves normally and exits the
drill-in, so a later click on the group selects it as a unit again.
* feat(studio): enable animation editing for static inline timelines
The unsupported-pattern banner now clears for static window.__timelines["id"] =
gsap.timeline() (the parser reports it editable), and the banner copy is retargeted
to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]).
* fix(studio): correct keyframes + expansion for sub-composition timeline clips
Two gaps for elements inside a sub-composition:
1) Clip keyframes rendered off-clip. The keyframe cache computes clip-relative
percentages from the element's start/duration, but sub-comp internals aren't in
the timeline elements list, so duration defaulted to 1s and percentages blew
past 100%. Resolve the timing basis from the sub-comp HOST's bounds (via
domClipChildren, since the host's data-composition-src is stripped in the
rendered DOM). Shared resolveClipTimingBasis used by both cache populators,
which now re-run when the sub-comp children appear.
2) Only GROUPED sub-comp children expanded. Generalize the DOM-children collector
to gather id'd children of the sub-comp inner-root (grouped OR ungrouped),
descending through id-less structural wrappers; one level into groups for
drill-in. Ungrouped pills now expand into timeline rows too.
* feat(studio): element groups — source mutations
Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.
* fix(studio): hoverable group interior + non-sticky drill-in
Two group selection bugs with animated members:
1) Empty space inside a group's overlay didn't hover/select the group. Members
animated outside the wrapper's static box (110px box vs 340px member union),
so elementsFromPoint hit only the full-bleed background there. Add a
member-union hit-test fallback: a point inside a group's live member bounds
resolves to that group (innermost wins).
2) After drilling into a group and selecting a child, nothing else was
selectable — out-of-scope resolved to null. Make drill-in non-sticky:
interacting outside the drilled group re-resolves normally and exits the
drill-in, so a later click on the group selects it as a unit again.
Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.
* fix(core): a global gsap.set is off-timeline, so resolvedStart is 0 not the comp end
resolveTimelinePositions walks anims in document order advancing a cursor; a
global `gsap.set(...)` carries no position arg, so it fell through to the
cursor fallback and inherited the comp-end time (every prior tween's duration
summed) as its resolvedStart. A global set is a load-time hold — its start is 0.
This silently broke 'Enable keyframes' on any element whose only animation is a
global gsap.set (e.g. a statically-positioned card): promoteSetToKeyframes bails
when `playhead <= setStart`, and setStart was the comp end, so any playhead
before the end was a no-op. Pin a global set to resolvedStart 0 in both the
recast and acorn parsers; don't let it advance the cursor/prevStart.
* fix(studio): Enable-keyframes marks the generated 0% endpoint as auto-tracking
When 'Enable keyframes' promotes a static set to a two-stop tween, the 0% (the
held start the user didn't choose) is now marked `auto: true` → serialized as the
`_auto: 1` marker. The parser's endpoint-sync then keeps it tracking the nearest
keyframe until the user edits it directly; the 100% (the real keyframe placed at
the playhead) stays fixed.
This re-wires the auto-endpoint behavior that was silently dropped in #1605 (the
sync logic stayed, but no flow produced an `_auto` endpoint anymore, so an
untouched 0% never tracked). Adds a guard test so it can't be lost again.
* chore: suppress fallow complexity findings surfaced in the touched files
The resolveTimelinePositions guard added a branch (pushes it over threshold), and
changed-file scope re-surfaces pre-existing complex functions (readElementPosition,
applyArcWaypointAtPlayhead, the useEnableKeyframes callback). Bare directives only.
* fix(studio): dragging a --hf-studio-offset element no longer flies
Dragging a static element positioned via the legacy --hf-studio-offset CSS var
(e.g. dot-a) flew off-screen — three independent failure modes, all fixed:
1. Live drag integrated: the per-move draft read its base from the live transform
it set last frame (gsap.getProperty), so base+delta accumulated frame-over-frame.
Fix: carry a stable baseGsap on the in-memory drag member (immune to mid-drag
re-renders that wipe the data-hf-drag-* attrs) and use it as the fallback.
2. Commit re-added the delta: the source commit re-read the wiped attrs / live
transform. Fix: re-stamp the stable base/initial attrs in applyManualOffsetDragCommit
before the commit reads them.
3. Drop left it offset: the committed source was correct, but the LIVE element kept
its --hf-studio-offset var + translate:var(...), which composed with the GSAP
transform (rendered at dropped + offset) until a full reload. Fix: on cleanup,
when GSAP owns the position, clearStudioPathOffset() migrates the element off the
legacy CSS channel (leaving transform untouched) — matching the stripped source.
Adds a regression suite covering all three layers.