Commit Graph
23 Commits
Author SHA1 Message Date
Miguel Ángel dda0e757b1 fix(studio): the host can name the Split key shown in the clip menu (#4985)
* fix(studio): let the host name Split's key in the clip menu

* fix(studio): editor shell and timeline pane forward the Split key label

* fix(studio): drop two added doc comments the comment ratchet refuses
2026-10-04 06:13:16 +00:00
Miguel Ángel 438ea9d59f perf(studio): manual edits stay under 8 ms per frame (#4898)
* ci(studio): measure any edit cases on quiet runners, five runs in parallel

* perf(studio): an undo or save stops re-rendering every panel

* test(studio): the edit bench types one key per frame, as a person does

* fix(studio): the edit bench measures frame work when a task outside the drag has no thread time

* refactor(studio): hooks hand back stable handlers themselves, keeping App.tsx under 600 lines

* test(studio): the edit bench says why a case's frame work is unknown

* fix(studio): the edit bench counts a task without thread time at its wall time

* perf(studio): draw toasts on an opaque surface without backdrop blur

* fix(studio): keep stable handlers bound to the project they were made in

* fix(studio): every stable handler names the project it belongs to

* style(studio): format the stable handler call sites

* refactor(studio): name the timeline editing handler objects before wrapping them
2026-10-02 21:15:19 +00:00
Vance IngallsandClaude Sonnet 5.5 0559be0b13 feat(studio): link a video and its audio with data-link (6/8) (#4818)
* feat(core): add data-link media link helpers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(lint): warn on out-of-sync and orphaned linked clips

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): select, drag and trim linked clips together

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): detach, merge, link and unlink source transforms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): link-aware split, delete, alt-edit unlink and link edit actions

Split and delete fan out to link partners. Alt drag/trim edits one member
and unlinks it. onLinkEdit runs unlink, link, detach and merge as one undo
step each. Single-clip timing edits of a linked clip skip the SDK path so
its linked default cannot move the partner behind the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio-server): give split right halves of a linked pair their own link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): clip menu detach, unlink, link, merge back and delete-one items

Items live in clipMenuLinkItems.tsx with a one-line hook in ClipContextMenu;
the same resolver drives the Cmd+L, Opt+Shift+D and Opt+Delete shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(sdk): setTiming applies to data-link partners by default

{ linked: false } edits one member and unlinks it, matching Studio's Alt-edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): document data-link linked clips in hyperframes-core

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(studio): keep comment share at or below base in link plumbing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(studio): audio pill link follows data-link, not a shared file name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): alt-click selects one linked member even inside the pair selection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(lint): drop the rule-list comment for the linked clip rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sdk,lint): keep a link group inside its own composition

Link ids are minted per file, so lk-1 is normal in the root and in a child
composition. The SDK's linked-timing query descended into nested hosts and
addressed every match with the initiating scope, so moving a root clip moved
the child's pair too; lint grouped them into one drifting group. Both now
group only members of the same composition scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): match linked media by full asset path, scope links, keep hidden sound silent

Merge and Link compared only a lowercased basename, so assets/one/talk.mp4
and assets/two/talk.mp4 enabled a destructive Merge. Clips now compare the
src resolved against each clip's own source file, and Merge re-checks the
two srcs in the source before it runs. Link membership in the timeline
(linked members, gesture expansion, unlink orphans) is scoped to a source
file. Detach and the keep-sound cutout carry a hidden video's data-hidden
to its audio, and Merge refuses when only one member is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(studio): move the timeline move-edits handler into its adapter

App.tsx sat at 600 lines on this layer and 602 once merged with main.
useTimelineMoveEditsHandler now lives beside persistTimelineMoveEditsAtomically,
same callback and dependencies, bringing App.tsx to 592.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sdk,studio,lint): keep link groups inside inline compositions

A link group now stays inside the nearest data-composition-id or
data-composition-file ancestor. The SDK used to recognise only file hosts,
and Studio compared only sourceFile, so an inline child composition that
reused a link id was edited together with the root group. core/media-link
linkScopeOf is the shared rule for the SDK and Studio. Lint cannot import
core, so it uses the same two attributes directly. Studio rows record their
compositionScope, and link and merge refuse pairs that cross it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sdk): refuse a link edit whose partner address names another clip

A partner addressed in the caller's scope can resolve to a different element
when an inline composition reuses a root clip's data-hf-id. setTiming now
checks that each partner's address resolves back to that partner and throws
before mutating when it does not, instead of editing the root clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): a remote media url keeps its query when matching an asset

Two different videos at one remote path with different queries were treated as
the same file, so Merge could delete the audio and use the wrong video's
sound. Project-local srcs still drop query and hash.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(studio): keep a remote url's query exactly as written when matching assets

Decoding the whole url folded %26 into & so two different media urls matched.
Decode only the path; the query is compared as authored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(studio): compare a remote url as serialized, decode only local paths

A remote pathname with an encoded reserved character (%3F) matched the same
path with a literal ?, though they can return different media. Remote urls
keep origin, path and query exactly; local srcs are still decoded and
stripped of query and hash.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:23:25 +00:00
Vance IngallsandClaude Opus 5.5 000ccd426a feat(studio): speed presets, voice, look, crop presets and freeze frame in the clip menu (3/8) (#4815)
* feat(core): add gentle ramp-in, ramp-out and slow-mo-middle speed presets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add voice, look and crop clip-menu items and clip badges

Voice and Look are single-choice submenus writing data-fx-chain and
data-color-grading; None clears. Crop opens the canvas crop preset bar.
ClipBadges reads the live preview node and follows attribute edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): drop the unstyled badge classes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add crop aspect presets to the canvas crop handles

Free, 16:9, 9:16, 1:1, 4:5, Reset and Done float above the clip opened from
the clip menu. Presets commit a centred inset clip-path; Reset removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(studio): read the committed crop from the inline clip-path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio-server): add freeze-frame file mutation

Extracts the frame under the playhead with ffmpeg (media time honours
data-media-start, playback rate and a rate lane) into assets/freeze/, then in
one write splits the video, inserts a 2 s <img> still with the video's box
styles, shifts the rest of the track and splits a data-link partner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): import replaceFileAtomically from core atomic-file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add freeze frame to the clip menu

Freeze frame on a video under the playhead posts the studio-server
freeze-frame mutation and records its before/after as one undo step.
onFreezeFrame is threaded through the timeline edit callbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): keep the freeze still inside assets/freeze

The clip id from the project HTML went straight into ffmpeg's -y output
path, so an id with ../ segments wrote outside the project. The id is now
reduced to one safe filename component, the output is pinned inside
assets/freeze before extraction, and the still's src is built from that
name rather than from a filesystem path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): give every freeze still its own immutable name

Two clip ids that sanitise alike (a.b / a_b), ids sharing an 80-character
prefix, or a second freeze of one clip at the same playhead all produced the
same still path, and ffmpeg's -y replaced the first still's pixels. The name
now carries a hash of the raw id and a per-extraction token, the route
refuses a name that already exists, and ffmpeg runs with -n so it can never
overwrite. The freeze media time reads data-playback-start before
data-media-start, as playback does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:08:50 +00:00
Miguel Ángel 5b79038ca4 feat(studio): hosts can add their own items to the clip right-click menu (#4784)
* feat(studio): hosts can add their own items to the clip right-click menu

* refactor(studio): let the clip menu item names carry their meaning

* fix(studio): a clip menu item that moves the focus keeps it after the menu closes

* test(studio): the clip menu focus hand-off holds under StrictMode

* docs(studio): state the menu focus rule without history
2026-09-30 18:07:24 +00:00
Miguel Ángel a57e095468 feat(studio): panel tabs take the decided Current design (#4266)
Studio's panels become cards on a dark shell, and each panel's tabs take the "Current" design: the open tab is a fill in the card's colour that slides to the tab you pick, only the open tab shows its icon and close glyph, and a strip that is too narrow scrolls with a soft edge instead of hiding tabs behind a menu. This replaces the pill, underline and close treatment that was on this PR before.

## Before

![Before 2x: main, dark](https://github.com/user-attachments/assets/00ec6f33-082a-4209-917d-dea3c73c5b3d)

![Before 2x: main, paper theme](https://github.com/user-attachments/assets/09b01028-fa94-4946-bc47-a9abe7111a2f)

## After

![After 2x: dark](https://github.com/user-attachments/assets/041dfebf-c2cd-48bb-9ad4-5200d9f2ef9a)

![After 2x: paper theme](https://github.com/user-attachments/assets/d901bda4-ed7a-462f-8b83-0a7e3d4a7eff)

Switching tabs (the fill slides in about 140 ms and the new icon fades in over 80 ms; the panel body switches at once):

https://github.com/user-attachments/assets/8d359ada-c9e1-4425-9c95-61d5d4f5271b

### Every label whole, and the tabs use the whole strip (2x)

An earlier head clipped the left strip's fourth tab next to empty strip space. That space was a 72 px slot (plus 4 px) held on every strip for the strip actions, even on strips whose panel is not focused and so draws no actions. Dockview sizes that slot by its content; this PR had pinned it. Now the slot takes room only where the actions are drawn, so an unfocused strip gives its tabs the whole width. Tab padding is 6 px with no gap between tabs, so the tabs also fit next to the actions. Labels, icons and close glyphs sit on the tab's centre in every strip.

Left strip at 1440 x 900, tabs width against the room before the action slot:

| | macOS | Linux (these captures) |
|---|---|---|
| Earlier head | 322 in 280, clipped | 312 in 280, clipped |
| Now, panel unfocused | 278 in 358 | 268 in 358 |
| Now, panel focused (actions shown) | 278 in 282 | 268 in 282 |

![Left strip 2x at 1440, earlier head: Catalog clipped](https://github.com/user-attachments/assets/42d8eae9-67f5-49b6-b581-19c5185a4bdc)

![Left strip 2x at 1440, now: every label whole](https://github.com/user-attachments/assets/fc5d9137-1cfe-42eb-a6cd-f8bd90c19b4d)

![Left strip 2x at 1440, panel focused: actions shown, labels still whole](https://github.com/user-attachments/assets/66ce47c2-5bd9-47ca-870e-9c3acf6001fc)

At 1280 x 800 the left column is narrower, and the pinned slot clipped Catalog next to the empty space. Room for the tabs there went from 241 px to 317 px:

![Left strip 2x at 1280, earlier head: Catalog clipped next to the empty action slot](https://github.com/user-attachments/assets/b0603454-7c2f-4a4c-98b4-9028bc9c0d31)

![Left strip 2x at 1280, now: the tabs use the whole strip](https://github.com/user-attachments/assets/f3dfb3ce-ccb8-4221-8339-e481ff31df7f)

Captured at the PR head (5fd6c7bd) from a fixture project (the `warm-grain` registry example) in headless Chrome on Linux, 2x, each state loaded fresh so Studio builds its default layout for that window. Before is `main`; the strip Befores use the earlier heads' `dock.css` (8079c635 at 1440, 8ed7ab69 at 1280) on the same build. The macOS column was measured live in Chrome on macOS. The paper theme only re-themes the timeline today, so the panels look the same in both themes.

## What changed

- **Cards on the shell.** Each dock group is a card with the only 1 px border (radius 10) and a 6 px gutter to its neighbours. The preview and timeline no longer draw a second rounded border inside it; the preview stage is an inset well. Dockview's tab dividers and separators are gone.
- **Tabs.** Tabs use the whole strip unless the panel shows its actions. Every label is whole at the default 1440 x 900 layout (6 px side padding, no gap between tabs), and labels and icons are centred in the tab. The open tab is a fill with rounded top corners, flush with the card body. Only the open tab shows its type icon and close glyph (the glyph appears on hover or keyboard focus, and its space is kept so the label never moves). Other tabs are labels.
- **One sliding fill per strip.** One element slides (transform and width, 140 ms ease-out) under the open tab. Reduced motion turns the slide and the icon fade off. Hover is instant.
- **Strip actions** (panel menu, maximise or restore, close group) sit on the active group only, at half opacity while the pointer is in the panel and full on hover or focus. They take room only on the strip that draws them, so the other strips give their tabs the whole width. The panel menu lists that side's panels as checkboxes, plus Reset layout.
- **Narrow strips** scroll sideways with a 12 px fade on the clipped edge. Dockview's overflow dropdown is off.
- Tokens: `--panel-shell-bg`, `--panel-card-bg`, `--panel-card-border`, `--panel-strip-bg`, `--panel-tab-bg-hover`, with contrast pairs registered.

## Timeline viewport gate

The first head of this rebuild (b20ff32a4) cost the timeline gate one frame on CI: interaction p95 33 ms on every run (first attempt 100 ms on three runs), against main's 20 ms. Main-thread traces were identical; the extra cost was compositing, from a fade mask on every tab strip. Masking only a strip that is actually clipped brought it back:

| Head | Interaction p95 per run (ms) | Frame p95 (ms) | Runs passing |
|---|---|---|---|
| main | 20, 20, 19, 20, 18 | 34 | 5/5 |
| b20ff32a4 (rerun) | 33, 33, 33, 33, 33 | 33 | 5/5 |
| 41302773 | 18, 20, 19, 20, 17 | 33 | 5/5 |
| eefaa839 | 18, 20, 18, 18, 20 | 17 | 5/5 |
| af4f2b9b (tab spacing), first attempt | 18, 79, 18, 116, 19 | 100 | 3/5 |
| af4f2b9b, rerun | 18, 16, 20, 18, 18 | 33 | 5/5 |
| 8ed7ab69 | 17, 16, 19, 24, 20 | 33 | 5/5 |
| 6a5edd89 | 33, 33, 33, 33, 33 | 17 | 5/5 |

From about 14:30Z `main` itself reads 33 ms on every run (three consecutive `main` CI runs), and CI tests this PR merged with the current `main`, so the 6a5edd89 row matches `main`'s level at that time.

The af4f2b9b first attempt had three runs at main's level and two single-run spikes; the same commit's rerun passed 5/5, and the change is tab spacing only.

## Tests

Each rule that could regress without anyone noticing has a test, and each test was proved by breaking the code once:

| Rule | Test | Break | Result |
|---|---|---|---|
| Icon and close glyph on the open tab only | `DockTabs.test.tsx` (dock.css applied, computed display) | delete the hide rule | 2 red |
| Actions on the active group only | `DockTabs.test.tsx` | drop the active-group check | 1 red |
| One fill per strip, following the open tab | `DockTabs.test.tsx` | fill stays at 0 / a second fill | 1 red each |
| One border per surface | `dockTabs.contract.test.ts` | add a strip border | 1 red |
| Only the slide and icon fade animate | `dockTabs.contract.test.ts` | add a hover transition | 1 red |
| Reduced motion turns them off | `dockTabs.contract.test.ts` | remove the reduced-motion rule | 1 red |
| Label and icon centred in the tab | `dockTabs.contract.test.ts` | drop the full-height rule | 1 red |
| Every tab label whole at 1440 x 900 on each strip while its panel is focused (the least room it gets), with 10 px for macOS's wider system font (CI renders a Linux fallback) | `scripts/studio-runtime-smoke.mjs` (CI "Studio: load smoke", real Chrome) | the "Ca" head's spacing; a spacing that fits on Linux but clips on macOS | red on both ("312px of tabs in 280px", "276px of tabs in 280px"); green on main and this head |
| Unfocused strips hold no room for actions they do not draw | `scripts/studio-runtime-smoke.mjs` | pin the action slot at 72 px again | red: "3 inactive dock strips hold width for actions they do not draw" |
| The shown tab stays whole when its strip narrows (its panel gains focus and the actions appear), comes back narrower than it was before it was hidden, or when a neighbouring tab closes and it moves | `DockTabs.test.tsx` | ignore each of those changes in turn | 1 red each; checked live at 1280 x 800: clicking into the left panel's body used to leave Catalog 37 px hidden, now it is whole |
| A strip the user scrolled by hand stays put when something else in the dock changes, when it widens, and through another panel's maximise and restore | `DockTabs.test.tsx` | reveal on every pass; on any width change; after a hidden spell | red each; checked live: a hand scroll of 20 px is still 20 px after a maximise and restore |

Run alone, each exits 0: `DockTabs.test.tsx` 5, `dockTabs.contract.test.ts` 5, `Dock.test.tsx` 14, `dockAccessibility.test.ts` 10, `PreviewPane.test.tsx` 1, `Menu.test.tsx` 10, `scripts/contrastRatchet.test.ts` passing.

## Independent review

An adversarial review (separate agent, mandate to refute) at 41302773, eefaa839, 8079c635, af4f2b9b and 8ed7ab69: **no blockers; one major, fixed.**

- MAJOR (af4f2b9b), fixed in 8ed7ab69: CI renders Studio's system font stack with a narrower Linux fallback, so the clip check could pass while a Mac clips. The check now keeps 10 px of room for the macOS font, and a spacing that fits on Linux but clips on macOS turns it red. The 8ed7ab69 review asked whether 10 px holds for every strip, not just the left one it was measured on. Measured macOS minus Linux tab width at 1440 x 900: left 10 px, right 6 px, Preview and Timeline 2 px each, so the left strip is the widest gap and 10 px covers all four.
- MINOR, left as is: the room check has no unit test of its own; it runs only in a real browser, and the smoke is its test (red on both broken spacings above).

- MINOR: a panel cannot be closed from the focused tab itself by keyboard. The keyboard path is focused tab, Tab to "Panel menu", Enter, uncheck the panel (checked live: closes that one panel; checking it again reopens it). The tab's close glyph is not focusable, the same as dockview's stock tab on main, because a tab cannot hold a focusable button.
- MINOR: the open tab is 16 px wider than on the design page (the close glyph's space is kept; see For Miguel).
- Resolved: a contrast pair cited stale line numbers.

## UX checklist

**Job:** someone editing a video moves between panels (Compositions, Assets, Code, Catalog; Design, Layers, Renders, Variables) many times an hour and needs to see which one is open and reach the next without hunting.
**One action:** click the tab of the panel you want.

| # | Question | Y/N | Evidence |
|---|---|---|---|
| 1 | First-time user knows what to do | Y | Tabs keep their labels; the open tab is joined to the body it controls (After captures) |
| 2 | Interactive things look interactive | N | Strip actions are hidden until the pointer is in the panel (Miguel's decision; see Traded off) |
| 3 | User's words, no internal ids | Y | Titles unchanged; controls named "Close Design", "Panel menu", "Maximize panel", "Close group" |
| 4 | Works like other tools | Y | Editor-style tabs with maximise and a panel menu |
| 5 | Guidance at the control | N | The three icon buttons have accessible names but no visible tooltip |
| 6 | Primary action reachable without reading | Y | The tabs are the strip |
| 7 | Exactly one primary action | Y | Tabs; actions are secondary and quiet |
| 8 | Counts match data | Y | Titles come from each panel through `Dock.Panel` (`Dock.tsx` setTitle) |
| 9 | Excluded items announce themselves | Y | Every label is whole at the default width (the load smoke checks it); a narrowed strip scrolls with an edge fade, and the panel menu lists every panel |
| 10 | Long names recoverable | Y | Tabs never truncate; the strip scrolls |
| 11 | Keyboard path | Y | Arrow keys move and activate tabs (existing); strip actions are buttons and show on focus |
| 12 | Nothing hover-only that must be acted on | N | Same as 2 |
| 13 | Refusals say what next | n/a | No refusals here |
| 14 | Refusal next to the thing | n/a | |
| 15 | Error prevented | n/a | |
| 16 | Can leave any state without losing work | Y | Closed panels reopen from Window or the panel menu; Reset layout restores the default |
| 17 | Destructive looks different | N | "Close group" uses the same x as a tab's close; it loses no work, and the Window menu reopens the panels |
| 18 | Empty state names what is empty | n/a | |
| 19 | Feedback within 400 ms | Y | The tab state and icon change in the same frame as the click; the fill arrives in 140 ms |
| 20 | Progress on waits | n/a | |
| 21 | Hand-built state survives reload | Y | Layout, open tab and maximised group persist per project (existing dock persistence) |

**Choice:** four tabs per side, one click each. **The end:** the panel body appears at once and the fill settles under the tab you picked.
**Not exercised:** a screen reader pass; touch input.
**Traded off:** hover-only strip actions (rows 2 and 12) and scroll-plus-fade instead of an overflow menu when a strip is narrowed are Miguel's decided design.

## For Miguel

- **Paper (light) theme:** Studio's paper block only re-themes the timeline today, so the panels are dark in both the Before and After paper shots. Panel paper values are Desktop's to set (design principles, section 7); this PR adds the token names Desktop can override.
- **Action slot:** the design review asked for the action slot to be reserved on every strip, so tabs never move when focus changes panel. That reservation is what clipped "Ca" next to empty space, so it now holds room only on the focused panel. The cost: when a strip that is already too narrow gains focus, its actions take 76 px. The strip then scrolls so the shown tab stays whole, and a neighbouring tab can go under the edge fade. At the default 1440 x 900 nothing clips either way.
- **Tab spacing:** side padding is 6 px (10 on the design page) and tabs have no gap. The design's 10 px would need 322 px on macOS; the left strip has 282 px beside its actions at 1440. A wider default left column would allow the design padding again. A narrower window scrolls the strip with the edge fade; per the decision there is no overflow chevron.
- **Close glyph space:** the open tab keeps its close glyph's space at rest, so it is 16 px wider than on the design page, where the label shifts on hover.
- **Not in this PR:** inspector section headers and the focused-card border step (later build steps).
2026-09-23 14:34:10 -04:00
Miguel Ángel 85a1a8f789 fix(studio): swap the preview via a shadow iframe instead of blanking on reload (#4207)
* 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.
2026-09-20 13:04:23 -04:00
Miguel Ángel 84e6531668 feat(studio): a host can make the preview read-only (#4200)
* 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
2026-09-20 10:10:57 -04:00
Miguel Ángel 98e8372a0a feat(studio): all panels move onto the dockable, persisted layout (#4192)
* 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
2026-09-19 19:04:16 -04:00
Miguel Ángel ef64323174 feat(studio): host apps can place overlays on the preview and restyle its stage (#4187)
* 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
2026-09-19 16:32:31 -04:00
Miguel Ángel c2ca2291ca fix(studio): dropping an asset between or above timeline rows opens a new row (#4160)
* fix(studio): dropping an asset between or above timeline lanes opens a new track there

* refactor(studio): reuse the source patcher and shared drop-placement type for track inserts

* fix(studio): the dropped clip must carry its peers' sourceFile, not the resolved path

planDropTrackInsert stamped the synthetic dropped element with the resolved
target path string, but real elements on the main document carry no
sourceFile at all. sameSourceFile compared the two directly, so the dropped
element always read as belonging to a different file and every peer was
skipped as unwritable, silently landing the drop on an existing track
instead of opening one.

* refactor(studio): trim drop-track comments and share one can-insert-track predicate

Drop the TimelinePlacement alias, compute the file-or-asset check once for
dragover and drop, and shorten two comments that narrated history.

* fix(studio): plan a dropped-in track against the visible rows, not clips the timeline hides

The insert row counts the rows the timeline shows, but the plan was built
from every same-file clip. A clip the timeline does not show as its own row
shifted the boundary and renumbered lanes it should have left alone.

* test(studio): assert the dropped clip lands on the opened lane

* fix(studio): a row drop measures its boundary on the same row order a clip drag does

The drop path indexed the insert row against the rows the timeline shows,
which include audio-group anchor rows, but planned it against a plain sorted
track list. An audio group above the boundary shifted the new row by one.
The placement now carries the displayed row order and the planner uses it,
as the clip drag already does.
2026-09-19 05:28:06 -04:00
Miguel Ángel 68b61675ed feat(studio): upgrade Studio to Tailwind CSS v4 (#3618)
* chore(studio): upgrade Tailwind CSS to v4 through the Vite plugin

Studio moves from Tailwind 3.4 to 4.3.3, loaded by @tailwindcss/vite instead
of PostCSS. The JS config becomes an @theme block in studio.css carrying the
same studio.* and panel.* colors, with explicit @source globs so the gitignored
data/projects tree is never scanned.

The official upgrade tool renamed the utilities, but it rewrites bare words
inside any string, so its edits to event names, CSS property names, filter
function names, status enum values and prose were reverted by hand.

Nothing in Studio relied on the v3 default border color, ring width or ring
color, so the tool's compatibility shim was dropped rather than kept.

* chore(studio): note the duplicated palette between theme css and preset

* fix(studio): keep the pointer cursor on buttons after the Tailwind v4 upgrade

v4 preflight dropped the pointer cursor on enabled buttons, so add it back in
one base rule. Also migrate the last outline-none to outline-hidden.

* fix(studio): keep the Renders Export button at its shipped 14px size

* chore(studio): allow the tailwind v4 config deletions in the deletion guard
2026-09-19 03:07:19 -04:00
Miguel Ángel a25da94b77 refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch

* refactor(studio): fold the thumbnail output branches left after the storyboard mode went

* docs(studio): drop leftover storyboard references after the view was removed

* ci: allow the storyboard view deletions in the no-deletions guard

* docs(studio): wrap an over-long comment line in the caption overlay helper

* style(studio): reindent App.tsx after the view-mode wrapper was removed
2026-09-18 23:47:12 -04:00
Miguel Ángel 0b6d1039ed feat(studio): copy, paste and duplicate clip groups on the timeline (#4064)
* feat(studio): copy, paste and duplicate clip groups on the timeline

Cmd+C on a multi-clip selection now copies all of them, not just the
first. Cmd+V pastes the group at the playhead keeping each clip's offset
from the earliest one and its original track when free. Cmd+D duplicates
the selection in place. Context menu gains Copy/Paste/Duplicate, and a
failed copy now toasts why instead of silently returning false.

Root cause was a DOM lookup that couldn't address a composition-instance
clip's root; group copy reuses the composition-aware resolver every other
timeline editor already uses. Also fixes a duplicated data-hf-id on
pasted/duplicated clips found during live testing, and reorders the
post-paste selection to land on the new clips instead of stale ones.

* fix(studio): scope the data-hf-id strip to tag brackets, not text content

A clip's visible text can contain the literal string data-hf-id="...";
the previous whole-string regex stripped it as if it were the attribute,
corrupting the copied text. Reproduced and confirmed the fix with a test.

* fix(studio): extend root duration and coalesce cut on group paste/duplicate

Paste and duplicate could silently truncate a group from export: they
inserted clips past the composition's data-duration without growing it,
so content existed in the file but never played or rendered. Both now
route through the same grow-only ratchet blockInstaller already uses.

Cmd+X on a multi-clip selection now deletes the whole selection in one
call (the existing batched delete already used by the Delete hotkey),
so one Cmd+Z restores all of them instead of needing one per clip.

The data-hf-id strip and id-capture on paste now go through DOMParser
and a tighter regex respectively, closing two escape hatches: a > inside
an earlier attribute value, and a data-id attribute preceding the real id.

* refactor(studio): share the id-attribute regex and hoist the clip DOMParser

Simplify pass on the previous commit: the id-capture lookbehind was a
second copy of the same pattern deduplicateIds already uses, now a
single exported ID_ATTR_RE both call. The per-clip DOMParser instance
in pasteTimelineClips is now created once per call, not once per clip.
Adds a test pinning stripHfIds's no-parseable-root fallback.

* fix(studio): close the stripHfIds hoist gap

stripHfIds silently returned html unstripped when DOMParser found no
body-level element (a tag the parser hoists into <head>, e.g. title or
meta). Closes it with a regex fallback and a test reproducing the exact
hoisted-tag case, not just the no-tag-at-all case from the prior commit.

* refactor(studio): dedupe clip menu item classes into one helper

The new Copy/Paste/Duplicate rows in ClipContextMenu each got their own
static enabled/disabled className string instead of the parameterized
itemClass(enabled) pattern the sibling TrackGapContextMenu already uses
for the same job. Consolidated to one function; rendered classes are
unchanged for every state.

* refactor(studio): reuse timeRangesOverlap for clip paste placement

tracksOverlap re-derived the exact half-open interval check
timelineCollision.ts already exports and uses elsewhere on the timeline.
2026-09-18 18:25:12 -04:00
Miguel Ángel 94da403d6d feat(studio): expose Studio's live state to an agentic browser (WebMCP) (#3511)
* feat(studio): expose Studio's live state to an agentic browser

Registers a `studio_look` tool on `document.modelContext`, so an agent in a
browser that supports it can read what Studio knows: the open project and
composition, the playhead, the human's current selection with its
capabilities, and the timeline's elements with a handle for each.

The API is `document.modelContext`, not `navigator.modelContext`. The latter
is a polyfill compatibility shim rather than a spec member, so feature
detecting it is wrong even where a published sample appears to work.

Three decisions worth knowing:

Registration happens ONCE per mount, with the dependencies held in a ref that
every render refreshes. Depending on the handlers instead re-runs on nearly
every interaction, because the DomEdit actions object changes identity with
the selection and the element list. Each re-run aborts the registration signal
and unregisters everything, and the spec warns that a quick unregister-then-
reregister can apply an old call's arguments against the new schema. The test
for this is the important one in the unit; breaking the empty dependency array
fails it and nothing else.

Tools resolve with a tagged result, they never reject. That is forced by the
spec: a rejected `execute` has its reason discarded and the caller sees a bare
UnknownError, so rejecting would guarantee the agent cannot learn why an edit
failed.

Elements are addressed by a minted handle, not by `TimelineElement.id`. That
id is a synthesised identity, so `getElementById` misses most elements; the
handle carries `data-hf-id`, else the DOM id, else a selector plus occurrence.

Mounted from `EditorShell` rather than `App`, because the DomEdit contexts are
only readable below `DomEditProvider` and `App.tsx` is three lines under the
600-line cap.

The undo signal is reported as the shell actually exposes it, `canUndo` and a
label, rather than as a revision counter. The depth lives in component-local
state and is not reachable without plumbing it through the shell context, so
the field says what it is instead of implying precision it does not have.

Writes are not in this change. `canWrite` is optimistic and the comment says
so; the write tools need a real guard against the paused-save and external-
conflict states, which are not on any context this component can reach yet.

* fix(studio): bound WebMCP look filters

* fix(studio): remove premature WebMCP write state

* docs(studio): name WebMCP singleton assumption

* fix(studio): surface WebMCP registration failures
2026-08-26 23:59:49 -04:00
Vance IngallsandClaude Sonnet 5 6a92d21401 feat(studio,core): reach presets and the rack from the timeline (#3292)
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).

The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).

The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).

Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
  (data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
  (timelineElementFxAttribute.ts), addressed by the TimelineElement
  itself rather than the current selection. This is the one real
  architectural gap the step doc's assumption didn't survive: the
  property panel's onSetAttributeQuiet closes over domEditSelection,
  so writing a clip that isn't already selected has no synchronous
  path through it. Extracted the shared live-patch-then-persist core
  (persistElementAttribute, timelineEditingHelpers.ts) out of both
  this new path and the existing setAudioGroupAttribute, which the
  fallow duplication gate flagged as a 66-line clone on first pass —
  now a single ~50-line core parameterized by patchLive/readLive, with
  each caller a ~15-line wrapper resolving its own patch target
  (buildPatchTarget({domId}) for a group, buildPatchTarget(element)
  for an arbitrary clip) and live-DOM lookup.

Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.

Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.

Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:01:34 -07:00
Vance IngallsandClaude Opus 5 254de3d1c4 fix(studio): captions UX — mode exit, undo, autosave surfacing, honest gating (#1968)
Caption-editing fixes from the studio UX review. This surface held five of
the thirteen criticals; the theme is that the editing UI shipped ahead of
its apply/persist pipeline, so several controls mutated an in-memory model
with no downstream effect, and the mode itself could never be exited.

Mode trap: caption edit mode auto-activated on detection and had no exit —
`setEditMode(false)` and `reset()` had zero call sites, so the caption
overlay replaced normal element editing for the rest of the session, even
after switching compositions. The store now resets on composition change
(flushing the last debounced edit first), an "Editing captions · Exit" pill
sits on the preview, and a re-enter button appears once dismissed.

Honest gating of dead surfaces: the Animation tab (31 presets ×
duration/ease/stagger/intensity) edited state that was never applied to
playback nor serialized — wiring it needs a CaptionOverride schema
extension in packages/core plus a runtime engine, so the tab is now visibly
disabled with an amber "isn't applied to playback or saved yet" notice
instead of silently discarding work. Timing edge-drags moved a block that
never changed playback and never saved; the handles are gone and the blocks
remain as select/seek targets. Double-click split desynced the overlay↔DOM
index mapping, so split is out until regeneration exists.

Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target)
across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is
active and reapplied to the live iframe. Previously ⌘Z reverted an
unrelated file edit while the bad caption drag persisted.

Autosave: save failures, including non-2xx, raise a persistent "not
saved — Retry" banner; the code's own comment called this a data-loss path
and it was telemetry-only. Debounced saves flush on unmount instead of
being discarded, `beforeunload` flushes and warns while pending, and
corrupt overrides JSON is distinguished from a missing file.

Input safety and a11y: arrow-key nudge no longer hijacks arrows inside
form inputs; numeric fields commit finite values only (typing "-" used to
inject NaN into gsap and persist null); "Mixed" shows on multi-select
divergence; Escape cancels an in-flight drag and restores the pre-drag
transform; ⌘A selects all; caption blocks are keyboard-selectable with a
playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed
but nothing passed it); 24px hit areas around the 8px handles; a hint when
no boxes are visible; visible input focus styles; tablist semantics.

Perf: the 66ms getBoundingClientRect polling loop is replaced with
event-driven updates (player-store subscription, preview messages,
ResizeObserver, rAF-coalesced); the interval now runs only during playback.

Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio
revamp (#2291), so the mode pill, the sync-error banner and the re-enter
button move to its successor, nle/PreviewOverlays.tsx, and the caption
track's onSeek is wired in EditorShell. The per-keyframe
onChangeKeyframeEase change that also lived in that file is dropped:
main removed the prop, and #1967 now routes the diamond menu's ease action
to the focused-ease-segment editor instead.

Restacked onto main now that PRs 1962-1967 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:37:28 -07:00
Vance IngallsandClaude Sonnet 5 5fd84c395b feat(studio,core): a volume and a living meter on the group row (#3290)
* feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:13:42 -07:00
Miguel Ángel 20d915938b feat(studio): ask for feedback when a render ends, not on a session counter (#3205)
## What

Studio's feedback prompt now fires when a render finishes or fails, instead of on a session counter, and the reports it collects carry enough context to act on.

- Replaces the 32px inline bar with a card in the existing toast stack
- Adds `studio_feedback_shown` / `studio_feedback_dismissed` / `studio_feedback_interview_click`, so the funnel is visible
- A failed export and a crash skip the 0-10 score and ask what happened
- Adds a crash prompt to the error boundary
- Attaches a breadcrumb trail, render settings and outcome, and how the project was created

## Before / after

<img width="1500" alt="Before: a 32px feedback strip pinned under the preview. After: the feedback card in the toast stack, with one-tap answers, and the red variant for a failed export." src="https://github.com/user-attachments/assets/29e2373f-ee96-42c4-b006-ab9a0e56a60a" />

The old bar's rating numbers are `neutral-600` on `neutral-900/80`, which is why it reads as a disabled row rather than a control.

**In the running Studio** — bottom-right, sharing the toast stack, hovering a chip explains it on the line above the input:

<img width="1500" alt="The feedback card in the bottom-right of the running Studio, with the rotated follow-up question and a hovered chip explained inline." src="https://github.com/user-attachments/assets/3969da50-cc7a-466a-bbf9-b151a1bc1d5d" />

**On the crash screen** — the prompt the error boundary renders, asking what the user was doing rather than what went wrong, since the stack trace already covers the latter:

<img width="1200" alt="The Studio crash screen with the feedback card below the Try again and Reload Studio buttons." src="https://github.com/user-attachments/assets/4d287011-9c19-4b94-99d0-07a0388fb39c" />

| | Before | After |
|---|---|---|
| **Trigger** | Every 10th session | A render finishing, failing, or a crash |
| **Placement** | 32px inline bar, pushes preview up | Card in the toast stack, no layout shift |
| **Rating targets** | 11 bare buttons, `neutral-600` | Native radios, resting fill, `neutral-400` |
| **Follow-up** | Free text or nothing | One rotated question, four to seven one-tap answers |
| **Option help** | None | Inline hint on hover and focus |
| **Press feedback** | None | `active:scale-[0.97]`, 150ms ease-out |
| **Keyboard** | No exit path | Escape closes, Enter sends, arrows move the rating |
| **Auto-dismiss** | 20s, always | 30s, cancelled the moment you interact |
| **Failure case** | Same NPS question | Its own question, no score, error quoted back |
| **Crash case** | Nothing | Prompt on the crash screen |
| **Visibility** | Submissions only | Shown / dismissed (with reason) / submitted / interview click |
| **Report content** | Rating, comment | Plus breadcrumbs, render settings and outcome, project provenance |


## Why

The old bar fired on a session count, so it interrupted at a moment with no subject: nothing the user had just done, nothing to have an opinion about. It also emitted nothing when it appeared or when it was dismissed, which made the collection rate impossible to diagnose. A prompt nobody answers and a prompt that never renders looked identical from the outside.

Visually it read as a disabled row: 11 buttons at 11px in `neutral-600` on a dark strip, with no resting affordance. And appearing mid-task pushed the whole preview stack up, which the old code carried a comment apologising for.

Separately, the reports it did collect were not actionable. A comment says what went wrong; it almost never says how to get there.

## How

**One trigger, one owner.** `feedbackTrigger` owns eligibility and nothing else does: once per tab, thirty days after an answer, seven after a dismissal, never when telemetry is off (prompting someone whose response we would then drop wastes their attention). `VITE_HYPERFRAMES_NO_FEEDBACK=1` still disables it entirely.

**One hook, every failure path.** The trigger watches the render job list rather than each of the four places a render can finish (server rejection, unreachable server, SSE terminal event, SSE connection drop), so paths added later are covered without touching the trigger. Renders loaded from disk history never fire it.

**Reuses what exists.** The card wears `StudioToast`'s glass treatment and joins its stack, so there is no second visual language and no new CSS. The rating row is native radios, which gives arrow-key navigation, grouping and labels for free.

**One question each, rotated across users.** A corner card that asks three things gets answered by nobody. Each person gets one follow-up with one-tap answers, explained on a reserved line rather than a floating tooltip (the card is 340px in a corner; a bubble above the chips lands on the question, below lands on the input). Detractors are never given a rotated question, because they already have a specific complaint. Every option was checked against the code: an option naming a feature Studio already has would collect taps meaning "I could not find it", which is indistinguishable afterwards from "it does not exist".

**Breadcrumbs cost one line.** Every studio event already flows through `trackEvent`, so recording the trail there needs no new instrumentation and stays correct as events are added.

**Provenance lives outside React.** A crash unmounts the tree, so it is captured when the project loads and read from module scope when the crash prompt renders.

### Privacy

Breadcrumbs and provenance carry names, enums and counts only. Values are copied from a fixed allowlist of short keys, and anything longer than a slug is dropped rather than truncated, so comments, file paths, stack traces and project titles cannot reach them even if a future event carries one. Tests assert this.

### Where these responses land

Studio feedback goes to PostHog and nowhere else, which is what it did before this change too.

Worth stating because the CLI behaves differently: `hyperframes feedback` also forwards to the backend feedback endpoint via `submitFeedback`, on top of its PostHog event. Studio has never used that path, before or after this PR, so if you read CLI feedback anywhere other than PostHog, Studio responses will not show up there.

Nothing here changes that either way. Whether the two surfaces should share a delivery path is a product question, not a defect in this change, and closing it would need a field on the backend DTO: it is shaped around `cli_version`, and Studio reports from a crash or a failed export deliberately carry no rating.

## Test plan

- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)

**Unit** — 39 new tests: trigger eligibility and cooldowns, the detractor override, rotation, preset shape and the no-brands rule, breadcrumb rolling and privacy, provenance parsing and its failure modes, and the crash boundary rendering the prompt with no rating input.

**Live** — both render paths driven end to end against a running Studio on a production bundle, with real renders. Every PostHog request was intercepted and dropped, so nothing reached the project. Verified the emitted payload for a finished render, a failed export, the rotated follow-ups, each chip's hint, and the interview link.

**Not covered** — no live capture of a spontaneous crash. Three attempts to force one failed because Studio's guards held and it kept rendering, so the crash path is verified by component tests rather than by driving it. Touch devices see chip labels without hints, since the hint is revealed on hover and focus.
2026-08-11 01:33:52 -04:00
Miguel ÁngelandCodex 96861cbafc perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails

* perf(studio): bound thumbnail decoding resources

* perf(studio): virtualize timeline thumbnail media

* perf(studio): prioritize timeline thumbnail work

* perf(studio-server): coordinate cancelable thumbnail generation

---------

Co-authored-by: Codex <codex@local>
2026-08-05 20:41:35 -07:00
Miguel Angel Simon Sierra 4a12eb9d9c feat(studio): wire expanded keyframe timeline lanes 2026-07-28 00:40:42 +02:00
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00
Miguel Ángelandukimsanov df29fa7a5e feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap

What: extends 21 glue files so the OLD timeline/canvas engine and the NEW
NLE components type-check side by side: playerStore (multi-select setters,
zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain
optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/
timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the
NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props
gain optional callbacks/params, contexts gain *Optional hooks, and
TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting
both engines' change shapes. patchDocumentRootDuration's test rides along.

Why: this is the keystone that dissolves the old "welded glue" problem —
every symbol the NLE components need is ADDED next to what the old engine
still uses, so the engine components and the swaps can land as separate
reviewable PRs.

How: 15 authored intermediate files (main content + additive symbols; no
behavior changes — new fields optional, new callbacks unused until wired)
plus 6 files whose final content is already purely additive. New exports
without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed
by the app-shell swap.

Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines
compile); bunx vitest run (full suite green incl. the 6 new
patchDocumentRootDuration tests); fallow audit clean.

* feat(studio): timeline interaction hooks and lanes component (unwired)

What: the timeline-side wiring layer, unwired: TimelineLanes (the lane
renderer driving drag/resize/marquee), timelineMarquee (+tests),
useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning,
useTimelineEditingDrops.

Why: everything between the pure drag math and <Timeline> itself; the
timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these.

How: new files, tsc-clean against the coexistence layer. Unwired components
carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap.

Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): NLE shell assembly (unwired)

What: EditorShell (the full editor layout replacing NLELayout +
StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and
useTimelineEditCallbacks (the callback bag bridging store edits to the
timeline), all unwired.

Why: the shell that App swaps to in the final step; reviewing it standalone
keeps that swap PR small.

How: new files against the coexistence layer; TEMP(studio-dnd) entries
until App mounts EditorShell in the app-shell swap.

Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit
clean.

* feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine

What: flips the timeline glue to its final form (23 files): Timeline and
TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag
drives preview/commit through the new drag engine, range selection goes
multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9
old-engine files this orphans (group drag, marquee selection, snap targets,
layer gutter, selection overlays + their suites) — each is compile- or
gate-forced by this swap, verified by probe.

Why: second swap step; timeline-only, canvas and App untouched.

How: modified files to final content + forced deletions.
playerStore/timelineEditing/timelineCallbacks stay at their coexistence
form until the app swap (the old App still runs on them).

Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean.

* feat(studio): clip thumbnail modules

What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode
with SVG/AVIF format fallbacks and rounded-corner clipping — plus
VideoThumbnail updates.

Why: the decode layer for timeline clip thumbnails, ahead of the visual
refresh that renders them.

How: new modules + one modified file; purely presentational.

Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.

* feat(studio): assets/blocks panel behaviors + preview helpers

What: blocks tab install flow, right-panel and global drag-overlay polish,
music beat analysis and clip-content rendering hooks, and the
preview-helper utilities backing asset preview.

Why: completes the studio NLE stack on top of the visual refresh.

How: modified files only (kept as one PR: splitting further would produce
sub-150-LOC fragments of interdependent panel glue).

Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc
--noEmit; fallow audit clean.

* fix(studio): restore timeline playback loop

* fix(studio): restore missing GSAP helpers module

* refactor(studio): split timeline GSAP helpers

* style(studio): keep timeline helper under size limit

* fix(studio): restore timeline overlays module

* fix(studio): remove stale GSAP import

* fix(studio): restore canonical timeline dependencies

* style(studio): format restored timeline helpers

* style(studio): satisfy helper line limit

* fix(studio): repair rebuilt timeline integration

* feat(studio): complete rebuilt NLE cutover

* fix(studio): guard project and timeline race boundaries

* fix(studio): preserve graded resize and crop geometry

* fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local

* fix(studio): treat duration-0 tweens as static holds and settle resize position before persist

Instant holds (to()/fromTo() with duration 0) were classified as animated
tweens by every commit route, so resizing or rotating them converted the
hold into a corrupt duration-0 keyframes tween (new value at 0%, old at
100%) that GSAP drops; panel edits appended a losing set. A shared
isInstantHold() now routes them through the static replace-in-place path,
and percentage math guards zero-duration windows.

Separately, anchored-corner resizes painted 3-5 frames at the new size but
old position while the offset persist round-tripped the server. The commit
path now applies the corrected GSAP position synchronously before awaiting
the offset persist, mirroring the scale route's settle.

* feat(studio): gesture-transaction seam with commit observability

Introduce runGestureTransaction — one owner for a gesture commit's
settle -> persist -> record lifecycle. It settles the live DOM
synchronously before any async persist, folds every mutation into one
undo entry via a per-transaction coalesceKey, restores pre-gesture state
exactly once on failure, and asserts (dev console) + reports (PostHog:
commit_transaction / commit_invariant_violation / commit_transaction_failed)
that a persist never changes pixels. The box-size resize path is migrated
onto it; the ad hoc per-route coalesceKey/reload handling is removed.

Extract the resize draft-rect math into resizeDraft.ts to keep the
gesture-handler file under the size cap.

Also: keep url_hash telemetry to the route slug only (drop the query
string, which carried the user's selected element id/selector), and gate
the [hf-resize] diagnostics behind localStorage hf-resize-debug so they
ship as opt-in tracing rather than console noise.

* fix(studio): transaction owns the undo label

The coalesced history entry took the last sub-mutation's label, so a
resize surfaced as "Move layer" (the offset persist) in undo/redo. The
seam now stamps tx.label on every wrapped mutation, so the folded entry
reads as the gesture.

* fix(studio): atomic static size/position commits (no data loss)

Static resize/position holds updated an existing set via delete+add — two
undo entries, and a delete that succeeded before a failed add lost the
hold on disk. Use one in-place update-properties mutation when a set
exists (one undo entry, no partial-failure window). The keyframed-hold
heal that can't be expressed as a property update now adds before it
deletes, so any single failure leaves a recoverable duplicate, never a
lost hold. Transaction-owned commits are tracked via a WeakSet so the
heal path never double-wraps an already-wrapped gesture.

* fix(core): restore timed-clip visibility after a forced timeline rebind

__hfForceTimelineRebind force-rendered the re-registered timeline but never
re-ran the per-[data-start] visibility pass, so after undo or soft reload
every clip rendered regardless of its time window until a full page reload.
Extract the visibility loop into syncTimedElementVisibility and call it from
both syncMediaForCurrentState (unchanged) and the rebind.

* fix(studio): atomic z-order/keyframe/split commits, one undo entry each

Three edit-commit paths hardened onto the one-transaction invariant:

- Z-order reorder (useElementLifecycleOps): N per-element writes now fold
  into one undo entry (coalesceMs Infinity) and, on a failed persist,
  restore already-written files to disk so no partial reorder survives.
- Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate
  convert phase no longer full-reloads the preview (skipReload), killing the
  black-flash remount; convert + edit share one coalesce key = one undo entry.
- Razor split-all (useRazorSplit): snapshot before the batch and restore on
  any failure, so a mid-batch error never leaves un-revertable partial splits.

Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams,
readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit
logic across these paths. Commit options thread as one partial object rather
than field-by-field. Test setup extracted into colocated helpers.

* fix(studio): fold multi-step edits into one undo entry; guard text revert

- Gesture recording (useGestureCommit): the per-property-group commits now
  share one coalesce key and only the last reloads, so a recording is one
  undo entry and one preview reload instead of up to four.
- Delete selected keyframes (deleteSelectedKeyframes, split out of
  timelineEditingHelpers): N removals fold into one coalesced undo entry
  with a single reload.
- Text-field commit (useDomEditTextCommits): commitDomTextFields now uses
  the same version-guarded revert as handleDomTextCommit, so a stale failed
  commit can no longer stomp a newer successful one.

* feat(studio): batch a gesture's mutations into one atomic server write

A transaction that emits N mutations previously did N sequential POSTs,
each rewriting the file and soft-reloading — the root of the multi-phase
persist window. Add a gsap-mutations-batch endpoint that validates every
mutation up front, applies them in one in-memory rewrite chain, and writes
the file once (all-or-nothing: an invalid entry rejects the whole batch,
no partial write). The seam buffers a transaction's commits and, when more
than one targets the same file, dispatches a single batch — one write, one
history entry, one reload. The batch capability rides on the existing
commit-function reference; no option fields are threaded through callers.

* fix(studio): soften off-canvas indicator outline to 30% opacity

The dashed off-canvas selection outline at 60% was noisy with many
protruding elements on screen; drop the resting opacity to 30% (hover
still restores full opacity so it stays discoverable).

* fix(studio): drop off-canvas indicator outline to 10% opacity

Follow-up to the 30% softening — 10% resting opacity reads much calmer
with many protruding elements; hover still restores full opacity.

* fix(studio): gate [hf-commit] console traces to dev only

The start/settled/persisted/restore lifecycle traces logged on every
gesture commit in all environments — console noise for end users. Route
them through a dev-only traceCommit helper (matching the pixel-violation
error's existing DEV gate). The commit_* PostHog events stay always on;
they are the production observability, the console lines are a dev aid.

* fix(studio): count actual reloads, not softReload requests, in commit telemetry

A resize's size and offset persists both request softReload; the seam
counted each request, so a batched gesture reported reload_count 2 even
though the batch is one write and one reload. Compute the count from what
dispatchBufferedCommits actually did — one for a batch, the request count
for the sequential fallback.

* fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop

- Hover overlay applied the element's rotation only to the selection chrome,
  not the hover box; it now rotates about center like the selection, via a
  shared orientedGroupAwareOverlayRect router (one owner for rotation-aware
  overlay geometry across hover/selection/off-canvas).
- Off-canvas indicator was axis-aligned; it now rotates with the element and
  inverse-rotates the canvas-exclusion clip into the element's local frame,
  so the protruding-sliver clip stays correct for rotated elements.
- Crop commit re-lifted the element only in the commit's .then(), so one
  frame painted the cropped state (the flicker). Re-lift synchronously right
  after onStyleCommit (which applies the clip before its first await), so the
  cropped state never paints; the persisted file value is unchanged.

* fix(studio): address code-review findings across the commit-hardening campaign

Correctness (would ship green, bite under latency):
- Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert
  folds into one undo entry instead of splitting past the 300ms default.
- The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the
  field); multi-keyframe delete and convert coalesce correctly when SDK-routed.
- Razor split-all's rollback is guarded so a failing restore can't swallow the
  error toast that tells the user the split failed.

Simplification (single source of truth / no dead flexibility):
- Decompose resolveResizeDraftRect (drops a fallow-ignore suppression).
- Delegate the third readProjectFileContent copy to the shared helper.
- Inline setPatchFromUpdateProperties (its only caller passes one mutation).
- One toSdkPersistOptions translates gesture overrides to SDK options.
- Bundle the reorder-rollback deps into one object (was 7-9 positional args).
- Dedupe the 'last group reloads' ternary; type gesture options as
  CommitMutationOptions; drop a Map+array wrapper around a single write.

* feat(studio): atomic z-order reorder via batch patch-element endpoint

Z-order reorder issued N per-element inline-style patches (one server
write each), so a mid-chain failure could leave a partial reorder on disk.
Add a patch-elements-batch endpoint that validates every patch, folds them
over the file in one in-memory rewrite, and writes once (all-or-nothing;
unsafe input rejects with no write). The reorder now sends one batch per
source file and records one undo entry. Because a failed atomic write
persists nothing, the interim disk-write-back rollback (restoreReorderedFile
/ restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure
rolls back only live DOM/store state. Closes the last disk-atomicity gap.

* fix(studio): razor-split undo no longer silently no-ops

The split clone was written to disk without a data-hf-id, so the split
endpoint recorded that unstamped HTML as the undo entry's afterHash. The
next reloadPreview() ran the preview route's ensureHfIds write-back, which
minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk
hash no longer matched afterHash and editHistory's content-mismatch guard
silently refused the undo (no write, no network, no error). Stamp the split
output via ensureHfIds in splitElementInHtml before it is written/returned,
so the preview write-back is a no-op and the recorded afterHash always
equals the final on-disk bytes. Fixes at the source rather than relaxing the
mismatch guard. Corrects the stale comment that credited forceReloadSdkSession.

* feat(studio): closed-hand grab cursor on the rotate handle

The rotate handle used the default arrow cursor; show a grabbing
(closed-hand) cursor on hover to signal it's grabbed and dragged to rotate.

* fix(studio): dropping a dragged element over another no longer selects it

A moved drag's release fired the box click, which re-selected whatever now
sat under the pointer via the hover cache — so dropping an element over a
higher-z one selected the drop target instead of keeping the dragged
element selected. The drag-move branch now suppresses the next box click,
mirroring the resize branch.

* fix(studio): group drag is one undo entry, not one per element

Dragging a multi-selected group committed each member's position write as
its own undo entry, so reverting took N Cmd+Z presses. Force a shared
coalesceKey (infinite window) across every member's commit so they fold
into a single undo entry, like the other multi-step commit paths.

* fix(studio): undo of a split no longer leaves a ghost clip in the timeline

The file and the composition iframe revert correctly on undo, but the
timeline panel kept a ghost node for the split clone. The element-merge
that repopulates the timeline preserves elements the fresh scan dropped —
intended for enriched sub-composition children a bare DOM re-scan misses,
but it also preserved a genuinely-removed TOP-LEVEL element (the split
clone after undo), leaving a phantom clip. Restrict the preserve to
elements with a compositionSrc (the enriched sub-comp children); a
top-level element missing from the fresh scan was truly removed.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
2026-07-13 02:55:36 -04:00