* feat(studio): timeline collision and placement model
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline magnetic snapping
What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.
Why: the magnet math for clip drags/trims, reviewable standalone.
How: new files only; type-only playerStore imports; consumers land with the
drag engine.
Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): multi-clip drag preview math
What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.
Why: the group-drag math, standalone and DOM-free.
How: new files only; consumed later by TimelineLanes.
Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline z-stacking sync model
What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.
Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.
How: new files only; consumed later by timelineZones and the stacking-sync
hook.
Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline lane-zone model
What: new pure module timelineZones — visual/audio track-zone
classification (classifyZone) and normalizeToZones, which re-packs lanes
into zone-consistent rows; tests cover the stacking/zones interaction.
Why: completes the z-model started in the stacking-sync PR.
How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and
computeStackingPatches (stacking-sync PR); type-only playerStore imports.
Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): asset click policy and canvas nudge gate
What: two small pure modules with tests — assetClickBehavior (click vs
double-click policy for sidebar assets) and canvasNudgeGate (debounce gate
for arrow-key canvas nudges).
Why: policy dependencies of the upcoming asset card and nudge hook,
reviewable as plain decision tables.
How: new files only.
Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.
* test(studio): characterization suites for resize commit and razor history
What: two test-only suites pinning CURRENT behavior before the NLE swap:
anchoredResizeReleaseShift.test.ts (manual-offset resize release commits)
and useRazorSplit.history.test.tsx (razor split undo/redo history).
Why: regression tripwires — the later glue-swap PRs must keep these green.
How: test files only; they import existing main modules unchanged and pass
against them as-is.
Test plan: bunx vitest run on both suites; fallow audit clean.
* feat(studio): canvas context menu and z-order actions (unwired)
What: CanvasContextMenu (right-click menu for canvas selections) and
canvasContextMenuZOrder (tie-aware bring-forward/send-backward z-order patch
computation) with its test suite. Shipped unwired.
Why: the z-order rules are the substance; mounting is one line in the later
overlay swap.
How: new files, compiled against current main. Nothing mounts the menu yet,
so .fallowrc.jsonc gains TEMP(studio-dnd) entries (entry registration +
ignoreExports) — removed by the app-shell swap PR that wires everything.
Test plan: bunx vitest run canvasContextMenuZOrder.test.ts; tsc --noEmit;
fallow audit clean.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
* feat(studio): timeline collision and placement model
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline magnetic snapping
What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.
Why: the magnet math for clip drags/trims, reviewable standalone.
How: new files only; type-only playerStore imports; consumers land with the
drag engine.
Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): multi-clip drag preview math
What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.
Why: the group-drag math, standalone and DOM-free.
How: new files only; consumed later by TimelineLanes.
Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline z-stacking sync model
What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.
Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.
How: new files only; consumed later by timelineZones and the stacking-sync
hook.
Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline lane-zone model
What: new pure module timelineZones — visual/audio track-zone
classification (classifyZone) and normalizeToZones, which re-packs lanes
into zone-consistent rows; tests cover the stacking/zones interaction.
Why: completes the z-model started in the stacking-sync PR.
How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and
computeStackingPatches (stacking-sync PR); type-only playerStore imports.
Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): asset click policy and canvas nudge gate
What: two small pure modules with tests — assetClickBehavior (click vs
double-click policy for sidebar assets) and canvasNudgeGate (debounce gate
for arrow-key canvas nudges).
Why: policy dependencies of the upcoming asset card and nudge hook,
reviewable as plain decision tables.
How: new files only.
Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.
* test(studio): characterization suites for resize commit and razor history
What: two test-only suites pinning CURRENT behavior before the NLE swap:
anchoredResizeReleaseShift.test.ts (manual-offset resize release commits)
and useRazorSplit.history.test.tsx (razor split undo/redo history).
Why: regression tripwires — the later glue-swap PRs must keep these green.
How: test files only; they import existing main modules unchanged and pass
against them as-is.
Test plan: bunx vitest run on both suites; fallow audit clean.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
* feat(studio): timeline collision and placement model
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline magnetic snapping
What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.
Why: the magnet math for clip drags/trims, reviewable standalone.
How: new files only; type-only playerStore imports; consumers land with the
drag engine.
Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): multi-clip drag preview math
What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.
Why: the group-drag math, standalone and DOM-free.
How: new files only; consumed later by TimelineLanes.
Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline z-stacking sync model
What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.
Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.
How: new files only; consumed later by timelineZones and the stacking-sync
hook.
Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline lane-zone model
What: new pure module timelineZones — visual/audio track-zone
classification (classifyZone) and normalizeToZones, which re-packs lanes
into zone-consistent rows; tests cover the stacking/zones interaction.
Why: completes the z-model started in the stacking-sync PR.
How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and
computeStackingPatches (stacking-sync PR); type-only playerStore imports.
Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): asset click policy and canvas nudge gate
What: two small pure modules with tests — assetClickBehavior (click vs
double-click policy for sidebar assets) and canvasNudgeGate (debounce gate
for arrow-key canvas nudges).
Why: policy dependencies of the upcoming asset card and nudge hook,
reviewable as plain decision tables.
How: new files only.
Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
* feat(studio): timeline collision and placement model
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline magnetic snapping
What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.
Why: the magnet math for clip drags/trims, reviewable standalone.
How: new files only; type-only playerStore imports; consumers land with the
drag engine.
Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): multi-clip drag preview math
What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.
Why: the group-drag math, standalone and DOM-free.
How: new files only; consumed later by TimelineLanes.
Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline z-stacking sync model
What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.
Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.
How: new files only; consumed later by timelineZones and the stacking-sync
hook.
Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline lane-zone model
What: new pure module timelineZones — visual/audio track-zone
classification (classifyZone) and normalizeToZones, which re-packs lanes
into zone-consistent rows; tests cover the stacking/zones interaction.
Why: completes the z-model started in the stacking-sync PR.
How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and
computeStackingPatches (stacking-sync PR); type-only playerStore imports.
Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow
audit clean.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
* feat(studio): timeline collision and placement model
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline magnetic snapping
What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.
Why: the magnet math for clip drags/trims, reviewable standalone.
How: new files only; type-only playerStore imports; consumers land with the
drag engine.
Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): multi-clip drag preview math
What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.
Why: the group-drag math, standalone and DOM-free.
How: new files only; consumed later by TimelineLanes.
Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.
* feat(studio): timeline z-stacking sync model
What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.
Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.
How: new files only; consumed later by timelineZones and the stacking-sync
hook.
Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
* feat(studio): timeline collision and placement model
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline magnetic snapping
What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.
Why: the magnet math for clip drags/trims, reviewable standalone.
How: new files only; type-only playerStore imports; consumers land with the
drag engine.
Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.
Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.
How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.
Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).
* feat(studio): timeline leaf helpers — audio inspector, zoom math, UI prefs
What: extends three leaf modules to their final NLE-stack form, tests in the
same change: timelineInspector (isAudioTimelineElement, resolveBeatSourceTrack),
timelineZoom (zoom/pps math incl. computePinnedZoomPercent), and
studioUiPreferences (persisted editor prefs).
Why: leaf dependencies of the NLE timeline stack; landing them first keeps
the later glue PRs to wiring.
How: additive from the consumer side — every export main already uses is
unchanged (typecheck against main's consumers passes untouched); every new
export is exercised by a test in this PR.
Test plan: bunx vitest run on the three test files; tsc --noEmit in
packages/studio; fallow audit --base origin/main clean.
* feat(studio): seek-restore contract for player reloads
What: useTimelineSyncCallbacks gains resolveReloadSeekTime and revealIframe —
the pure contract for where the playhead lands after a preview reload
(pending seek > deep-link seek > store playhead, clamped) and for undoing
refreshPlayer's iframe hide. Test suite included.
Why: the NLE editor reloads the preview on every committed edit; this
contract is the difference between "playhead restores" and "jumps to 0".
How: additive exports on an existing hook file; main's consumers unchanged.
Test plan: bunx vitest run useTimelineSyncCallbacks.test.ts; tsc --noEmit in
packages/studio; fallow audit clean.
What: extends three leaf modules to their final NLE-stack form, tests in the
same change: timelineInspector (isAudioTimelineElement, resolveBeatSourceTrack),
timelineZoom (zoom/pps math incl. computePinnedZoomPercent), and
studioUiPreferences (persisted editor prefs).
Why: leaf dependencies of the NLE timeline stack; landing them first keeps
the later glue PRs to wiring.
How: additive from the consumer side — every export main already uses is
unchanged (typecheck against main's consumers passes untouched); every new
export is exercised by a test in this PR.
Test plan: bunx vitest run on the three test files; tsc --noEmit in
packages/studio; fallow audit --base origin/main clean.
Selecting a rotated (cropped) element appeared to straighten it: the crop
dim and dashed window were drawn on the axis-aligned bounding box, so the
bright window was a straight rectangle and the element's rotated corners
were masked to near-black — while the DOM transform was untouched.
clip-path applies in the element's LOCAL frame, before its transform, so
the crop visualization now renders inside a container rotated with the
element: readElementCropFrame decomposes the computed 2D matrix into
angle + per-axis scale (element scale finally factored into the px
mapping too) and 3D/unparseable transforms keep the axis-aligned
presentation. Pointer deltas rotate into the element frame before the
inset resolvers, so edge/pan drags track the rotated handles correctly.
Review follow-ups (both reviewers, all findings):
- resize captures scope to the resize group: convert-to-keyframes
resolvedFromValues and the whole-offset backfill pass the group filter,
so an opacity-touching intro tween can't ride into a converted scale
tween (the rotation fix's contract, now uniform across intercepts)
- commitStaticSet resolves every group's target set BEFORE committing and
coalesces groups landing on the same legacy mixed set into one commit —
the second commit can no longer chase a stale group-derived id
- installAuthoredOpacityCapture also stamps an element the moment it GAINS
data-color-grading at runtime (attributeFilter), not just at insertion
- both writer twins now share the same emitted-set dedupe shape
- applySoftReload's positional tail becomes a SoftReloadOptions object
- readAllAnimatedProperties builds the group-filtered key set immutably
instead of deleting from the set mid-iteration
- applyAuthoredInlineOpacity documents the priority-lossy round-trip
- the marquee hit-test reads activeCompositionPathRef like its neighbors
New tests: resize intercept (scale route + group filter + non-uniform
longhands), after-write-HTML / stamp / empty-stamp opacity restore, the
no-op-commit-with-missed-instant-patch soft-reload contract, and the
runtime-gained-grading stamp.
The deselect restore read a ref recomputed from RENDER state — on a direct
A→B selection switch, state re-syncs to B before A's effect cleanup runs,
so after a committed crop gesture A was restored with B's crop string (or
lost its crop when B had none). The committed value is now written at
gesture-commit time (tri-state: none committed / crop removal / the exact
committed string), so cleanup never touches render state. Adds component
tests for lift/restore ordering, including the direct A→B switch and the
uneditable-clip stand-down.
gsap_from_opacity_noop matched 'opacity: 0' as a prefix — an authored
'opacity: 0.98' triggered the rule. One boundary-anchored predicate now
owns the exactly-zero test for both block and inline declarations,
including a block's last declaration without a trailing semicolon.
domEditingDom now imports the grading contract attribute from core
instead of re-declaring the literal.
commitStaticSet merged every property into the FIRST set found for the
selector: a panel W edit on an element whose only set was positional
produced tl.set("#el",{x,y,width}) — a mixed-group set the split
machinery exists to prevent — labeled "Set 3D transform" in undo.
Commits now batch per property group into a set that owns that group
(exact group match, then a mixed set already carrying the group, then a
fresh off-timeline gsap.set), with undo labels derived from the group
(Move layer / Resize layer / Rotate layer / Set 3D transform).
The always-on crop lifted EVERY selected element's clip-path and restored
only what it could parse as a px inset — selecting an element with a
circle/polygon/percentage clip visually un-clipped it, and deselecting
deleted the authored clip outright.
readElementCropInsets is now tri-state (zeros = no clip, null = a clip
the tool can't represent): uneditable clips get no lift and no handles,
and the lift restores the pre-lift inline value verbatim unless a crop
gesture actually committed.
- resize on a scale-driven element commits per-axis scale (scaleX/scaleY
for non-uniform drags) with keyframe normalization to the longhands, and
clears the width/height draft so size can't double-apply; the intercept
moves to gsapResizeIntercept.ts
- the drop frame applies the corrected position synchronously in the same
microtask chain as the soft reload (no network-window jump), and the
draft pins the anchor through accumulated moves on scaled elements
- gesture size/position math divides by the element's own content scale
- convert-to-keyframes resolves current values through the property-group
filter for ALL capture passes (opacity/rotationX from unrelated tweens
no longer leak into a rotation commit), and a grading-hidden source's
opacity is read from its canvas, not the inline hide
- canvas pointer-down confirms the hover target with a synchronous
hit-test before starting a marquee (stale-hover race lost selections)
Editing commits made elements vanish or dim permanently: invalidating the
whole timeline (or re-running the composition script on soft reload) made
GSAP re-capture tween bounds while runtime transients were live — the
grading hide's opacity 0, or a mid-flight tween value — so from()/to()
bounds got poisoned and the element rendered invisible from then on.
- patch only the edited tween in place, never timeline.invalidate()
- soft reload restores every animated element's authored inline opacity
(after-write HTML first, parse-time stamp as fallback) before the script
re-runs and re-captures
- a paired x/y commit whose second half is a no-op (changed=false) still
applies its instant patch, so panel edits reflect without deselecting
handleTimelineElementSelect tags each call with a monotonic token and ignores its result
if a newer selection started while it was resolving, so a rapid A-to-B clip click can no
longer let A's slower async lookup land after B and restore the wrong selection.
A timeline move/resize recorded the timing patch, then a server GSAP rewrite mutated the
same file afterward, leaving the recorded after stale so an undo hit a hash conflict. The
GSAP mutation now snapshots the touched files and records a follow-up edit under the same
coalesceKey, with a per-entry coalesceMs override large enough to survive the GSAP round
trip, so undo restores the original in one step. Applies to single-clip and group edits.
setSelectedElementId now always collapses to one element (genuine user intent); a new
setSelectionAnchor moves the anchor within a multi-selection without collapsing it, used
only by the DOM-to-store sync echoes so a group survives a gesture.
applyDomSelection mirrors the whole DOM group into the store via setSelection instead of
writing only the anchor, so the store stays authoritative and a preview click collapses
while a preserved-group echo keeps every member.
The store-to-preview sync no longer applies a partial selection: if a resolvable member's
DOM node is not ready yet it bails and retries on the next effect run, so the write-back can
never shrink the store's selection by dropping an unresolved member.
Marquee row hit-testing reuses shouldShowTimelineLayerGroupHeader instead of re-deriving
the group-header placement rule, keeping one owner for that predicate.
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.
setSelectedElementId no longer resets the selection set when re-selecting an element
that is already a member: DOM-to-selection sync echoes fire on every pointer move during
a group drag and were collapsing the set to the grabbed clip.
Also drops the duplicate clearSelectedElementIds action in favor of clearSelection,
which the marquee now uses to clear on an empty drag.
Apply handle deltas through a group resize session that snapshots selected members.
Clamp one shared delta against zero start and min duration before preview and commit.
Playback-start changes are calculated per member with the existing trim formula.
Fan selected body drags through a group move session that snapshots all selected members.
The grabbed clip snap result is applied first, then one shared delta is clamped.
Live preview patches every moved member and release persists once through batch timing.
Use selectedElementIds as the live selection set with selectedElementId as anchor.
Keep single-click selection as a one-element set and clear both fields together.
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>