* perf(studio): stop the manual-edit reapply loop from running while paused
* fix(studio): judge nested timelines by their parent, not the player alone
* fix(studio): bound the paused-ancestor walk and ignore unknown parents
* style(studio): format the manual-edit tests
* fix(studio): timeline clips keep their pictures while you scroll
With row virtualization on, the timeline dropped every clip's picture for the length of a scroll
gesture and put it back 100ms after the gesture stopped, so a zoomed-in timeline showed grey boxes
the whole time a person was moving through it. Clip content now stays mounted while scrolling;
pictures already loaded come straight from the thumbnail cache.
* perf(studio): clips that scroll into view get their pictures when the scroll stops
Keeping every clip's content mounted through a scroll made each jump of the 50,000-clip viewport
gate mount and lay out a screenful of new thumbnails, a 90 to 130 ms long task per run where main
has none. A clip's content now mounts only while the timeline is at rest and then stays through
later scrolls, so a picture already on screen never blanks and a scroll never mounts new ones.
* perf(studio): refresh only the edited scene's thumbnails and let the preview load first
One scene edit regenerated every sidebar and timeline thumbnail, and those renders starved the new preview's requests on the Studio server. The thumbnail cache now keys on what a composition renders from, the server names the compositions a write affects, only those thumbnails refresh, and composition thumbnails wait while a shadow preview reload is in flight.
* test(studio): pin scene-scoped thumbnail reuse and the preview-reload hold
Memoizes the scoped thumbnail signature on the project signature so requests keep reusing the adapter's cached one, keeps the fresh-signature check that catches a write the watcher has not reported yet, and adds tests for sibling-scene reuse, root-head invalidation, the affected-composition set, per-path revisions and the scheduler hold.
* refactor(studio): share the file-change payload guard
* fix(studio): match thumbnail revisions for scenes mounted with a ./ path
* perf(studio): keep signature cache keys constant-size
* style(studio): format thumbnail-scope changes
* docs(studio): trim thumbnail input comments
Every timeline render re-sorted every clip in the project once per visible row to find transition
seams, then kept the row's own. On the 50,000-clip viewport fixture that sort was the largest CPU
cost in a scroll. Seams are now derived once per change of the clip set, grouped by track, and each
row looks up its own. Pairing within a track also fixes a missing transition badge when a clip on
another track starts between the transition's two clips.
Two residuals from the cutover-dashboard work, both found in the data the
first pass shipped (0.8.61/0.8.62).
`absent_or_empty` was one label over two different failures: the route
answers `content: ""` both from the `optional=1` shim (nothing resolved at
that path) and for a real 0-byte file. The route now marks the shim with
`missing: true`, and the read path with `missing: false` so the field's
PRESENCE is what tells the client this server draws the distinction — a new
server's empty file would otherwise look exactly like either case from an
old one. Client splits them into `absent` and `empty_file`; a server without
the field still reports `absent_or_empty`, so the existing series stays
honest instead of silently folding into one of the new ones.
Why it is worth splitting: on 0.8.62, 75 tabs hit this class and not one of
them ever landed an SDK edit afterwards — it is terminal for the tab, while
the tab keeps playing back and navigating, so nothing surfaces to the user.
Every measured case with a loaded tree carries `path_in_tree: true`: a file
the tree lists and this read cannot get. Which of the two causes that is
decides the fix, and until now the event could not say.
`res.json()` at the end of the read was unguarded, so a 200 carrying HTML —
an SPA fallback or a proxy answering in the route's place — rejected out of
the function entirely and the effect's outer catch filed it as
`stage: "open"`, which asserts the COMPOSITION failed to parse. Observed in
production on 0.8.62: `Unexpected token '<', "<!-- /*!"... is not valid
JSON`. That is the third label to make the same wrong claim, after the
fetch-rejection split in #4240 and #4269. Now `reason: "invalid_json"` with
the response's content-type.
`path_in_tree` now rides all three empty-read reasons, not just the combined
one, so the split keeps the signal that justified it.
Tests: 4 new (absent / empty_file / combined-label fallback / non-JSON 200)
plus a server test pinning `missing` on both answers. 1172 tests green
across studio hooks + studio-server routes; oxlint, oxfmt, tsc clean.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(studio): composition and text clips show their frame at full strength
CompositionThumbnail drew its frames at 0.7 opacity with a lighten blend, the only timeline
picture dimmed that way; on a light theme the frame nearly vanished into the clip. Opacity and
blend are now theme tokens, untinted by default like video and image clips.
* fix(studio): touching clips show a hairline seam where they meet
Two clips that meet end to start used to read as one long bar. Each exact join on a row now
gets a 1px line in the row colour, drawn over the clips. Clip positions, widths and keyframe
diamonds do not move, so the timeline still tells the truth about time.
* fix(studio): hide clip join seams while a clip is being moved
A moved clip is drawn by the drag overlay, not at its element position, so its join line stayed
behind at the old boundary until the drop. Rows draw no join lines during a move or multi-clip
drag; they come back on drop. The join math now reuses the lane helpers for sort order and the
touching tolerance.
* refactor(studio): drop a multi-drag check the move-drag check already covers
A multi-clip drag preview only exists once a move drag has started, so the join lines only need
the move-drag check.
@hyperframes/studio's public index doesn't export the clip-content
thumbnail primitives, forcing a deep import. Desktop's Canva timeline
lane needs to draw audio waveforms and image thumbnails from a host
app without reaching into the package's internals.
Adds a test that mounts each export through the package name the way
a host app would (not a relative import), so a future removal or
accidental un-export fails a test instead of a downstream deep-import
workaround.
* refactor(studio): keep legacy fixture state out of rendered rows
* fix(studio): keep timeline keyboard actor mounted
* refactor(studio): remove expansion telemetry and prove stable nested rows
* test(studio): type nested timeline fixture broadly
* test(studio): remove expansion lane assertions
* test(studio): assert nested row identity by key
* fix(studio): satisfy removal and format guards
* fix(studio): clear fallow findings in timeline cleanup
* test(studio): update timeline removal expectations
* fix(studio): remove stale timeline test helpers
* fix(studio): keep automation lanes mounted without disclosure
* refactor(studio): keep audio lanes while removing clip expansion
* fix(studio): restore audio disclosure state
* test(studio): remove obsolete expanded fixture assertions
* fix(studio): resolve provider rebase without clip auto expansion
* fix(studio): remove inline keyframe expansion rows
* fix(studio): restore automation disclosure state
* fix(studio): remove stale timeline model inputs
* fix(studio): pass animation state to timeline layout
* fix(studio): remove stale expansion tests and helpers
* test(studio): cover shared audio lane labels
* fix(studio): remove unused keyframe callback plumbing
* fix(studio): clear rebase lint leftovers
* fix(studio): remove unused timeline header export
* style(studio): format timeline cleanup
* fix(studio): restore composable timeline exports
* fix(studio): keep audio lanes open across selection
* fix(studio): align timeline lane test contract
* fix(studio): remove stale lane test import
* fix(studio): align track header test callback
* fix(studio): remove stale track header test prop
* test(studio): preserve ruler half pixel alignment
* test(studio): keep audio lane fixtures drawable
* test(studio): assert shared lane owner label
* fix(studio): align audio lane expansion with layout state
* test(studio): use the audio lane owner label
* fix(studio): unify audio lane expansion state
* fix(studio): align automation lane labels
* fix(studio): remove stale lane height import
* test(studio): assert automation labels against the real curve, not a formula
The previous test recomputed the expected top with the same formula
the fix uses, so a regression in TimelineAutomationLaneSlot's own
offset would still read green. Moved it to TimelineLanes.test.tsx,
which mounts both the header and the canvas, and compares their
rendered [data-automation-lane-label]/[data-automation-lane] tops
directly.
* revert(studio): restore the keyframe-row feature from main
Miguel: keep the per-property keyframe rows (diamonds, prev/next
arrows, disclosure, auto-open) exactly as main. This PR's real scope
is removing dead expandedParentStart consumers, not the keyframe-row
UX.
* refactor(studio): remove dead expanded-child timeline code
expandedParentStart is never set anywhere on main (the inline
sub-composition child-row expansion that produced it was already
removed in #4132/7797c5ead). Delete its now-dead consumers: the
TimelinePane move/resize/delete/split rebase wrappers, the ZMirror
call site that routed through them, the timelineDragLanding topology
guards, isMainTrackElement's expanded-child exclusion, laneGapFloor's
host-window-start math, and the paired expanded-clip-edit telemetry.
Keeps every keyframe-row feature (diamonds, prev/next arrows,
disclosure, auto-open, telemetry) untouched.
The error handler called cleanup(), which sets video.src = "" and calls
load(). Clearing src itself fires another error event, re-entering the
handler at event-loop speed — every pass allocating a React update via
setFailed(true). The seeked path triggers the same loop: its cleanup
provokes the synthetic error right after a successful frame extract.
Detach the error listener inside cleanup() and ignore the synthetic
empty-src error. Instrumentation on a real session measured ~475k src
assignments/second and ~8.6 MB/s of update allocations before the fix.
Fixes#4036
Co-authored-by: CasbaL <26603379+CasbaL@users.noreply.github.com>
The shadow reload promotes on frame-driven readiness (the player's
paint-and-idle, then two requestAnimationFrames) but failed on a
wall-clock 15s setTimeout. A hidden tab renders no frames, so every
external edit made while Studio was in the background failed with
"it took too long to load", the toasts stacked, and the stale preview
stayed up even after returning to the tab.
The budget now only runs while the document is visible: hiding the tab
clears the timer, and showing it again restarts the full budget for the
pending shadow, which then promotes normally.
Fixes#4290
— Rames Jusso
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(studio): render geometry-sized video filmstrips
* fix(studio): keep poster tier during timeline scroll
* fix(studio): cap rendered thumbnail tiles
* chore(studio): refresh filmstrip checks
* chore(studio): refresh filmstrip captures
* chore(studio): refresh four-times captures
* fix(studio): stop capping filmstrip render tiles at 33
computeThumbnailStrip no longer clamps frameCount; the decode-side cap already lives in quantizeThumbnailFrameCount. VideoThumbnail only acquires the rich filmstrip lease when requestFrameCount > 1, so a narrow clip issues one decode job instead of two identical ones. Deletes the unreachable duplicate defaults in createVideoThumbnailRequest.
* style(studio): format filmstrip changes
Implements the fixes from the 2026-09-21 cutover dashboard read
(plans/sdk/2026-09-21-cutover-dashboard-fixes-plan.md):
- sdk_cutover_failed now carries `family` and `error_kind`
(network/conflict/http/sdk). The one production gate event was a raw
`TypeError: Failed to fetch` from an unwrapped fetch (readProjectFile /
writeProjectFile's preflight), which an instanceof-only classifier would
still file as "sdk" and trip the rollback gate — added a message-based
fallback for the browser's fetch-rejection strings.
- studio:sdk_session_unavailable's `network` reason now carries `elapsed_ms`
and `hidden` (document.visibilityState at rejection), separating a
near-instant policy block (CSP/PNA/extension) from a dropped connection
the tab or server going away mid-flight.
- The ownership-race branch in useSdkSession's open effect now emits
sdk_session_superseded instead of sdk_session_unavailable{stage:ownership}
— it's a benign race (identity moved on mid-open), not a broken
precondition, and was being counted as one on the dashboard.
- Root-caused and fixed the read-404 "storm": /api/events (CLI host) and
the vite-dev hot-reload channel are one connection per SERVER, not per
project. A stale tab left open after `hyperframes preview` restarts on a
different folder (same port) received every save the CURRENT project
made, reloading its preview (which then 404s) and re-reading its own
composition on each one. Both transports now carry `projectId`; the
client-side coordinator drops a delivery whose projectId doesn't match
the tab's (absent field still passes, for one release of skew).
- studio-server's file routes report `why` on 403/404: `project_dir_missing`
(new — the CLI host's static resolveProject kept answering for a project
whose folder was renamed/deleted under a running server, which used to
fail closed as an indistinguishable path-traversal 403) vs `nul` vs
`outside_project`.
- absent_or_empty reads now carry `path_in_tree` (against the caller's
loaded file tree). Instrumentation only, per the graveyard page
(clearing-activecomppath-on-tree-absence) that refuted the "clear
activeCompPath" fix this was the proposed next step for.
Tests: unit coverage added for every change (sdkCutover.test.ts,
useSdkSession.lifecycle.test.tsx, useExternalFileChangeCoordinator.test.tsx,
studioServer.test.ts, files.pathSafety.test.ts). oxlint/oxfmt clean,
tsc --noEmit clean on studio/studio-server/cli.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(studio): timeline clips take the designed material
Picture clips use an 8px radius and audio clips a 21px pill.
Clips sit one step above the lane. The label is a chip that
drops under 60px. Selection keeps the inset outline plus a
1px inner highlight. Hover background is the only transition.
* style(studio): format timeline clip shadow
* fix(studio): restore handle-bar transition and de-pin box-shadow test
* feat(studio): menus and popovers paint instantly
Menus and popovers open and close with no fade. Hover colour is unchanged.
* fix(studio): fade menus in from a visible shape
Menus, popovers, and dropdowns start at opacity 0.8 and scale 0.97, then settle in 120ms. They close in 90ms. Tooltips use the same shape in 100ms.
* feat(studio): audio meter faders for groups and master
* fix(studio): meter's mid band uses amber-500, the theme's actual token
yellow-500 was reset to nothing by palette-reset.css (--color-*: initial)
and never redefined in theme.css, so the mask never revealed any color
for -6 to -3dB; the token gate test already catches this and was red.
* refactor(studio): rename YELLOW_AT to AMBER_AT to match the amber token
* fix(studio): fader nudge and aria-valuenow match the visual dB position
* refactor(studio): use core StereoLevel for meter channel shape
* fix(core): emit levelTap in the packed core build
* fix(studio): name the preview fader Monitor and share clampNumber
* fix(studio): split the meter rAF tick and cover its branches
* style(studio): oxfmt AudioMeterStrip tests
* fix(studio): preserve authored gain in audio meter faders
* fix(studio): make timeline audio meters opt-in
* feat(studio): typed audio values, clip fades, audible video drops
Audio rows in the media inspector (Volume, Speed, Media start) take a
typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter.
Speed presets are hidden on <audio> clips.
Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the
preview runtime (per-tick gain), the render mixer (afade after volume) and
Studio (Fade in / Fade out rows, and drag dots at the top corners of audio
clips and audible videos). Linear on both sides so preview and render
agree; fades that outrun the clip scale to meet inside it.
Dropped videos with an audio stream now land with data-has-audio="true"
instead of muted, so the mixer keeps their sound. The media metadata
endpoint reports hasAudio from the ffprobe call it already makes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(audio): one owner for fades, dB, and afade-after-volume
Illegal fade values become no-fade at fadeGain; typed inspector input
still returns null so the readout stays red. Master and group mixes
share one filter chain with volume then afade. Fade-out stays anchored
to the trimmed clip end.
* fix(studio): share audio helpers and bound typed media values
* fix(studio): reserve sibling fade duration when editing
---------
Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* feat(studio): audio level meters at the timeline's right edge
* test(core): cover metering started before the audio context finishes init
* fix(studio): keep meter follow after a dead preview and start bars at silence
* fix(studio): keep the 0 dB peak tick inside the meter
* fix(studio): keep the 0 dB peak tick visible without calc()
* fix(core): tap meters on the program bus, mute on a monitor gain
* style(core): wrap applyMasterGain to satisfy oxfmt
* fix(studio): report a rejected read as a network reason, not an open failure
`stage: "open"` is documented as `openComposition` threw — an unparseable
composition, or OOM. Every event in that bucket on 0.8.56 and 0.8.57 carries a
fetch-rejection message instead: "Failed to fetch" (28 users / 11 users), "Load
failed", "NetworkError when attempting to fetch resource.".
The cause is one missing catch. `readProjectFileOptional` awaits `fetch` with no
try/catch, so a rejected request escapes the function and is caught by the
effect's outer `.catch` — the handler written for `openComposition`. The read
path has a five-member reason union precisely so this class of failure is
diagnosable, and the one case that never produces a response was the case
outside it.
That made the largest SDK-session failure class in Studio (2.9% of session users
on 0.8.56, 2.3% on 0.8.57) unaddressable: you cannot fix a network failure while
it is reported as a composition that failed to parse.
Now `readProjectFileOptional` catches its own rejection and returns
`reason: "network"`, so it lands in `stage: read` with everything else, and
`stage: open` means only what its comment says.
Telemetry only. Nothing branches on the stage and no user-facing behaviour
changes. Fifth split of this shape, after `ineligible_operation` (v0.8.49),
`stage: read` (v0.8.51) and `block_detail` (#4214).
Co-Authored-By: Claude Code <noreply@anthropic.com>
* test(docs): point the catalog tab-row test at the page gutter #4238 moved the inset to
`catalog-detail.test.ts` asserted the exact strings that #4238 deleted:
`.hf-ve-tabs-row { margin: 20px 0 0 16px; }` and `.hf-ve-body { padding: 2rem 0 0 16px; }`.
That PR removed both insets deliberately — its own comment says "the page gutter is
the page's job, and a second 16px here put everything below the stage on a
different left edge from the title, install block and stage above it" — and
restored the gutter on `#content-area` in `docs/custom.css` instead. The test was
left asserting the old mechanism, so main has been red on it since, unnoticed
because the `Test` workflow does not run for a docs-only change.
The invariant did not change: this content is still inset from the page edge. Only
the mechanism did, so the test now asserts both halves of it — the two rules carry
no inset of their own, and the gutter standing in for them is still present, at both
its values. Asserting only the first would keep passing if the gutter were deleted
outright, which is the regression the test exists to catch; both mutations were
checked and both fail.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
A tab whose project the running Studio cannot resolve failed every composition
read with a 404 and said nothing. `useSdkSession` recorded the failure to
telemetry and returned, so the editor looked normal while every edit silently
took the server path instead of the SDK — nothing landed, and nothing said
why. The `out-fb1` timeline sat in that state for an hour and a half, through
a reload that passed mount validation.
The banner names both sides: "This Studio is serving X. This tab was opened
for Y." That is the whole truth in the host that produces these 404s. The
CLI-embedded Studio serves exactly one project, and `hyperframes preview`
reuses port 3002 when the folder matches, so starting it on a different
folder takes the port out from under an open tab — and the project the old
tab was pointed at is untouched on disk. "No longer available" would be
false: nothing was lost, and the same timeline shows the project return.
With several projects served (the Vite host) a rename cannot be told from a
deletion, so that wording claims no cause.
`reportReadFailure` answers which project a failed read identifies. Only a 404
does; a 5xx, a dropped request, an unexpected body or an empty file say nothing
about which project the server serves, so none of them claim it.
Also fixes a smaller lie on the same path: `useFileTree` parsed the 404's JSON
body without checking `r.ok`, so the failure took the success branch and
recorded an empty tree — and an empty provenance snapshot for a project that
was never read — which then travelled out in bug reports.
The 404s continue; this ends the silent part, not the failure.
Co-authored-by: Claude Code <noreply@anthropic.com>
The mount path has validated its hash since v0.8.52. The hashchange path did
not — it adopted whatever id the URL offered, so changing projects walked
straight around the mount fix and re-armed the same failure: every later
/api/projects/<id>/... request 404s for the life of the tab, including the
composition read that opens the SDK session.
Same tri-state as mount: ok adopts, unknown adopts (a transient check failure
must not rewrite a hash that is probably fine), missing falls back.
Three details that are not obvious from the diff:
- **The race guard reads the hash, not a counter.** This effect re-registers on
every projectId change, so a `generation` in that closure resets and an
in-flight verdict from the previous registration would read as current —
adopting a project the user already navigated away from. The hash cannot go
stale, so it is the ground truth to compare against after the await.
- **The fallback re-fetches the project list.** tryConnect has `data.projects`
in scope; this path does not, and caching the mount value would be stale in
exactly the long-lived-tab case this exists for.
- **Both rewrites use replaceState.** `location.hash =` pushes, leaving the dead
id one Back away — and Back would then re-validate, fall back, and push again,
forever. The mount fallback had this bug too; fixed here as well.
Also emits the hashchange verdict so the `unknown` branch — which deliberately
keeps the hash and is invisible from outside — is countable.
Verified: 7 mutants, 7 caught, including adopting without validating, collapsing
unknown into missing, trusting the verdict over the live hash, memoizing the
fallback list, and pushing on either rewrite. Full studio suite 5,323 passed.
Co-authored-by: Claude Code <noreply@anthropic.com>
Two telemetry gaps, both pure instrumentation with no behaviour change.
`tab_id` — distinct_id identifies a browser, not a page. Several
investigations stalled on "two tabs, or one tab switching?", because a burst
of events alternating between two project hashes is indistinguishable from a
user moving between projects, and `session_start` fires once per page load so
counting it cannot tell them apart either. One id per page load, from one
shared module, attached by BOTH transports (`studio:*` and `studio_*`) — those
two families already drifted once on how `agent_runtime` is encoded.
Deliberately not persisted. Storage would survive a reload and quietly turn
this into a tab-session id, changing what every query means, and blocked or
cleared storage would make the property intermittently absent — which reads as
"old client" rather than "storage unavailable".
`project_hash_validated` — a tab that keeps its hash because the check itself
failed ("unknown") is currently indistinguishable in production from one that
never ran the check. Different causes, different fixes, one silent bucket.
Emits `stage` (mount | hashchange), `outcome` (ok | missing | unknown), and
the HTTP status when the server answered.
Verified: 8 mutants, 8 caught — including dropping tab_id from either
transport, regenerating it per read, persisting it to storage, emptying it in
the no-DOM fallback, and collapsing "unknown" into "missing". Full studio
suite 5,314 passed.
Co-authored-by: Claude Code <noreply@anthropic.com>
* fix(studio): reloading the preview swaps in a ready copy instead of blanking
* fix(player): report painted once assets are ready and the loader has faded out
assetsready fires when the loader fade starts, so a consumer promoting on it can
expose a raised or fading panel. painted fires once per document load after the
fade completes, with a getter for late listeners; it resets on each load.
* fix(studio): promote the shadow preview only when painted, under one wait budget
Studio waited for ready plus two frames, which can land while the player's loader
panel is still up or fading. It now also waits for the player's painted event.
The shadow no longer arms the 5s adapter give-up, so a slow but live load fails
only at the single 15s budget. Set-destination availability follows the live
iframe across a swap.
* feat(studio): edit a clip's speed ramp with a rate lane and presets
* fix(studio): keep the live preview attribute in sync with what gets persisted
* fix(cli): snapshot picks the source frame through a rate lane and up to 10x
hyperframes snapshot clamped the constant playback rate at 5 and ignored a
rate lane in data-automation, so a sped-up or ramped clip showed the wrong
frame. It now reads the rate through the runtime's own reader and maps time
with the shared source-time function.
* docs(skills): teach speed ramps as a rate lane in one reference page
Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10
and the 'speed ramps are not supported' lines in the skills, and regenerates
the skills manifest.
* docs(skills): say what fixes a ramped clip's length and how the render approximates pitch
* fix(studio): hide the speed preset until the clip has a known duration
A preset on a clip with no data-duration was stretched over a made-up one
second. The panel now offers presets only when the duration is known.
* docs(skills): fix the last stale ramp line in the editing recipes and the slice-cap wording
* feat(studio): a host can make the preview read-only
Gates the shared geometry commit funnel (offset/size/rotation), so the
inspector's manual X/Y/W/H/rotation fields refuse the same as canvas
drag/resize/rotate. The flat inspector's fields render disabled with a
host-supplied reason. Fixes a hooks-order crash on deselect found in review.
* refactor(studio): derive the read-only preview flag from context, not a synced store
EditorShell synced its readOnlyPreview prop into a zustand store via a raw
useEffect behind an eslint-disable of no-restricted-syntax. Replace it with
a PreviewReadOnlyProvider context computed fresh from the prop every render:
Tier-1 consumers (genuine EditorShell descendants) read it via useContext;
Tier-2 consumers (useAppHotkeys, useGestureCommit, the
useDomEditSession/useDomEditCommits/useDomGeometryCommits chain), which run
in App.tsx above EditorShell, take it as an explicit parameter threaded from
StudioApp's own prop instead. Adds a host-mount test proving EditorShell
gates its descendants from its own prop with no StudioApp/App.tsx involved.
* fix(studio): keep preview hotkeys context explicit
* fix(studio): close read-only interaction escapes
* fix(studio): preserve timeline ownership in read-only preview
* fix(studio): rollback read-only preview work
* fix(studio): keep hotkey ownership on the active surface
* fix(studio): format preview overlay and strengthen split test
* fix(studio): clear preview gate findings
* refactor(studio): share preview test fixtures
* feat(studio): the timeline's colours read CSS custom properties instead of literals
The timeline's surfaces, hairlines, ruler text and clip colours were hard-coded
hex/rgba, so a host with a light chrome saw a black slab. The 24 values move
into new --timeline-* tokens in theme.css (the same mechanism Button and Menu
already use), holding today's exact values, so a host themes the timeline by
overriding a token instead of the object staying opaque.
* fix(studio): route timeline clip colours through theme tokens
* chore(studio): bank the colour-literal baseline
* fix(studio): preserve timeline clip token defaults
* fix(studio): increase default timeline clip contrast
* fix(studio): keep timeline hover brighter
* test(studio): stabilize property panel flag coverage
`edit_blocked` is the largest interaction failure in Studio: 575 users in 14
days, 17% of everyone who touches an editing control, and 41% of them are
blocked on their first or second attempt. Only 30% ever commit anything
afterwards.
Almost all of it is one message. "This animation is computed at runtime. Edit
the animation in the Code tab." covers ~460 of those users and is thrown from
seven different places — runtime-dynamic provenance, unresolved keyframes, an
unresolved selector, a geometry commit over unresolved source, and three
live-tween-without-a-source-match cases across position and rotation. The
telemetry carried only that string, so the classes were indistinguishable.
The copy is also only literally true for the first. The rest are parser or
source-matching limits where the animation may be plain authored source, and
we send the user to the Code tab to rewrite something that could be fine.
Adds a `detail` beside the coarse reason, set at each of the seven origins and
emitted as `block_detail`. Telemetry only; nothing branches on it, and the
user-facing copy is unchanged. Which cause dominates decides whether the fix
is better copy, a parser gap, or an Unroll-style recovery path — and guessing
that without the split is what this is meant to avoid.
`directEditOutcomeForProperties` rebuilt blocked outcomes from `reason` alone,
so it dropped the detail for the three causes that flow through it; it now
carries it. Mutation testing caught that, not review.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(studio): undo waits for in-flight hand edits before consuming history
* docs(studio): trim comments to the 3-line house rule
* feat(studio): export timeline editing, undo/redo, conflict banner and TimelinePane for a host
* docs(studio): trim comments to the 3-line house rule
* refactor(studio): simplify exports pass
* docs(studio): trim comments to the 3-line house rule
* fix(studio): resolve canEdit targets against expanded timeline rows
* docs(studio): trim comment to the 3-line house rule
* fix(studio): guard razor-split-all and the audio-group attribute write
* fix(studio): gate the audio-group attribute for sub-composition-only members
* fix(studio): keep blocked timeline edits truthful
Refresh cached permission checks from the current host policy, gate auto-grouping before live patches, and restore optimistic attribute previews when a guarded commit is refused.
* fix(studio): publish desktop compatibility exports
Expose range selection and the canonical loader from the built Studio package while retaining timeline editing seams.
* fix(studio): gate timeline drop writes
Route asset, file, and composition drops through the shared edit permission gate before any side effect.
* fix(studio): preserve release-owned style exports
* fix(studio): clear file and Fallow gates
* fix(studio): keep the carve gate and range slice under the size budgets
* style(studio): format playerStore
`studio:unhandled_promise_rejection` was dominated by two AbortError rows
("signal is aborted without reason", "The user aborted a request."), about
10,000 events across 265 users per week, every one with a stack rooted in
the thumbnail scheduler's `release()`.
The scheduler is correct: every promise on its abort signal is awaited, and
a Chrome 153 reproduction with the real bundle confirms that releasing a
lease mid-fetch produces no unhandled rejection on its own. The leak comes
from browser extensions that wrap `window.fetch` in the main world
(Requestly and Poper Blocker are two seen in field stacks) and derive a
promise from each request without a rejection handler. Chrome rejects that
derived promise with our abort reason, so the stack points at `release()`.
Injecting such a wrapper reproduces the field rows exactly. Among users
with both this leak and a "Failed to fetch" error, 69 of 72 have an
extension frame inside `window.fetch`; among the rest, 13 of 308.
An AbortError rejection is a cancellation we asked for, so record it as a
sampled `abort_rejection_filtered` counter instead of an error. The filter
sits before the ERROR_CAP check: a scroll burst previously spent the
50-event cap in under a second and dropped every real rejection for the
rest of the session (116 users hit the cap in the last 7 days).
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A host mounting Studio's header reuses HyperframesLogo and buttonSizes for
matching chrome, and imports the compiled stylesheet as a fixed-name package
export instead of a hash that changes on every rebuild.
* feat(studio): every Studio panel is a dock panel and the shell moves onto it
EditorShell, App.tsx, the left and right panel columns, and the
inspector hooks now render through Dock.Panel instead of the old
fixed-width sidebars and split-pane inspector. usePanelLayout becomes
a thin facade over the dock store; fitPanels, the resize divider and
the split-inspector hook are dead and removed.
* refactor(studio): drop dead inspector flags and stale fallow entries after dock migration
Collapse inspectorButtonActive into inspectorPanelActive, remove unread
layers/design active fields, un-export unused prop types, flatten a nested
ternary, and point fallow ignores at the renamed/deleted files.
* fix(studio): keep right-panel behaviours the dock migration dropped
Block params dismiss on selection or when Design is hidden, the slideshow
panel can be closed, the selection outline follows the inspector intent,
caption editing focuses Design, and a URL slideshow tab opens once the
panel exists.
* refactor(studio): name the inspector-open state once in the header
* fix(studio): right-panel behaviours survive selection, restore and reopen after the dock migration
Block params stay open over an element that was already selected, caption mode wins over block params, the slideshow panel waits for the dock, the Renders tab title survives a reopen, each side column remembers the tab it last showed, tab switches are reported only when the tab changes, and the e2e thumbnail selector follows the dock.
* docs(studio): describe the dock layout and point comments at the renamed right panels
* fix(studio): block params survive a preview reload of the selected element, and panel titles are covered
The dismissal compares the selected element by its target key instead of object identity, because a preview reload rebuilds the selection for the same element. A closed and reopened panel keeps its custom title, with a test, and the e2e selector is formatted.
* fix(studio): block params leave with any other tab pick, and a restored slideshow placement survives loading
Block params are now dismissed by the tab setter the way their old tab was, including a re-pick of the same element, instead of by comparing selections. The slideshow panel is only closed after a file that was a slideshow stops being one, so a restored layout is not discarded while the file loads.
* refactor(studio): drop the duplicate inspector-open computation
The rebase onto main left two identical inspectorButtonActive/inspectorOpen
constants after a conflict resolution; only the first was still referenced.
* chore(studio): allow the dock migration deletions in the guard
StudioLeftSidebar.tsx, LeftSidebar.tsx and its storage test,
TimelineResizeDivider.tsx, useInspectorSplitResize.ts and fitPanels
are replaced by the dock; the CI deletion guard needs each named
with its reason.
* style(studio): oxfmt the deletion-guard entries
* fix(studio): rename Timeline's aria-label so it does not collide with its dock panel wrapper
Every dock panel wrapper now carries aria-label=<title>, matching the "Timeline"
title for the timeline panel. Timeline.tsx also set aria-label="Timeline" on its
own content div, so `document.querySelector('[aria-label=\"Timeline\"]')` found
the dock wrapper first and the timeline viewport gate CI check timed out waiting
for an attribute the real content div actually carried. Renamed to "Timeline
track view" and updated every consumer: the production diagnostics selector,
its tests, and the e2e viewport-gate script.
* fix(studio): restore inspectorButtonActive as a real prop after a rebase regression
Rebasing PR2 onto main replayed an older local commit whose diff did not
textually overlap main's newer inspectorButtonActive prop, so git applied
it with no conflict marker and silently reverted the header to computing
the value itself instead of receiving it. StudioHeader.dom.test.tsx already
asserts the prop is respected; restoring it makes that test meaningful
again. App.tsx now computes it once (panelLayout.rightCollapsed is already
the dock's effective collapse state, no separate railed-by-width case) and
passes it down.
* chore(studio): wrap a long doc comment in useRightPanelIntents
* feat(studio): the Renders panel on the shared primitives
The four native selects become the shared Select, so no OS popup is left in
the panel and the four controls read as the same control as everything
around them. Disabled options survive the move: SelectOption gains a
disabled flag, which is what keeps a resolution the composition cannot
reach out of the keyboard's path while its label still explains why.
Export drops its own type size. cn resolved that override by dropping the
size recipe's step, which is what left the panel Export a type step below
the header's; the computed-style table now reads 28px / 6px / 12px for
both. Its two per-row siblings become IconButton with a Tooltip carrying
what the title attribute used to say.
The panel's colours move to the semantic tokens.
* docs(studio): shorten two Renders-panel comments to fit the comment gate
* fix(studio): keep the renders-export test id the merge silently dropped
* fix(studio): type the Renders test mock against StartRenderHandler, not vitest 4's loose default
* fix(studio): type each mock declaration, not just the mountRenderQueue param
The project id in the location hash is user-supplied and outlives the project
it names: rename the folder, or open a bookmark from a project that is gone,
and it points at nothing. useServerConnection trusted it unconditionally, so
every later /api/projects/<id>/... request 404'd for the life of the tab —
including the composition read that opens the SDK session. Studio then fell
back to the server path for every edit, and the resolver shadow never ran
either, so nothing recorded why.
Telemetry, 24h after the read-reason split shipped in v0.8.51: 120
`stage: read` / `http_error` 404 reads across 5 users, about 24 each, none
recovering. On this route a 404 has exactly one origin — resolveProject
returned null — since the other failure modes answer 403 and the GET does not
set mustExist.
Validates through /api/projects/:id, which calls the same
adapter.resolveProject the file routes use. Matching against the
/api/projects list instead would be wrong twice over: that list omits session
ids, which resolve fine, and skips project dirs without an index.html.
The check is tri-state on purpose. Only a definite 404 counts as missing; a
rejected request or a 5xx keeps the hash, so one network blip cannot rewrite
a valid deep link out from under the user.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(studio): move undo and redo into the timeline toolbar
Undo and Redo now live in the flat timeline toolbar next to Select,
Razor and the rest of the CapCut-style tools, instead of the header.
Same actions, shortcuts and disabled states, with a shortcut-aware
tooltip; disabled reads clearly dimmer than enabled at a glance.
The header's right side is now segmented: Capture and Inspector share
one rounded, bordered group with a hairline separator and one control
height and radius, Inspector fills when on, and Export stays the lone
separate primary action. Below about 1000px Capture drops to icon-only.
* test(studio): tighten the Inspector fill check and cover Undo/Redo hotkeys
An exact class token now proves the fill, since bg-hover also names a hover
variant that was always present. Undo and Redo at their new toolbar spot
get the same hotkey-classification proof the header used to carry for them.
* fix(studio): undo and redo lead the timeline toolbar, left of select
Miguel's call from the mockups: undo/redo read as the first tools reached
for, ahead of selection and razor, matching where his reference places them.
* feat(studio): host apps can place overlays on the preview and restyle its stage
* feat(studio): stage background also recolours the preview panel; document the host layer
* fix(studio): preview consumers read the live iframe from one shared owner
* fix(studio): clear the shared preview iframe when the studio unmounts
The store outlives StudioApp, so a remount would start from the previous mount's detached iframe. The owner hook now writes null on unmount.
* fix(studio): skip the preview-iframe notify when setIframe repeats the current value
setIframe always merged a new state object, so zustand notified raw
subscribers even when the iframe reference had not changed. Bail out
by returning the same state reference on a no-op set.