Resizing a rotated element from an NW/NE/SW handle made it jump/lag and left
the selection box detached from the element (empty gap when shrinking); the SE
handle was fine. Root cause: the anchor translation that keeps the opposite
corner fixed was computed from the axis-aligned bounding-box delta
(originWidth − measuredWidth) and pushed through GSAP x/y. GSAP x/y is a
screen-space translate, but a rotated element grows about its transform-origin
in its LOCAL frame, so its real corners move along rotated axes — the AABB
delta then slid the fixed corner every frame (drift ∝ drag distance, up to
~10px at −4° on a 640×360 image; zero only when unrotated, which is why SE and
axis-aligned elements looked correct).
Fix: pin the actual (rotation-aware) fixed corner. New
elementCornerOverlayPoints() transforms the element's border-box corners
through its live transform matrix into overlay coords; startGesture captures
the fixed corner (opposite the grabbed handle) at gesture start; the
pointermove anchor is now (fixedStart − fixedAfterSize), the exact screen shift
needed to return that corner home. Falls back to the old AABB delta only when
the live DOM can't be measured. The box left/top is re-read from the element's
post-anchor AABB so it hugs the true bounds every frame.
Verified live in Studio against a rotated (−4°) image via the shipped
functions: fixed-corner drift 0.00px on every sampled frame for NW/NE/SW
(was up to 10.58px), and 0.00px across 0°/−4°/30°.
Two bugs landed with f0b11989e's card-grid restyle:
(a) Added detection — deriveUsedPaths was stripping the /api/projects/<id>/preview/
prefix with a server-relative regex, but the core runtime's resolveNodeAssetUrl
calls new URL(raw, document.baseURI).toString() which produces fully-absolute URLs
(e.g. http://localhost:3012/api/projects/demo/preview/assets/my%20file.mp4).
The regex never matched, so every asset stayed "unused". Fix: parse the URL via
the URL constructor to strip the origin, then strip the API prefix, then
decodeURIComponent so percent-encoded filenames (spaces, parens) match the
plain-text asset-list entries the server returns.
(b) Duration badges — the manifest-only path (.media/manifest.jsonl) 404s for
every project that doesn't use the media-use pipeline, so manifest.get(a)?.duration
was always undefined. Fix: useProbedDuration hook in AssetCard lazily creates a
hidden video[preload=metadata] per card and reads onloadedmetadata.duration. Retries
once after 50ms to survive React 18 StrictMode's synchronous effect-cleanup that
aborts the first attempt. Skip flag avoids probing non-video assets or when the
manifest already provides a duration.
Tests: four new deriveUsedPaths cases covering absolute URLs and percent-encoded
filenames. Live-verified on qa-visual project (files with spaces/parens): Added
chips appear on used assets, duration badges show 00:04 and 00:28 on the two
video cards.
Marquee-selecting near/past the viewport edges now scrolls the timeline
(same resolveTimelineAutoScroll zones + RAF step the clip drag uses), and
the marquee rect/live selection stay correct under the scroll because the
origin is content-space — scrolling mid-marquee extends the selection.
normalizeToZones packed each authored data-track-index group independently
in ascending track order, so lane order was driven purely by the authored
track number. The reverse z->lane reordering only reached WITHIN a single
track, so a z=0 video on track 0 sat ABOVE a z=26 icon on track 1 — the
timeline contradicted the canvas, whose CSS stacking painted the icon on top.
Order a zone's authored track blocks by descending representative z (the max z
among a track's clips), tie-breaking on the ascending authored track index so an
all-equal-z composition keeps its prior order. Within-track packing and the
stable-key tie-break are unchanged; the mapping stays idempotent and converges
with the forward lane->z mapping.
Regression tests derived from the real qa-clean project (svg z=26/z=5 over a
z=0 video across authored tracks 0/1/3 + bottom audio).
Restore the live passenger preview removed in 90892dc7a. While a selected clip
is dragged, ALL selected clips now move together as one rigid formation
following the cursor: the grabbed clip is the free ghost, and every passenger
slides by the SAME delta via a compositor translateX. That delta is the grabbed
clip's previewStart minus origin start — already group-clamped upstream
(clampGroupMoveDelta in updateDraggedClipPreview) — so the whole formation stops
at the wall together and never deforms. Passengers no longer stay still (the
90892dc7a behavior) and never lag behind individually.
Re-adds isMultiDragActive/multiDragDeltaSeconds/isMultiDragPassenger/
multiDragPassengerOffsetPx + MultiDragPreviewInput and their tests, updated to
the rigid-formation contract (every passenger shares the one clamped delta).
The corner-resize move handler computed the anchor translate (NW/NE/SW keep
the opposite corner fixed) from nextSize.overlay{Width,Height} — the raw math
size (originSize + delta). applyStudioBoxSizeDraft rounds and clamps the CSS
width/height, and a GSAP scale + centered transform-origin drifts the visual
size further, so the element never actually renders at the math size. Anchoring
off the math value trailed the true edge by the rounding/scale residual every
frame: the "fixed" edge wobbled and lagged on NW/NE/SW (SE, anchorless, stayed
glued — hence the reported SE-only smoothness), and the box was then placed at a
second BCR read that disagreed with the mis-applied offset, leaving empty space
inside the selection border.
Measure the element's REAL rendered overlay size once, right after the size
draft and before the anchor translate, and drive the anchor from that measured
size. The box top-left is then exact by construction (origin + anchor) for
anchored corners — no second, one-frame-late BCR read — so box, dots, and the
element's true bounds track together every frame. SE (no member) keeps reading
its own top-left from the measured rect for scale/origin drift. The commit path
(single size+offset persist) is untouched.
LayersPanel only re-collected DOM layers on refreshKey/compositionLoading/iframe-load changes. During continuous scrubbing the player updates time via the liveTime pub/sub (bypasses React/Zustand to avoid per-frame render storms), so the panel went stale until the tab was closed and reopened.
Subscribe to liveTime with a trailing rAF + 100 ms cooldown so collectLayers fires at most ~10 times/s during a scrub, keeping the layer list live without flooding React with re-renders.
The empty-canvas marquee guard in DomEditOverlay.handleOverlayPointerDown
gated on hoverSelectionRef.current, but that ref is populated
ASYNCHRONOUSLY by the RAF hover loop in useDomEditOverlayRects. On a
genuine first click over an element the hover hadn't resolved yet, so the
ref read null, the click was misread as empty canvas, and a marquee
started — its stopPropagation + suppressNextOverlayMouseDown swallowed the
selecting onMouseDown, and the zero-drag pointer-up cleared everything.
Only the second click (hover now populated) selected: the reported
two-clicks-to-select bug. Replace the async-hover guard with a synchronous
getPreviewTargetFromPointer hit-test so an element under the pointer never
starts a marquee.
Reflect canvas/LayersPanel z-index edits in timeline lane ordering. When a
clip's z changes via handleDomZIndexReorderCommit (inline-style z-index
patches), overlapping-in-time same-zone clips now display higher-z on higher
(upper) lanes — closing the loop with the forward lane→z mapping in
timelineStackingSync.
- playerStore: add optional zIndex to TimelineElement.
- timelineElementHelpers: readTimelineElementZIndex — inline style first, then
computed; "auto"/empty/absent ⇒ 0. Mirrors canvasContextMenuZOrder.parseZIndex
so the two directions agree. Works on detached parse Documents.
- Discovery threads zIndex onto elements in all build paths: timelineDOM
(manifest-clip factory, implicit layers, parseTimelineFromDOM) and
timelineIframeHelpers (buildMissingCompositionElements).
- timelineZones.packTrackLanes: pack in descending-z order (tie-break on the
STABLE key, never the mutated lane — the historical oscillation bug), using
occupancy-based first-fit so sequential (non-overlapping) clips still share a
lane regardless of z. Higher z claims the upper lane among overlapping clips.
Tests: z-ordering, no-overlap unaffected, audio zone unaffected, missing z ⇒ 0,
stable-id tie-break, fixed-point (normalize∘normalize), reload simulation, and a
round-trip convergence test proving lane-move → z patch → re-discovery settles
to the same lanes with no oscillation.
Replace the list-based ImageCard layout with a 2-column card grid for
image and video assets. Each card shows:
- Thumbnail filling the card (aspect-video, rounded, object-cover)
- "Added" dark chip badge top-left when the asset is referenced in the
timeline (reuses existing deriveUsedPaths / usedPaths machinery)
- Duration badge top-right (MM:SS) sourced from the manifest cache;
only shown for media with a known duration, never for images
- Filename caption below the card truncated in the middle (e.g.
"2a37e…887d8.mp4") so the extension is always visible
- Hover video-preview overlay on video cards (unchanged behaviour)
- Drag-to-timeline payload (TIMELINE_ASSET_MIME) untouched; durationSec
now populated from manifest rather than hardcoded null
Font assets keep the compact row layout (no meaningful aspect-ratio
thumbnail); extracted to FontRow to remove the old size="small" prop.
Exports two new pure helpers with unit tests:
- truncateMiddle(str, maxLen) — middle-ellipsis truncation
- formatDuration(seconds) — MM:SS formatter, empty string for ≤0/NaN
A hero/background <video> (or full-bleed <img>/<canvas>/<svg>) spanning
>=95% of the composition on both axes was excluded by the canvas picker's
full-bleed heuristic, which was meant to skip decorative scene wrappers and
backdrops so a click lands on inner content. Media leaves ARE the content the
user clicks, so the picker fell through to whatever sat behind the video —
selecting the layer behind it, and on a true first click resolving to nothing
(only the hover fallback recovered it, hence the 'needs a second click'
symptom). Spare visual media leaves from the full-bleed exclusion; non-media
full-bleed containers still get skipped so inner content stays reachable.
Remove the live passenger translateX preview: co-selected members now stay
visually still on their lanes (keeping the selected-highlight) while the grabbed
clip follows the cursor freely. On drop the commit still shifts every member by
the same group-clamped delta (rigid formation, unchanged). Strips the now-dead
preview helpers (isMultiDragActive/multiDragDeltaSeconds/isMultiDragPassenger/
multiDragPassengerOffsetPx + MultiDragPreviewInput); clampGroupMoveDelta stays.
Provision the two optional deps commitDraggedClipMove accepts so a
timeline lane-change drag also patches the edited clip's z-index to match
lane order (research/STAGE3-NEEDED-WIRING.md).
- useTimelineClipDrag: accept readZIndex + onStackingPatches, hold in refs
(mount-effect closure), forward to commitDraggedClipMove.
- Timeline.tsx: provision both from the preview iframe + the canvas z-order
persist path (handleDomZIndexReorderCommit) — the SAME inline-style commit
shape the right-click menu / LayersPanel use. resolveIframeElement reuses
the hfId ?? id ?? selector[selectorIndex] resolver (findElementForSelection).
- Export readEffectiveZIndex from canvasContextMenuZOrder (reuse, not dup).
- Add optional context accessors so the public standalone <Timeline> export
stays mountable without DomEditProvider/StudioShellProvider (z-sync no-op).
Atomicity: the doc marks the lane-move + z-patch one-undo-step MERGE as
deferred ("only the persist-merge is deferred"); handleDomZIndexReorderCommit
already self-coalesces its z patches under a z-reorder coalesceKey. Not forced
here, per the doc.
Post-rebase regression: the optimistic setDuration on move/resize was
being immediately reverted — the soft-reload path's __hfForceTimelineRebind
makes the runtime re-read the iframe's root data-duration and re-post it,
overwriting the store with the stale pre-edit value. Fix: the optimistic
sync now also patches the iframe document's root data-duration (grow AND
shrink) before the rebind, so the runtime re-posts the CORRECT value and
the readout stays live. Covered by patchDocumentRootDuration tests.
When the grabbed clip is part of a multi-selection, the whole selection
moves by its delta on commit, but the live drag let the grabbed clip's
ghost out-run the group: its own snapping/clamping led individually while
passengers previewed a raw delta, and a passenger clamped at 0 on commit
deformed the formation (main keeps it rigid).
Port main's model (clampTimelineGroupMoveDelta): compute the group-clamped
delta — bounded by the most-constrained (leftmost) member so no member
crosses 0 — and apply it to the grabbed clip's preview start too. The
grabbed clip now STOPS the instant any member hits the wall; the formation
never deforms. Lane changes still apply to the grabbed clip only. Commit
path (atomic w/ rollback) unchanged.
Clips show grab at rest / grabbing while dragging so draggability is
advertised (was default cursor). The zoom slider gains a compact numeric
readout — "Fit" in fit mode, N% in manual — matching main's affordance in
our flat chrome.
persistTimelineElementsMove grouped edits by owning source file and
called saveProjectFilesWithHistory once PER group inside the loop. A
move whose clips span two files (a sub-comp) therefore did N writes and
recorded N history entries; a partial failure (group 2 throws) left
group 1 written plus a stray history entry while the caller rolled the
store all the way back — non-atomic and multi-undo.
Split into three phases: patch every group in memory, write ALL files in
ONE saveProjectFilesWithHistory call (write-all / record-one /
rollback-all), then run the non-fatal per-group preview sync. Disk now
matches the caller's all-or-nothing store rollback and undo is one step.
Ports main's MoveSession rollback-all-members discipline
(useTimelineClipGroupDrag.ts) onto our batched-persist engine.
Port main's TimelineLayerGutter music-icon affordance onto our gutter.
Main gates a <Music> icon on audio layers (TimelineLayerGutter.tsx:30-37);
our TimelineCanvas gutter (~276) was eye-toggle only. Add the same audio
check via isAudioTimelineElement over the track's elements, stacked above
the eye button in a vertical flex column, styled with our flat chrome
(text-white/35) rather than main's inline theme color. Eye/EyeSlash
variant already matched main's.
Post-rebase fixup. The rebase onto origin/main textually auto-merged
main's #2111 (multiselect) and #2068 (z-order) hunks into our engine
glue files, leaving the tree non-compiling (91 tsc errors). Our engine
is the base:
- Remove main's orphaned #2111 multiselect island (useTimelineClipGroupDrag,
useTimelineGroupEditing, useTimelineMarqueeSelection, timelineClipDragPreview,
TimelineSelectionOverlays) plus its main-only tests. Nothing in our live
shell imports them; our marquee/group behavior is covered by our own modules.
- Restore engine-glue files corrupted by main auto-merge hunks to ours
(timelineCallbacks, timelineDOM, useExpandedTimelineElements, useDomSelection,
useDomEditWiring, TimelineClipDiamonds, TimelineEditContext).
- Restore three test files (playerStore, useDomSelection, timelineDOM) whose
main test hunks targeted main's replaced setSelection/z-index API.
- Drop a stale .fallowrc allow-unused entry for the deleted timelineDropPreview.
tsc --noEmit exit 0; build exit 0; studio tests 1700 pass / 18 pre-existing
localStorage-env fails.
Branch-wide audit: deleted 145 LOC of provably-dead code left behind by
the drag-model pivots — buildTrackInsert (+types +5 tests, the known-dead
free-placement insert planner), snapClearOfClips (+3 tests, the last
magnetic-main-track relic), the never-wired snapKeyframePctToBeat export;
de-exported two internal-only symbols and dropped a stale fallow
suppression. The fallow dead-code gate section goes 4 findings -> 0; test
count drops by exactly the 8 deleted dead-code tests (1625 -> 1617), suite
otherwise at the pre-existing 18-env-failure baseline.
Adds the audit report (research/AUDIT-2026-07-09.md — includes reported-
only risky simplifications and the >600-line gate offenders with split
suggestions) and the rebase/PR plan (research/PR-PLAN-2026-07-09.md —
main-drift analysis, conflict forecast, stacked PR sequence).
The NW/NE/SW corner resize committed size then anchor-offset as two
sequential persists — the frame between them (new size in source, offset
still live-only) was the visible release jump, and it cost two undo
entries. handleDomBoxSizeCommit now takes the offset and emits both patch
sets in ONE commitPositionPatchToHtml call (builders read the mutated
element and touch disjoint properties — invariant locked by 3 new tests).
SE (anchorless) is unchanged, which is exactly why it never jumped.
Panel/timeline seams widened 1px -> 3px per feedback, 8px grab zones
recentered.
- "Bring forward" blanked the canvas: z-index was the ONE style property
that forced a preview reload on both commit paths (property panel's
skipRefresh = property !== "z-index"; the batch reorder reloaded on its
last entry) — a stale defensive measure. Inline z-index restacks live
in-browser, and both paths already mutate the element optimistically,
so both now skipRefresh. Verified the SDK persist path honors it too.
- The context menu could get stuck open: the dismiss listened for
bubble-phase mousedown, and the overlay's marquee/selection handlers
stopPropagation'd it away. Dismiss now listens capture-phase for
pointerdown+mousedown (any press outside the menu closes it, including
over the canvas), and the menu also closes when the selection moves off
its target (reselect/deselect/reload) so it can't orphan.
- Corner resize dots 9px -> 12px (hit area unchanged).
Anchored corner resize was confirmed zero-reload already; its remaining
two-undo-entries gap needs a small useDomGeometryCommits change
(documented in the audit queue).
Three rounds of padding shrinks changed nothing because the visible gap
was never the padding: the structural w-2/h-2 (8px) resize dividers were
the seam, deliberately kept full-size for grab area. Standard fix: the
divider element is now 1px (the visible seam) with an absolutely
positioned invisible 8px hit zone centered on it, so hover/drag/keyboard
resize behavior is unchanged. Live-measured: panel-to-panel distance is
now the 1px divider itself (was ~8px of shell showing through).
Two independent bugs, both proven live in headless Chrome:
- Every z-order item rendered permanently disabled: getSiblingEntries
filtered siblings with `instanceof HTMLElement` — but the elements live
in the preview IFRAME's realm, so the top-window constructor check was
always false, every sibling was dropped, and resolveZOrderChange always
returned null. Realm-safe check via the element's own
ownerDocument.defaultView (nodeType fallback).
- Clicks on enabled items never landed: the menu is portaled to
document.body but remains a React-tree child of DomEditOverlay, so
synthetic pointer events bubbled into the overlay's marquee/selection
handlers, which preventDefault'd and re-resolved selection — cancelling
the trailing click. The menu root now stops propagation for
pointer/mouse/click/contextmenu, and item actions fire on pointerdown
(button 0) ahead of any outside-click logic.
Live proof: Bring-to-front moved z 8->11; Delete removed the clip; outside
click and Escape dismiss; right-click elsewhere repositions.
Corrections on the restyle round:
- Ruler tick marks restored (they were wanted); what the user meant were
the FULL-HEIGHT vertical gridlines drawn through the track area — those
are removed (beat lines untouched).
- Top track pad 72 -> 50px; panel gaps 2px -> 1px.
- Toolbar gains a thin divider between the tool-mode group (select/razor)
and the editing-actions group (snap/keyframe/split/beat).
- Playhead head truly centered, live-measured: position:sticky treats
`left` as a threshold, not an offset, so the old 50%+translate(-half)
landed 4.5px left of the line. left:0 with no transform puts the head
center exactly on the line center (measured 51.516 == 51.516, was
47.016 vs 51.516).
Every timeline edit used to full-reload the preview iframe: the raw DOM
flashed ALL clips stacked for a beat, fit-zoom recalculated so the whole
timeline rescaled, scroll jumped, and a freshly dropped asset stayed
invisible at the playhead until nudged. Four coordinated fixes:
- Timing-only edits (move / resize / multi-move) no longer reload AT ALL:
the GSAP-mutation endpoints already return the rewritten script, so the
live iframe gets it via applySoftReload (the DOM editor's existing
mechanism) — the optimistic DOM patch + store update already happened,
and nothing structural changed. Full reload remains only for structural
edits (insert/delete/split), escalating from soft reload solely on the
PERMANENT cannot-soft-reload outcome.
- Zoom pins on the first edit: computePinnedZoomPercent converts the live
fit pps into an equivalent manual zoom, so duration changes stop
rescaling every clip; Fit re-fits on demand. Zoom mode/percent are now
persisted (the reload used to snap back to "fit" — that was half the
rescale bug).
- Structural reloads hide the iframe (visibility) until the restore seek
renders, then reveal — a brief blank instead of the all-assets flash;
a 5s failsafe always reveals. Timeline scrollLeft is restored after the
element resync, clamped to the new width.
- Drop-visible-at-playhead: GSAP seek(t) to the CURRENT time is a no-op
that never re-renders, so the post-drop restore seek skipped the new
clip. The restore now guard-seeks (t-epsilon then t) to force a real
evaluation.
+19 tests (pin math, preference round-trip, soft-reload classifier,
reveal helper).
- The canvas right-click menu never opened when right-clicking an
unselected element: handleContextMenu resolved the element and called
onSelectionChange, then immediately read selectionRef — which React
hadn't updated yet (stale-ref race), so it bailed; the render gate on
the unsettled selection prop compounded it. The resolved selection now
travels WITH the menu state ({x, y, sel}) instead of being re-read.
- Rotate handle is a <button>, so UA/preflight gave it cursor:pointer —
explicitly cursor:default now (no cursor change at rest or mid-rotate).
- resolveMediaPreviewUrl concatenated raw paths: filenames with spaces or
parentheses ("heygen-symbol-blue-logo (2).svg") produced invalid URLs
and 404'd every thumbnail tile — the svg fallback then dutifully
rendered broken imgs. Each path segment is now encodeURIComponent'd
(slashes preserved); fixes thumbnails for ALL media with such names.
+5 URL-encoding tests.
Per user feedback, live-verified via computed styles in a browser:
- Panel gaps down to 2px (px-0.5/pt-0.5; collapsed rail mr-1).
- Playhead head centered exactly on the line (-headWidth/2) and the line
now STARTS at the head's bottom edge, so it no longer shows through the
hollow center.
- Ruler matches the panel surface (#0A0A0B), bottom border removed, tick
LINES removed entirely — time labels only, CapCut-style.
- All track lanes one uniform color (#101014), distinct from the panel;
hidden-track dimming and drag highlights preserved.
- Top track pad 24px -> 72px (matches the bottom pad).
- 24px left inset before t=0 NOT shipped: the x-origin audit found
conversions outside the shared constant; skipped rather than desync
x<->time math (documented).
- Multi-drag preview: co-selected clips now slide LIVE with the dragged
clip (compositor translateX of the same time delta, elevated
z/opacity), matching exactly what the commit applies. Pure
delta/eligibility module + 15 tests; single-drag path untouched.
- Playhead head: pins to the top of the scrolled track area (sticky,
above the sticky ruler) so it never scrolls out of reach, and is
outline-only at rest, filled while actively scrubbing (new reactive
isScrubbing signal from the ruler-press state machine).
- Track-area breathing room, CapCut-style: 24px above the first lane,
72px below the last (drag into the void to create a bottom track).
One source of truth — getTimelineRowTop/getTimelineRowFromY in
timelineLayout — threaded through clip rects, marquee, drop-row and
insert-row math; 12 new y-math tests. Left inset before t=0 skipped:
GUTTER is the x-origin of every time conversion, not empty space.
- Snap: no code change — default was already ON (a persisted legacy
toggle explains it being off for one profile), and cross-track edge
snapping has no track filter (the intermittent miss is the 8px radius
at low zoom).
- SVG clips showed no thumbnail: the aspect probe's onerror path hid the
strip even though <img> tiles render SVGs fine — svg sources now fall
back to the 16:9 default strip instead of erroring (intrinsic-size-less
SVGs already fell through correctly on onload). avif was missing from
IMAGE_EXT/MEDIA_EXT entirely, so .avif drops got no clip at all.
- The beat tool stayed disabled with plain audio present: analysis only
ran for the role-tagged music track (isMusicTrack requires
data-timeline-role="music" or a music-ish id), and dropped audio has
neither. New resolveBeatSourceTrack: explicit music track wins, else
the longest untagged audio clip (excluding sfx/voiceover roles) becomes
the beat source; the fallback path skips the saved-beats-file guard so
analysis runs and the button enables immediately. isMusicTrack itself
is unchanged (beat-snap and drag exclusions keep their semantics).
+26 tests.
Right-clicking a canvas element (selects first if needed) opens a context
menu: Bring forward / Send backward (z-index vs overlapping siblings,
computed from the live iframe), Bring to front / Send to back (vs all
siblings), and Delete — disabled states when already at a limit. Applied
optimistically to the live element and persisted through the existing
z-reorder commit path (handleDomZIndexReorderCommit, one undo entry) and
the standard element-delete handler, wired at PreviewOverlays. Pure
z-order math unit-tested (17 cases).
Also per feedback: the rotate handle/gesture no longer changes the mouse
cursor (default arrow throughout), and the corner resize dots now render
strictly above the selection outline (they clipped under it).
- Panel seams reduced one step (6px -> 4px paddings, collapsed rail
8px -> 6px); resize divider hit areas untouched.
- The preview stage field behind the composition was bg-neutral-700 gray —
now the panel surface (bg-neutral-950) so the video floats on the panel.
- Split-at-playhead uses the CapCut-style "][" bracket glyph (inline 16px
SVG, 1.6 stroke) instead of scissors; the razor tool keeps scissors.
Four timeline behaviors:
- Multi-select finally DOES something: Delete removes every selected clip
as ONE atomic edit (one read/patch-all/duration-sync/save per file,
single undo entry "Delete N timeline clips"; hotkey + context menu both
expand to the selection). Dragging one selected clip moves the WHOLE
selection by the same time delta (tracks unchanged for passengers, lane
logic applies to the dragged clip; persisted via the atomic batched
move path with rollback). Passengers shift on commit — live multi-ghost
is a known cosmetic gap.
- Sticky-ruler scrub: the ruler-vs-body decision used content-space y,
so a scrolled-down press on the visually stuck ruler fell into the
marquee branch. New pure isTimelineRulerPress uses viewport-space y;
unit-tested with a scrolled scenario.
- Trim-to-extend: resizing a clip's right edge now extends the rendered
timeline and auto-scrolls at the edge, symmetric with move drags —
with scroll compensation so the trim keeps tracking the pointer while
content scrolls beneath it.
- The timeline always renders at least 60s (MIN_TIMELINE_EXTENT_S): fit
pps derives against max(duration, 60), so a short comp occupies the
left fraction with real, drag/drop-enabled empty ruler after it.
+21 tests (ruler press, multi-move commit, fit-pps/width derivation).
Two timeline thumbnail bugs:
- Image clips never showed a thumbnail: the clip-content dispatcher routed
img tags into VideoThumbnail, which loads the src into a hidden <video>
— an image URL fires the error event, zero frames extract, and the clip
shimmers forever. New ImageThumbnail renders a lazy object-fit:cover
film strip (IntersectionObserver + aspect probe), sharing extracted
strip math with VideoThumbnail via thumbnailUtils (which also fixes a
latent divide-by-zero on aspect-0 videos) and the tested
resolveMediaPreviewUrl for /api/projects/{id}/preview URL building.
- Thumbnails poked sharp corners out of rounded clips: the clip container
is deliberately overflow-visible (keyframe diamonds hang outside), and
the inner content wrapper clipped to a sharp rectangle. borderRadius:
inherit on the wrapper clips both video and image strips to the clip's
radius, on the normal and drag-ghost paths; labels and trim handles
stay above (z 6/4).
+12 tests (strip math, URL resolution, ImageThumbnail states).
User verdict on 167e56a4a: the panels were fine — the problem was the
GAPS between panels being the same color. Revert every panel surface to
its original dark value (bg-neutral-950/900, timelineTheme
#0A0A0B/#0B0C0F/#0E0F12) and lift the SHELL canvas to #18181B so the
seams between the dark cards read clearly, CapCut-style.
Also per feedback:
- The h-px white/10 hairline inside the timeline height divider is
transparent at rest (hover/drag/focus washes kept, hit area untouched).
- Toolbar never hides controls: add-keyframe and add-beat were unmounted
without an eligible selection/analysis — now always rendered, faded
disabled with explanatory tooltips (split already behaved this way).
- Player transport row loses its own background/border and blends into
the preview panel surface.
Selection chrome restyle per user feedback:
- Corner resize handles are 9px circular dots (white fill, accent border,
soft shadow) instead of 12px accent squares; pointer target stays 16px
via an invisible hit square centered on the corner.
- The mid-side crop bars read as mystery controls — now hover-reveal
(opacity 0 at rest, fade in when the pointer enters the edge hit strip,
during a crop drag, or when a crop is committed), restyled as short
accent pills clearly distinct from the resize dots, with "Crop" /
"Reposition crop" tooltips. Hit strips sit flush outside the crop edge;
corners stay free for resize, per the component's original constraint.
- Rotate handle: knob + 1px stem replaced with an attached circular-arrows
chip at BOTTOM-center, 12px below the bbox — no connecting line.
Rotation math is center+delta based, so handle position doesn't enter
it; Shift 15-degree snap intact. Verified the bottom crop strip (0-12px)
and rotate hit (from +12px) touch but don't overlap.
Toolbar: every control is now a flat, borderless icon button (28px hit
area, transparent bg, subtle hover/pressed washes, 16px icons — were
11-14px mixed); the boxed razor group, magnet border, and Fit border
chrome are gone; the TIMELINE label dropped for CapCut-like density. The
split-at-playhead button now subscribes to selection/elements reactively
(it read getState() in render, so enable/disable lagged until the next
playhead tick), gets flat styling with a dimmed disabled state, and
tooltip "Split at playhead (S)" — verified byte-identical gate + handler
to the S key.
Panels: the four regions were separated in a53cc7219 but read as
near-invisible. Panel surfaces lifted to #131316 against the #050506
shell, borders strengthened to neutral-700/60, right panel brought onto
the same surface as the other three, and the timeline's inner theme
(shell/row/gutter) lightened to sit inside the card instead of blending
into the canvas. Verified live: computed styles + screenshots show four
distinct cards.
Canvas audit corrected two things the docs claimed already worked:
- Snap guides (including the composition-center H/V magnets, which existed
in the snap engine all along) rendered SHIFTED off the composition: the
overlay drew them from the overlay origin, ignoring the letterbox offset.
New pure resolveGuideLineRect anchors every guide to the composition
rect, so the center-magnet lines now sit exactly on the canvas center and
span it fully. Applies to element-to-element and spacing guides too.
- The selection had ONE resize handle (SE), not the assumed eight. Now all
four corners render (same chrome, correct cursors); non-SE corners keep
the opposite corner visually pinned by pairing the size change with a
manual-offset translation through the same GSAP channel drags use, with
the size commit sequenced before the offset commit so the two source
mutations can't race. Resize snapping is now edge-aware (the grabbed
edges snap, composition center included; Alt suppresses). Non-offsetable
elements keep the old SE-only behavior.
Known limitation: an anchored corner resize records two undo entries
(size + offset); collapsing them needs app-level commit plumbing.
+13 unit tests (corner delta/anchor math, edge-aware snap, guide rects).
Dragging on empty timeline body no longer scrubs the playhead — it draws a
marquee: every intersecting clip selects LIVE while dragging (dashed accent
rect mirroring the canvas MarqueeOverlay), release keeps the selection,
Cmd/Ctrl at pointerdown (or Shift mid-drag) adds to the existing one,
Escape cancels and restores. A plain click on empty body deselects all.
Scrubbing stays on the ruler (the standard surface); clip interactions and
the Shift+click range-selection popover are unchanged.
Selection lands in the store's selectedElementIds (new bulk setter) with
selectedElementId as the primary. Pure geometry in timelineMarquee.ts
(4px drag threshold, 4-direction rect normalization, clip rect from
track-row math, additive union) with 18 unit tests. NOTE: downstream ops
(delete/move) still act on the primary only — wiring batch ops onto
selectedElementIds is follow-up work.
An external file drop fires two preview reloads back-to-back (the upload's
refresh + the insert's reload). The second refreshPlayer called
saveSeekPosition while the first reload's iframe was mid-teardown —
getAdapter() still returned that dying document's adapter, whose getTime()
reads 0 — overwriting the correctly saved pending seek with an explicit 0,
which initializeAdapter then honored. Result: drop a file from Finder and
the playhead jumps to 00:00 (reproduced live with a currentTime
write-trace; the store held the right time, the poisoned pendingSeek won).
saveSeekPosition now refuses to re-read position while a refresh is
already in flight and a save exists — the first save wins until it is
consumed. Verified live end-to-end: seek 11s → Finder-style file drop →
clip inserted, playhead stays at 11s (was: 0). Sidebar asset drops were
already correct (single reload) and stay so.
Pressing Escape mid-gesture (clip move, trim, or pending blocked-drag)
cancels it: no commit, no undo entry. Previews live only in the drag/
resize state — the store mutates only in the pointerup commit path — so
clearing the state restores the pre-drag timeline; the auto-scroll RAF is
stopped and the click from the eventual pointerup is suppressed once so it
can't reselect. Decision extracted as pure resolveTimelineDragEscape
(unit-tested: cancels for each gesture kind, click suppressed only past
the drag threshold, other keys ignored).
Arrow keys move the selected canvas element(s) by 1 composition px, Shift
by 10 — the top-ranked gap vs CapCut-class editors. The nudge reuses the
manual-offset drag machinery end to end: the same drag-member snapshot,
the same GSAP/CSS preview channel per keypress, and the same path-offset
commit a drag drop uses, so persistence and undo behave identically to a
tiny drag. A key-repeat burst commits once (400ms debounce after the last
arrow, flushed on selection change / pointerdown / unmount) = one undo
entry per burst.
Guards: no text-input/contentEditable focus, no pointer gesture in flight,
every target must support manual offset, plain/Shift arrows only. Caption
editing is safe by construction (CaptionOverlay and DomEditOverlay mount
mutually exclusively). While a nudgeable selection exists, ArrowLeft/Right
frame-stepping yields via a claim gate (both listen on window capture with
mount-order-dependent priority, so defaultPrevented can't arbitrate) —
deliberate behavior change: arrows nudge when an element is selected,
frame-step otherwise.
Three UI polish changes (CapCut as the visual reference):
- Timeline ruler is position:sticky (top of the vertical scroll container,
opaque gutter corner, z above track rows / below the playhead) so the
timestamps never scroll out of view. Scrub math verified: seek hit-testing
is x-only, and sticky keeps its layout slot so the RULER_H-based drop
y-math is unchanged.
- Timeline toolbar slimmed ~36% (44px -> 28px content box: py-2 -> py-0.5,
h-7 -> h-6 buttons, icons 18 -> 16).
- The four main regions (left sidebar, preview, right panel, timeline) read
as distinct panels: darker #050506 shell canvas, rounded borders per
panel, gaps via the existing 8px structural seams. Panel resize verified:
side resize is delta-based; timeline height uses inner padding so the
divider's absolute math is untouched. Preview drops its chrome in
fullscreen.
Product decision (user, 2026-07-09): every add — OS file drop, asset-panel
drop, block drop — lands at the current playhead time, not at the drop x;
only the track comes from the drop position. Reverts the drop-x landing
from c568f748c while keeping its zone- and overlap-aware track resolution,
so external and internal drops still behave identically.
New media dropped on the timeline now lands where you release it (drop-x
for start, drop-y for track) instead of at the playhead — the industry
drag semantic; playhead placement stays for the add-at-playhead
button/shortcut path. Drops past the content end are allowed into the
rendered empty space (the comp grows on commit), matching the clip-drag
bound.
The insert path then runs the new clip through resolveZoneDropPlacement —
the same pure, tested decision a clip DRAG uses (visual lanes top / audio
lanes bottom, no same-track time overlap: relocate to the nearest free
lane in the kind zone, else a fresh track). Because OS file drops, asset-
panel drops, and playhead adds all funnel through handleTimelineAssetDrop,
adding an asset now behaves exactly like moving one, from any source.
Two edge-of-timeline fixes:
1. Drag-to-extend: while a clip is dragged, the rendered timeline keeps a
160px margin past the ghost's end, so holding the pointer in the right
edge auto-scroll zone keeps stepping and the timeline extends at
auto-scroll pace — you can now place a clip farther than the timeline
currently shows. Growth is bounded per frame by AUTO_SCROLL_MAX_SPEED,
stops when the pointer leaves the edge zone, and the extra width
collapses when the drag ends (the comp only grows on commit).
2. Trim limit = available source media only, not the composition length.
Duration is content-driven, so capping a trim at the current comp end
both blocked extending the last clip rightward and collapsed a clip to
the sliver between its start and the comp end after a far move (the
8s → 0.95s audio incident). Images/text extend freely; audio/video stay
capped by remaining source.
Dropping a file (or any edit) reloads the preview iframe, sometimes more
than once (refreshPreviewDocumentVersion staggers extra bumps at 80/300ms).
The saved playhead lived in a consume-once slot: initializeAdapter read
pendingSeekRef and nulled it, so any reload after the first found the slot
empty and seeked to 0 — the "dropped at the playhead, then the playhead
jumped to 0" bug — and a mid-load save could overwrite it with a stale
value ("moves randomly" on asset drops).
Extract resolveReloadSeekTime: pending seek → deep-link seek request →
the store's last known playhead. The last fallback makes the playhead
reload-invariant (an edit never moves it); fresh project loads still start
at 0 because the store resets currentTime on project switch, and content
shrinking past the playhead clamps it (the one sanctioned move).