## What
Removes six Studio feature flags that have been default-`true` for 7+ weeks. Each is reachable under two env names, so this deletes **12 `VITE_STUDIO_*` env vars**:
| Flag constant | Env names removed | Default-on since |
|---|---|---|
| `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`, `VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | 2026-05-12 |
| `STUDIO_INSPECTOR_PANELS_ENABLED` (+ its `STUDIO_PREVIEW_SELECTION_ENABLED` alias) | `VITE_STUDIO_ENABLE_INSPECTOR_PANELS`, `VITE_STUDIO_INSPECTOR_PANELS_ENABLED` | 2026-05-12 |
| `STUDIO_BLOCKS_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_BLOCKS_PANEL`, `VITE_STUDIO_BLOCKS_PANEL_ENABLED` | 2026-05-18 |
| `STUDIO_GSAP_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_GSAP_PANEL`, `VITE_STUDIO_GSAP_PANEL_ENABLED` | 2026-05-28 |
| `STUDIO_KEYFRAMES_ENABLED` | `VITE_STUDIO_ENABLE_KEYFRAMES`, `VITE_STUDIO_KEYFRAMES_ENABLED` | 2026-06-05 |
| `STUDIO_RAZOR_TOOL_ENABLED` | `VITE_STUDIO_ENABLE_RAZOR_TOOL`, `VITE_STUDIO_RAZOR_TOOL_ENABLED` | 2026-06-10 |
## Why
Every one of these shipped as a rollout gate, went to `true`, and then stayed. Because none of them was ever flipped back, the `false` branch was unreachable in practice while still costing a real import, a real conditional, and a real "what happens if this is off?" question at ~90 call sites across 25 files.
The bigger cost is what the dead branch kept alive. Removing the flags also removes the disabled-Studio code paths that only existed to serve them:
- the greyed-out, `disabled`, "Manual editing is temporarily disabled" Inspector button in `StudioHeader` (and the `STUDIO_MANUAL_EDITING_DISABLED_TITLE` constant behind it)
- the inspector-off reset `useEffect` in `useDomSelection`, which force-cleared selection and redirected the right panel to Renders
- three selection kill-switch early-returns in `useDomSelection` (`applyDomSelection`, `handleTimelineElementSelect`, `applyMarqueeSelection`)
- the tab-redirect branch in `normalizeStudioUrlPanelTab`, whose `options.inspectorPanelsEnabled` parameter had no production caller at all (only tests passed it)
## How
No behavior change: every flag was removed by keeping its default-`true` side.
The call-site edits are three mechanical boolean shapes (`X && rest` → `rest`, `rest && X` → `rest`, `!X || rest` → `rest`), applied by script for uniformity. Everything else (ternaries, `if` guards, unreachable blocks, JSX wrappers that had no other condition) was done by hand and the whole diff was read line by line afterwards.
`resolveStudioBooleanEnvFlag` and the `import.meta.env` / `window.__HF_STUDIO_ENV__` plumbing stay: three flags still use them (`STUDIO_FLAT_INSPECTOR_ENABLED`, `STUDIO_SDK_CUTOVER_ENABLED`, `STUDIO_SDK_RESOLVER_SHADOW_ENABLED`). Its unit tests kept their coverage but now exercise a live flag pair instead of retired env names, so no dead `VITE_STUDIO_*` string is left in the repo.
Net **-191 lines** (236 insertions, 427 deletions across 25 files); most insertions are reindentation of JSX that lost a wrapper.
### Deliberately not in scope
Flags authored by other people are untouched, even where they look similarly settled:
- `VITE_STUDIO_ENABLE_FLAT_INSPECTOR` / `VITE_STUDIO_FLAT_INSPECTOR_ENABLED` (default true, but not mine)
- `VITE_STUDIO_SDK_CUTOVER_ENABLED`, `VITE_STUDIO_SDK_CUTOVER_FAMILIES`, `VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (SDK cutover canary, still soaking)
- `VITE_HYPERFRAMES_NO_TELEMETRY`
Mine but genuinely long-lived configuration rather than rollout gates, so they stay: `VITE_STUDIO_DISCOVERY_PORTS`, `VITE_HYPERFRAMES_FEEDBACK_INTERVAL`, `VITE_HYPERFRAMES_NO_FEEDBACK` (a documented user opt-out), plus the `HYPERFRAMES_*` binary paths, API URLs, cache sizes, and timeouts.
`VITE_STUDIO_ENABLE_MOTION_PANEL` / `VITE_STUDIO_MOTION_PANEL_ENABLED` were already retired from production code before this PR; they only survived as placeholder names inside the resolver's unit tests, and this PR swaps those out.
## Test plan
- [x] Unit tests added/updated - dropped the two tests asserting removed flag defaults; retargeted the `resolveStudioBooleanEnvFlag` cases at a live flag pair; updated `studioUrlState` tests for the narrowed `normalizeStudioUrlPanelTab` signature (now also asserts an unknown tab returns `null`).
- [x] Manual testing performed - see below.
- [ ] Documentation updated (if applicable) - not needed; no removed name appears in `docs/`, `skills/`, or `registry/`. (`docs/changelog.mdx` has one historical entry naming `STUDIO_KEYFRAMES_ENABLED`; changelog history is left as written.)
```
packages/studio: bunx vitest run # 280 files, 3116 tests pass, 1 skipped
packages/studio: bunx tsc --noEmit # clean
bun run build # green (all packages)
bunx oxlint <25 changed files> # 0 warnings, 0 errors
bunx oxfmt --check <25 changed files> # clean
```
Two extra checks, because part of this diff was script-generated:
1. Zero references to any removed flag constant or env name remain anywhere outside `docs/changelog.mdx`.
2. Diffed every string literal in each changed non-test file against `origin/main`. The only differences are the intended removals: the 12 env names, `"Manual editing is temporarily disabled"`, the `"cursor-not-allowed …"` disabled class, the 3-column `"1fr 1fr 1fr"` grid, and the `"renders"` redirect literals. No user-facing label, tooltip, or class string changed by accident.
The narrowing this branch adds missed the motion-path overlay, and every
caller that could not narrow fell back to the exact bare class the narrowing
exists to replace.
- motionPathSelection.selectorFor now goes through writeTargetSelector. It
feeds both the geometry read and the "set destination" write, so a class
sibling measured its home off the FIRST sibling and then authored
add-motion-path onto all of them. The toolbar toggle hides when no
one-element form exists rather than arming a press that is dropped.
- The five new-tween writers that fell back to the selection's own selector
now drop the commit instead. A gesture that does not persist reverts on the
next reload; a tween silently aimed at five elements does not.
- tweenTargetsElement only follows the DOM to a target that matches exactly
one element. A target the element merely shares with its siblings is a group
tween, and these callers mutate what they find, so an individual nudge was
rewriting the group's own tween and moving all five.
U3 fixed "add keyframe at playhead" widening a write to every sibling
sharing a class, but wired writeTargetSelector into only two paths. The
same bug was still reachable from the add-animation button, drag, resize,
rotate, gesture recording, and the property panel: each derived its target
from selectorFromSelection, which hands back a bare class for an id-less
element, so one edit authored a tween over all five siblings and the
timeline collapsed their rows into one.
Route every path that authors a NEW tween through the existing ladder:
- ensureElementAddressable now accepts selection.selector only when it
addresses exactly one element, so the id-minting fallback right below it
(previously unreachable whenever any selector was present) does the work.
- gsapDragCommit's five new-tween branches go through one newTweenTarget
helper; instant patches reuse the written target so the runtime moves the
element the source write names.
- useGestureCommit and useAnimatedPropertyCommit keep the existing selector
for matching/retargeting and author new tweens with a separate write
selector.
Retargets of an EXISTING tween are deliberately untouched: they keep
anim.targetSelector, so a tween aimed at a whole group stays aimed at it.
Narrowing the write alone regressed idempotency, verified by test: the
"is there already a write for this element" lookups matched targetSelector
by string, so the next nudge missed the write it had just made and appended
a second, conflicting one. The read half now falls back to the live DOM
(tweenTargetsElement, same contract as getAnimationsForElement), which also
still matches a deliberate group tween.
Tests reproduce each site through a real writer, re-parse with the real
parser, and resolve through resolveSelectorElementIds (what feeds the
keyframe cache and the lanes), plus pins for the new-tween vs
retarget-existing distinction so a future change cannot collapse the two.
Two latent defects in the announcement path this branch adds.
- lanesId was keyed by render row alone, so a second TimelineLanes on the
page (a mini-timeline beside the main one) would mint the same
timeline-lanes-track-0 and every caret's aria-controls would resolve to
whichever instance mounted first. The prefix now comes from useId, with the
colons stripped so the id stays a legal CSS selector.
- trackDisplayNumber returned trackOrder.length + 1 for a key it could not
find, which is indistinguishable from a real row: the label announced a row
the user can see is wrong and nothing upstream could tell it had guessed. It
returns null now, and trackDisplaySuffix drops the number from the label
rather than inventing one.
The timeline track key is a fractional z-order sort key: an expanded
sub-composition child gets `host.track + n / (siblings + 2)`. The undo
history entry for the eye toggle interpolated that key directly, so
hiding an expanded child recorded "Hide track 0.16666666666666666".
Give the key-to-display-row conversion a single owner
(timelineTrackDisplay.ts) and route both the track header labels and the
history label through it, so the two cannot drift apart again. The raw
key still routes the callbacks and lookups that need it.
Adds a regression test that toggles a track keyed 1 / 6; a test on track
0 formats cleanly and proves nothing.
writeTargetSelector returned the selection's bare selector whenever the
structural walk failed, including when a live DOM was there to check
against. An element detached between selecting and committing takes that
path, so the add re-authored the exact `.group` string the function exists
to replace. Return null instead: a failed walk against a live DOM is
evidence, not absence of it. Callers that cannot drop a user edit opt back
in with `?? selectorFromSelection` where the trade is visible.
The replace-with-keyframes paths had the mirror defect. The server deletes
and re-adds the tween, so their target string is a full rewrite, and they
derived it from the selection: promoting a set on a tween already narrowed
to `#scene > div:nth-child(3)` widened it back onto every class sibling,
undoing the narrowing an earlier add had made. They now keep the tween's
own authored target, matching what eight sibling commit modules already do.
"Add keyframe at playhead" on an element with no id authored the bare class
buildStableSelector hands back, so one add on a `.group` wrote
`tl.to(".group", ...)`: a tween that animates all five siblings and that
resolveSelectorElementIds reads back as all five, collapsing their timeline
rows into one. It survived a reload, so the written file stayed un-editable.
writeTargetSelector is the write-side counterpart to selectorFromSelection
(which must keep returning the exact string findTweenAtTime compares against).
It resolves the element's own identity to a selector that addresses exactly
one element: `#id`, else `[data-hf-id="..."]`, else the selection's selector
when it is already unique, else a `:nth-child` path anchored on the nearest
identifiable ancestor (the selector + selectorIndex pair, resolved through the
DOM the index was counted in).
Applied to the two paths that author a NEW tween: the no-animation branch of
useEnableKeyframes and commitKeyframeAtTimeImpl. replace-with-keyframes still
writes the selection's own selector, since retargeting a tween the author
aimed at a whole group is a different decision from adding a keyframe.
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.
Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.
Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.
Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.
Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.
resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
Review follow-ups on the expanded keyframe lanes.
Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
dispatched update-meta, so a rejected write left the diamond parked at its drop
position. `observeGsapMutation` now resolves to whether the mutation landed and
the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
a layer with position AND opacity keyframes kept half of them. It now walks
every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
which picks an arbitrary tween when a target carries several. Match by id first.
Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
selection to its own source keyframe, undoing a retime the user could see. The
revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
rows instead of hardcoding LABEL_COL_W over the canvas.
Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
the clicked-element writes are actually pinned; three assertions that passed
either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
also accepts NaN); ease segments assert their label ORDER, not just that the
three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
An explicit null selection override now aborts the write instead of falling
back to domEditSelection: a caller that resolved a selection for its own
element and found none was committing onto whichever element happened to be
selected. Ease changes and the playhead keyframe toggle resolve the edited
element's animations and selection instead of the current selection's, and
lane header rows follow the real label-column width so a narrowed column no
longer hangs its value readout over the canvas.
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare
`as T`; the gsapShared fixtures only carry the fields under test.
The fallow complexity gate flagged three functions on this branch. Each is
split at its natural seam rather than suppressed: the auto-expand scan moves
out of the effect, the four repeated attribute guards in
nodeMatchesManifestClip collapse into one table-driven check, and the
segment-% interpolation moves out of onPathDown.
Selector reads now go through one inverse of `idSelector`. Every writer emits
`[id="01-hook-hero"]` for an id a `#id` selector can't address, but the readers
still matched `#id` only, so the post-commit keyframe-cache refresh, the AST
load and the remove-all-keyframes clear all silently skipped exactly the ids
`idSelector` was added to support.
A keyframe merged from two tweens with different eases kept whichever ease
iterated last. Readers that don't check `easeAmbiguous` showed a curve from a
different animation than an edit would target, so the ambiguous flag now clears
`ease` instead of leaving an arbitrary one behind.
One tolerance for "the playhead is on this keyframe". The motion-path drag used
0.05% while the toolbar and the playhead apply used 1%, so a drag that landed a
fraction of a percent off an authored waypoint skipped the update-point branch
and appended a near-duplicate. `buildTemporalArcKeyframes` now owns the
invariant and replaces any keyframe inside the tolerance, rather than trusting
each caller's own pre-check.
The pending-retime bookkeeping matches on keyframe identity, not just on
"something is near that percentage" — an evenly spaced row cleared the entry off
an unrelated sibling. The neighbour clamp composes pending destinations in
before sorting, so a second drag can't cross a neighbour that already moved.
Also: `keyframeCache`/`gsapAnimations` setters return the same state for a write
that changes nothing (every no-op re-rendered every subscriber), the auto-expand
set drops clips that left the source so an undo/paste under the same id expands
again, `invalidateGsapCache` has a stable identity instead of re-creating the
whole timeline edit context each render, the studio test hook deletes its window
key rather than leaving it enumerable as undefined, and the past-last-row
extrapolation documents why it uses TRACK_H where the pre-first-row branch uses
row 0's own height.
Covers `idFromSelector` round-trips, the insert boundary band across plain,
expanded and unusable row heights, and the collapsed selection key for a
colon-bearing element id.
The QA fleet's 295 findings were replayed against the merge-base. Most were
pre-existing, but these were caused by this stack:
Deleting one keyframe destroyed the whole tween. The lane-header remove toggle
escalated a flat tween to a whole-animation delete, which took the authored
`tl.to(...)` and its source comment with it on a single click. The base build
posts remove-keyframe and lets the writer refuse it; restore that.
A keyframed layer could not be hidden at all. The visibility eye had moved off
the always-mounted layer row onto a hover-gated property-group row, so it only
existed while the lanes were expanded AND the pointer was over that lane. A
keyboard-only user could reach no eye at all, and its label named a track its
row did not act on. It goes back on the layer row.
A drag from the centre of a clip bar did nothing, because the 16px inline ease
button sits exactly there and swallowed the press. It now lets the press through
to the clip and keeps only the click, dropped if the pointer travelled.
Dragging a diamond onto a neighbour silently discarded the retime. The clamp
bounded the dragged keyframe by the whole merged row, so two animations
colliding at one percentage pinned each other in place and the drag resolved
back to a click. Clamp against the dragged keyframe's own tween instead.
Also: floor the diamond hit box at 12px (the gap-derived size fell to ~7px at
the zoom floor), round the diamond tooltip percentage, and prune the keyframe
caches when a composition switch drops a file from the scan set — each file only
ever cleared its own entries, so the previous composition leaked every element
into both keyframeCache and gsapAnimations, with nothing to evict it.
Validate the parse response before reading `.animations` instead of casting
the JSON blind, and narrow the fetch's return type to the slice callers read.
Route the AST cache load through the shared clip-keyframe and cache-key
helpers so it can't drift from the other writer. Drop the unused numeric
track-count branches from `trackHeights`/`getTimelineCanvasHeight`, and pick
the widest keyframed clip with a reduce so there's no index assertion.
Carries the gsapAnimations/keyframeCache symmetry fix into the extracted
AST loader: an ungrouped tween now lands in both stores, so the collapsed
row and the expanded lanes agree on what a clip animates.
The keyframe cache was fetched for a single source file: the selected
element's, else the active composition. On open nothing is selected, so
a project whose clips live in sub-compositions loaded only index.html
and every property lane rendered empty until a clip was clicked.
Load the cache for each composition file the timeline has rows for, so
keyframed clips are expanded on open as intended. The AST load path
moves to keyframeCacheAstLoad.ts to keep useGsapTweenCache.ts under the
600-line cap.
- revert diamond selection when a rejected retime leaves the source in place
- clear project-local ease focus and expansion on player store reset
- share one static-position-hold predicate across the tween cache
- invalidate the GSAP cache even when a group timing rewrite throws
- stamp each lane keyframe's ease from its own source tween
- memoize property lanes and row offsets so memo'd diamond lanes hold
- use the editable tween duration for drag position commits
- restore the pre-t=0 pad in the all-collapsed content origin
- clamp the drag ghost and drop placeholder to the collapsed clip height
- aria-expanded on the layer disclosure, aria-pressed plus state-specific
labels on the keyframe toggle, 24px chevron targets, focus-visible parity
The diamond context menu still declared `onChangeEase` and `onCopyProperties`
props, and `TimelineOverlays` still threaded `onChangeKeyframeEase` plus a
`keyframeCache` it never read. Nothing on any timeline branch calls them, so
they are removed along with the `onChangeKeyframeEase` callback implementation.
Also from review:
- `deleteSelectedKeyframes` only falls back to the sole keyframed animation when
there is exactly one. A collapsed selection key carries no animation id, so
taking the first of several deleted an arbitrary tween's keyframe.
- The duration-less retime test asserts the real 87.601% instead of
`expect.any(Number)`, so a wrong timing basis fails it.
- `Timeline` wires the keyframe handlers' `onSelectSegment` through to the
diamonds; it was built and then dropped, so segment ease selection never fired.
- The flat text section arms auto-focus in state rather than reading and
clearing a ref during render, which Strict Mode's double render swallowed.
Two sibling call paths still answered from GSAP's 0.5s default while the
toggle path had moved to the clip-wide fallback.
isPlayheadWithinTween now takes the selection, so the toolbar stops
promising "extends animation" on a duration-less tween whose click actually
toggles an interior keyframe. The arc drag commit resolves its replacement
duration the same way its click-path sibling does, instead of authoring
duration 0.5 and collapsing the arc window.
The flat text section drops its two marker-clearing effects: the autofocus
marker is a ref now, read and cleared by the render that consumes it.
Each timeline keyframe callback now reads and writes through the SAME
element: the clicked element's animations resolve the target, its selection
commits the mutation, and its animation computes the playhead percentage.
onMoveKeyframeToPlayhead previously took the percentage from the clicked
element and the animation plus selection from the current one, so a context
menu on a non-selected diamond retimed against one tween and wrote into
another file. onChangeKeyframeEase and onToggleKeyframeAtPlayhead had the
same split. An explicit null selection override now aborts the write instead
of falling back to the current selection.
The timeline-element animation lookup rebuilt the three cache-key variants
by hand. elementCacheKeys already owns that list for the writers, so this
reader takes it from there instead of drifting from it.
The local extractIdFromSelector duplicated the `#id`-only regex that
idFromSelector replaced, so both DOM-less paths in
resolveSelectorElementIds (no-iframe fallback and querySelectorAll-throw
recovery) read no id at all for the bracketed `[id="..."]` form writers
emit for CSS-unsafe ids. Deleted the duplicate and imported the shared
reader; both forms now resolve.
The R1/R3 residuals on this PR were fixed at the top of the stack, so they
only cleared once every branch above landed. They belong here, next to the
code they correct:
- `idFromSelector` inverts `idSelector` for both regex readers, so the
post-commit cache refresh stops skipping the CSS-unsafe ids `idSelector`
exists to support.
- `deduplicateKeyframes` drops `ease` when it is ambiguous; the flag was the
only honest answer and the last-writer-wins curve belonged to an arbitrary
colliding tween.
- `isStaticPositionHold` is now the single owner of the hold skip. The
`sourceAnimations` filter and the `allKeyframes` filter had diverged on
whether `immediateRender` counts as a property.
- The keyframe-cache setters no-op when the write changes nothing, instead of
handing every subscriber a fresh Map.
- `reset()` clears `focusedEaseSegment`.
- The test hook `delete`s its window key rather than setting it to undefined,
so feature detection still works.
- The `toClipKeyframes` fixture uses `as unknown as T` with the justification
CONTRIBUTING.md asks for.
R3 review follow-ups on the keyframe cache:
- clearKeyframeCacheForFile collected ids from the index.html alias prefix
too, so a re-scan of one composition file wiped rows a sibling file had
just written (several files re-scan concurrently). Only the file's own
prefixed keys name the ids now; clearKeyframeCacheForElement still takes
the alias and bare key with them.
- toClipKeyframes fell back to a fixed 1s tween duration, which put a
duration-less tween's keyframes at a percentage no edit path agreed with.
It now spans the clip, matching resolveEditableTweenDuration.
- collectAnimatableKeyframeProperties takes `object` so call sites drop
their `as Record<string, unknown>` casts.
Regression tests cover both fixes.
Each keyframe-cache writer re-derived a clip-relative percentage inline, and the
post-commit writer rounded to 0.1% while the others used 0.001%. Selection keys
embed that number, so a commit-time rewrite could orphan a live key.
toClipPercentage owns the rounding, toClipKeyframes owns the whole row (percentage
plus the tween percentage and animation identity the lanes read), and the parsed
write reuses elementCacheKeys instead of open-coding the three key variants.
An ungrouped tween (mixed property groups classify to propertyGroup
undefined) fed keyframeCache but was skipped by every gsapAnimations
writer, so the collapsed row drew diamonds the expanded lanes had no
source animation to render. Drop the property-group gate at all three
writers; lane consumers already filter by group.
Also route the same-percentage merge in updateKeyframeCacheFromParsed
through deduplicateKeyframes so the easeAmbiguous rule has one owner.
slideshowIslandRegex scanned the full file content on every editingFile
change even for the common non-slideshow case. Gate it behind a plain
substring check on SLIDESHOW_ISLAND_TYPE first — cheap, and avoids the
full-content RegExp pass for files that plainly have no island.
Added a test for the still-open behavior this preserves: a malformed
island (invalid JSON) still trips the substring check and the regex,
so the tab stays discoverable rather than silently disappearing.
The Slideshow tab rendered unconditionally, showing the branching editor
for any composition regardless of whether it was actually a slideshow —
a plain video comp offered a tab with nothing meaningful to edit.
Gate it on the composition carrying the slideshow JSON island
(<script type="application/hyperframes-slideshow+json">), the same
definitive marker the CLI's `present` command already requires (it
refuses to run without one). Presence-only, not full manifest
validation, so a malformed island still surfaces the tab rather than
disappearing entirely. Also bounce rightPanelTab off "slideshow" to
"renders" if the active composition stops being a slideshow while that
tab is open (e.g. switching files), since its button would otherwise
vanish with no way back to it.
Extracted the gating + scene-list derivation into useSlideshowTabState
to keep StudioRightPanel.tsx under the 600-LOC gate.
Reported as "template variables are broken": binding an element's field to a
variable via the flat inspector's "◇ var" promote chip (or editing an
already-bound field's value) wrote the correct bytes to disk, but the
Variables tab kept showing the pre-edit value until the whole Studio page
was hard-reloaded.
Root cause: DesignPanelPromoteProvider deliberately opens its OWN SDK
session (`useSdkSession(projectId, selection.sourceFile ?? activeCompPath)`)
so that promoting inside a sub-composition binds the variable in the
sub-comp's own file, not the host's. For the common case — a top-level
element, same file as `activeCompPath` — this session is a SEPARATE
in-memory `Composition` instance from the shared one `VariablesPanel`
(Variables tab, Slideshow, etc.) reads. A persist through the promote
provider's session never fires the shared session's own "change" event.
Worse, the shared session's file-change listener runs
`isSelfWriteEcho(path, content)` to decide whether to reload — but
`sdkSelfWriteRegistry` is keyed by file path only, not by session instance
(its own doc comment assumes "the studio process has a single SDK session
lifecycle at a time"). It sees the promote provider's write registered
under the same path and concludes it's its own echo, permanently
suppressing the reload it actually needs.
Threaded `forceReloadSdkSession` (the same mechanism every other
server-side-write path in Studio already uses for exactly this "resync
after a write I didn't make myself" case) from App.tsx through
StudioRightPanel into DesignPanelPromoteProvider, and call it after every
successful promote/setDefault persist — unconditionally, not gated on the
promote target matching activeCompPath, since re-opening a file that
didn't change is a harmless no-op re-parse and a path-equality guard here
already produced one subtly wrong comparison (activeCompPath can be null
while the shared session still defaults to "index.html") before landing on
this simpler version. Verified live: editing a variable-bound field's
value now updates the Variables tab immediately, no reload required.
App.tsx crossed the 600-line file-size gate after threading the new prop;
extracted the tiny handleAddAssetAtPlayhead wrapper into its own
useAddAssetAtPlayhead hook (with a regression test) to bring it back under.
Full studio suite (2639 tests) green against a fresh main; typecheck/
oxlint/oxfmt clean.
Review feedback on #2497 (Rames D Jusso) found a real gap: the exclusivity
this PR introduced only applied to the direct in-panel tab click, which
calls setExclusiveRightInspectorPane. Every OTHER caller that reaches
setRightPanelTab("design"|"layers") — element select (useDomSelection.ts),
closing block-params (App.tsx), the header Inspector button
(StudioHeader.tsx), and even this PR's own "!inspectorTabActive" entry
branch in handleInspectorPaneButtonClick — went through
trackedSetRightPanelTab's old unconditional additive
`{...panes, [tab]: true}`, reproducing the exact "both tabs highlight, only
one renders" bug this PR claims to fix. Confirmed via the reviewer's traced
repro: fresh boot, click Layers tab while no inspector tab is yet active →
rightInspectorPanes ends up {design:true, layers:true}.
Fixed at the reviewer's preferred choke point: trackedSetRightPanelTab
itself is now flat-aware, applying the same exclusive-radio update
setExclusiveRightInspectorPane does whenever STUDIO_FLAT_INSPECTOR_ENABLED
is on, falling back to the legacy additive update otherwise. This closes
the gap for every current and future caller of setRightPanelTab, not just
the one call site this PR touched.
New usePanelLayout.test.ts cases pin both directions: setRightPanelTab
stays additive under flat=off (legacy split-view behavior unchanged), and
enforces exclusivity under flat=on even when called directly (not through
the tab-click handler) — using the vi.doMock(manualEditingAvailability)
pattern already established in PropertyPanel.test.tsx for flag-dependent
module state.
Full studio suite (2643 tests) green; typecheck/oxlint/oxfmt clean.
The flat inspector split Layers and Design into a vertically-resizable
stacked pair whenever both panes were toggled on, mirroring the legacy
panel's layout. For the flat redesign this reads as two competing panels
crammed into one column; Layers should always render full-height by
itself there instead.
Gate the split-view branch behind !STUDIO_FLAT_INSPECTOR_ENABLED so it
still applies to the legacy panel, and fall through to Layers rendering
alone (the existing `layersPaneOpen` branch already does this — it just
never got reached previously because the split check ran first).
Also added setExclusiveRightInspectorPane (radio-style: selecting one pane
turns the other off) and use it for the Design/Layers tab clicks under the
flat flag, since leaving both panes independently toggleable would highlight
both tabs as "active" while only one actually renders.
New usePanelLayout.test.ts covers both the existing toggle behavior and the
new exclusive variant. Full studio suite (2634 tests) green; typecheck/
oxlint/oxfmt clean.