* 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.
* feat(studio): header and inspector tabs on the shared primitives
The header's view-mode toggle and the inspector's tab strip become real
Tabs, so both gain the roving tabindex, arrow keys and Home/End they never
had; undo, redo and Inspector become IconButton and Button; Export becomes
the primary Button at the medium size. Capture stays an <a download>,
wearing Button's recipe, because a button cannot save a file.
Colour literals in StudioHeader.tsx drop to zero: the brand gradient moves
into theme.css as two tokens and the Export's hand-written text colour
becomes text-bg-0. Ratchet total 475 to 470.
* docs(studio): shorten the header and inspector tab comments to fit the comment gate
* chore(studio): bank the lower colour-literal baseline for StudioHeader
* fix(studio): arrow keys only move focus when the inspector strip toggles panes
* chore(studio): allow deleting PanelTabButton, replaced by the shared Tabs primitive
* refactor(studio): move file undo/redo into one history-actions hook
* refactor(studio): simplify history-actions hook
* test(studio): cover history-actions ordering and motion-file reads
* test(studio): type the history-actions test mock
* feat(studio): shared Menu, ContextMenu and Popover primitives
One menu chrome and one popover chrome, on Base UI, wearing the theme
tokens. Dismissal is plain Base UI with no capture-phase wrapper: its
outside-press listeners are registered on document with capture set, so
the canvas overlay's bubble-phase stopPropagation cannot starve them.
Menu items carry an optional mono shortcut hint, a disabled state and a
danger tone. ContextMenu opens at the pointer. Popover shares the
surface, radius and open motion but owns no item semantics, so a text
field inside it keeps its own keys.
Also adds [role='menuitemradio'] to the playback-shortcut ignore list.
The hand-rolled speed menu spelled its rows as buttons, which that list
already matched; the shared radio item is a div with the same role, and
nothing else in the list matched it, so an open speed menu would have
leaked arrow keys and Space to the player.
* feat(studio): shared Input, NumberField, Select, Slider and Toggle primitives
The five value controls exist once, on Base UI, wearing the theme tokens.
Each is boxed with a visible border and a focus ring so an inspector value
reads as an input rather than as its own label.
Value writes happen at a commit boundary, never per keystroke or per pointer
move: one drag is one onCommit and one telemetry call. Right-click and Escape
abort a drag in flight and put the pre-drag value back.
The switch role joins the typing-target selector list, so a Toggle keeps the
keys the native checkbox it replaces owned.
No consumer moves onto these yet.
* refactor(studio): shorten the primitives' comments and stop a default string reading as a class
Main's comment gate rejects long comment blocks, and the class gate read the
MenuItem tone default as a Tailwind class. The prop is optional instead.
* feat(studio): export the shared primitives from the package entry
A host mounting Studio pieces in its own tree imports them from the package:
cn, Menu family, Popover, Input, NumberField, Select, Slider and Toggle.
* feat(studio): dock sashes and tab strips work from the keyboard and side columns yield on narrow windows
Every dockview sash is a focusable separator (role, aria-orientation, aria-label);
arrow keys along the split axis resize the neighbouring groups by 16px (Shift 64),
clamped by the panels' minimum sizes, by replaying a pointer drag so dockview's own
clamping applies. Tab arrows now activate the tab they land on and wrap; Home/End
jump to the first and last tab.
Narrow-window rule: the preview keeps its 360x200 floor first; side groups' minimum
width is clamp((viewport - 360) / 2, 120, 200) and they shrink to their defaults when
it drops below 200. Below 600px the dock overflows and clips instead of squeezing the
preview. Measured at 560px: before, the inspector column sat fully off-screen at
x 560-760; after, columns are 120/360/120 and reachable.
* refactor(studio): simplify side group zone lookup in dock layout
* fix(studio): dock side-fit spares the preview group, follows panel moves and is covered through the shell
A side panel tabbed into the preview group no longer lowers the preview minimum width, moving a panel re-applies the side minimums, a project switch disposes the previous observer, and the dock shell tests now cover the sash and resize wiring.
* fix(studio): pin the preview floor on every group that holds the preview
A group that held the preview kept a stale side-column minimum after a drag, so the preview could shrink below its floor. Every group with a preview tab is now constrained to the preview minimum explicitly.
* fix(studio): every dock group gets a width minimum, shortcuts keep their meaning on tabs and sashes
The side-fit now rewrites the minimum of every group each time, so a group that stopped holding the preview or gained the timeline never keeps a stale value. Alt, Ctrl and Meta arrows are left to the browser. The shell test now proves the resize observer watches the dock.
* test(studio): the dock shell tests cover panel drags and sash labels
* refactor(studio): share the dock test harness and drop an unused export
The sash decorator is internal, the bare dockview setup and the persisted-group lookup used by several dock tests live in one place each.
* refactor(studio): shorten the side-minimum comment to one line
* feat(studio): shared Button, IconButton, Tooltip and Tabs primitives
Four primitives that consume design tokens only, plus the configured class
merger they all share.
- ui/cn.ts: one `cn` instance whose config knows the two class families
theme.css invents, the `text-step-*` type scale and the `ctl` control
heights, so a caller's class replaces a primitive's instead of stacking
with it and letting stylesheet order decide.
- Button and IconButton: native buttons on the three control heights, the
radius scale, the semantic palette and the motion tokens. Four classes
that resolved to nothing (rounded-button, shadow-btn-primary,
bg-accent-red, ease-standard) are gone, which is why the Renders Export
now has a radius.
- Tooltip: Base UI's tooltip, so the portal, the viewport flip and the
collision shift stop being hand-rolled. role="tooltip", the id wiring and
aria-describedby stay explicit here because the library does not add them.
- Tabs: Base UI's tabs, with roving tabindex, arrow keys, Home and End, and
a data-tab-id the sidebar's focus restore looks up.
Every interactive look is also reachable through a data-preview-state
attribute that CSS alone reads, so a gallery can show hover, active and
focus without a pointer. A test pairs the two lists in both directions.
The class-resolution test compiles Studio's real stylesheet with every class
the primitives emit and fails on any that produces no selector. Tailwind
treats an undefined class as silence; that silence is what let four dead
classes sit in this file.
* feat(studio): export Button, IconButton, Tabs and Tooltip from the package root
* docs(studio): trim comment blocks to pass the no-essay-comments gate
* refactor(studio): use native CSS.escape in the button class-resolution test
* refactor(studio): share the ui test mount helper and flatten the Button leading slot
* fix(studio): keep the tooltip open on a disabled control
* fix(studio): give the tooltip trigger no layout box and anchor to the child
* fix(studio): anchor the tooltip through a ref so it adds no render
* fix(studio): button children lay out with the consumer gap
* test(studio): keep the no-icon label and shortcut layout assertion from the standalone fix
* docs(studio): shorten the Tooltip header comment to fit the comment gate
* test(studio): the Button vacuity guard names tokens that stay undefined
* feat(studio): fail the suite on a class that resolves to nothing
Tailwind silently drops a class it cannot compile, so `rounded-button`
renders as no radius at all and nothing goes red. Two tests close that:
- the token gate compiles Studio's entry stylesheet with every class the
source claims and reports `file: class` for anything that produces no
selector. Tailwind is the judge, so there is no allowlist.
- the hex ratchet counts colour literals per file against a committed
baseline. It fails on a rise, and on a fall it prints the command that
banks the lower number. The baseline is only written under a named
flag, never as a side effect of a run.
The gate's first run found seven names the markup has always asked for
and no config has ever defined: rounded-button, shadow-btn-primary,
bg-surface-hover, bg-accent-red, ease-standard, text-2xs and two panel
backgrounds. Each is added to theme.css, the semantic ones by role and
the rest as deprecated aliases for the sweep to remove. It also found a
dead state class on the timeline clip, styled nowhere, which is deleted.
* fix(studio): gate a class by where it is used, not what it is called
The extractor treated a string as a class candidate only when its binding
matched Styles/Classes/ClassName(s), so the same twelve classes were gated
in `sizeStyles` and invisible in `buttonSizes`. A name is not a contract.
Candidate-ness now follows one file's data flow: an identifier used inside
a className attribute or a class-building call marks whatever is bound to
it as classes, under any name. A subscript is excluded, so the key of
`variantStyles[variant]` is not mistaken for a class list. The name-based
anchor stays for the map whose only consumer is another module.
The sweep this opened found one class no config has ever defined: the
automation menu's hover row asked for a background that compiled to
nothing, and now uses the token the rest of that row already uses.
* fix(studio): the colour ratchet counts code, not colour-shaped numbers in comments
* fix(studio): draw the drop preview and preview guides from theme colours instead of literals
* chore(studio): bank the lower colour-literal baseline now that comments are not counted
* docs(studio): shorten the style test and extractor comments to fit the comment gate
* fix(studio): the colour ratchet keeps strings when stripping comments
* fix(studio): escape every regex metacharacter when following a bound class list
* fix(studio): find a class list bound to a dollar-prefixed name
* fix(studio): style source paths use forward slashes on every platform
* feat(studio): say why the SDK session's file read came back empty
`stage: read` was the largest remaining class of SDK-session failures and a
single opaque string covering four different outcomes. It is a hard block, not
a blip: 56 users hit it over a 7-day window and, between them, landed zero
successful SDK edits — while staying active in Studio (3107 playback events,
138 design inputs, 20 commit transactions). Every edit in those sessions fell
back to the server path with nothing recorded, because no session also means no
resolver-shadow event.
readProjectFileOptional now returns a discriminated result instead of
`string | undefined`, and the telemetry carries the branch that fired:
- unsafe_path — the traversal/NUL guard rejected the path client-side
- http_error — the request failed, with its status
- missing_content — 2xx whose body was not the shape we expect
- empty_file — `optional=1` answers an absent file with 200 + "", which
parses into a session with no elements; previously this
looked like a healthy session that merely went unused
Two candidate causes were investigated and ruled out first, with tests kept as
regression cover in studio-server (projectIdEncoding.test.ts): project ids that
percent-encode (the CLI derives the id from the folder name, so a space or
non-ASCII character produces one) and sub-composition paths whose separator
encodes as %2F. Both resolve correctly today because Hono hands over an
already-decoded `c.req.path`. The decoded/encoded seam in resolveProjectPath is
a real latent hazard, hence the tests, but it is not this bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(studio): fold the absent-file read reason into the result union
Review feedback on #4161. `empty_file` was synthesized by the caller after
the helper returned `{ok: true, content: ""}`, so reason attribution was split
across two places and `ProjectFileReadResult` was not the full reason surface.
It now joins the union, and the caller has one branch.
Renamed to `absent_or_empty`: the optional=1 absent-file shim and a genuinely
0-byte file are the same 200 on the wire and cannot be told apart here, so the
literal name says what the tag actually means. For a composition it is always
the former.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(studio): dockview-backed dock engine, not yet wired into the shell
Registry, layout schema/builder, zustand store, the Dock component and
a persisted studioUiPreferences.dockLayout field. No panel moves onto
it in this change; Studio shell is untouched and looks identical.
* fix(studio): dock.css reads panel tokens as css variables so it compiles standalone
Tailwind v4 processes dock.css without the studio theme, so theme() calls failed to resolve. Use the --color-panel-* variables instead. Also drop the unused DockController.open member.
* test(studio): dock restore and schema cross-checks fail when their code is removed
* fix(studio): a reopened side panel rejoins its column's tab group instead of opening a new column
* test(studio): pin the dock reopen paths for the centre zone and a fully closed column
* feat(core): speed ramps as a rate lane in data-automation
A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.
* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests
* test(engine): read the playback-rate clamp from core instead of a literal
* feat(studio): trimming a clip shows the snap guide and the frame at the dragged edge
* fix(studio): guide and preview stay correct under group trim and beat snaps
Independent review found: the group-trim guide and preview seek read the
raw single-clip snap even when a member clamp moved the rendered edge; a
beat-snapped trim never drew a guide because the beat highlight still read
the old move-only draggedClip prop; the 1/30 frame lead duplicated
STUDIO_PREVIEW_FPS.
* fix(studio): a trim never changes play state and never snaps to the playhead
Both are regressions from the guide/preview seek this branch adds: the
preview and restore seeks now carry keepPlaying, so seek() only resumes
playback if it was already running; the playhead is dropped from the trim
snap set since the dragged edge drives its own preview seek, so snapping
to it was circular. Neighbour clip edges and beats remain valid targets.
* fix(studio): releasing a trim while playing no longer rewinds the playhead
* refactor(studio): share trim-with-seeking-playhead setup in resize tests
* 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.
* feat(studio): move every design value into one theme file
Studio's design values lived in three places: a JS Tailwind preset, an
inline @theme block, and raw hex and pixel literals in markup and CSS.
theme.css is now the single owner. Tailwind v4 turns it into both the
utility classes and the CSS custom properties, so markup, plain CSS and
canvas code all read the same number.
- semantic colour palette (background levels, surfaces, borders, text
levels, accent, selection, playhead, danger, media, container), radii,
control heights, the micro type scale, font stacks, shadows, easings
and motion durations
- the stock Tailwind palette is cleared with --color-*: initial, so a
colour that is not a Studio token cannot be reached from markup; the
entries Studio's markup still names are re-declared as deprecated
aliases and removed once the sweep converts them
- motion durations ship as @utility rules that zero themselves under
prefers-reduced-motion, since Tailwind has no duration namespace
- Phosphor icons get one default size and weight, read off the theme at
the app root; icons that pass their own still win
- published as @hyperframes/studio/theme.css; the JS preset export stays
as a deprecated shim for v3 consumers, with a test that fails if the
two drift apart
* docs(studio): trim theme token comments to pass the comment-length gate
* docs(studio): shorten theme test docblock to fit the comment gate
* refactor(studio): collapse the duration utilities into one functional rule
* refactor(studio): trim theme.css comments to three lines
* test(studio): fail when a legacy palette entry is not a Tailwind default
* fix(studio): keep the palette reset out of the exported theme.css
* style(studio): format theme.css with the repo formatter
* fix(studio): declare theme.css browser-only so the packed-consumer smoke does not import it in node
* feat(studio): the Lint badge pulses while an error finding exists
* refactor(studio): drop needless memo around the lint error flag
* fix(studio): the Lint badge pulse reads the same findings as its count
* fix(studio): name the badge severity for screen readers and cover a mixed finding list
* fix(studio): the error badge uses the panel danger token
* feat(cli): hyperframes timeline prints a project's tracks and clips
* docs(cli): list the timeline command
* feat(cli): timeline command reports unreadable lanes; add skill docs
* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes
* fix(cli): timeline resolves symlinks before the project containment check
* fix(cli): timeline accepts project folders whose name starts with two dots
* refactor(cli): timeline and compositions share the engine's start resolver
* docs(skills): point agents at the timeline command where they decide how to read a project
* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips
Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.
* fix(cli): timeline places nested media at the runtime start and tests its query one-liners
A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.
* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0
The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.
* refactor(core): one media-start rule shared by the runtime and the timeline command
The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.
* test(cli): timeline fixture for a media start written as an expression
parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
Children now render directly as flex items of the button (which
already carries gap-1.5/gap-1/gap-2) instead of a wrapping span, so a
label plus a kbd shortcut as two children get real spacing instead of
0px.
* 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
* 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
* docs(skills): one owner doc for everyday edits and a hyperframes-studio skill
Agents got contradictory advice on volume fades and were blocked from splits
and copies by the Ask-agent guardrails.
- creator-editing-recipes.md is the single owner: adds align-to-event,
copy-a-group and swap-media recipes and the one volume rule (use the lane)
- variables-and-media, data-attributes (skill and CLI copy), the gsap adapter
and tracks-and-clips now defer to it
- Ask-agent guardrails allow the neighbour edits a split, retime, copy or swap
requires and point at the owner doc
- new hyperframes-studio skill: sub-compositions for every scene, one caption
track, one element kind per track, safe zones
- drift test lints every html example in the owner doc with the real linter
and fails on any doc teaching a volume tween
* refactor(skills): drop unused tween allowlist from recipes drift test
* docs(skills): name the track kind attribute in the studio skill
* docs(skills): stop claiming lint rules that do not exist yet and fix track numbering
Review fixes:
- studio skill: drop the severity claim and the preview-overlay claim,
group tracks by data-track-kind instead of numeric ranges that clashed
with the recipes
- recipes: audio data-start is root time only in the root composition;
scene-local inside a sub-composition file
- guardrails: timeline edits are exempt from the element-only and timing
lines; the pointer names the skill reference, not a repo path
- montage doc defers to the volume lane; skill count is 21 everywhere
- drift test also scans the montage doc
* fix(skills): the ducking generator emits a volume lane and captions defer to the studio safe zones
- audio-duck.mjs prints a data-automation lane (clip-local times from the
target data-start) instead of a timeline volume tween; --json adds the lane
- operations.md shows the generated lane; the drift test scans it
- embedded-captions safe-zone lines point at the studio skill, which owns
the values
* chore(skills): regenerate the skills manifest after rebase
* docs(skills): remotion translation docs point volume ramps at the automation lane
* docs(skills): studio safe zones use the action-safe 90 and title-safe 80 boxes
* docs(skills): caption docs defer to the studio safe boxes
* chore(skills): regenerate the skills manifest after rebase
* fix(skills): studio skill names the safe margins; guardrail test matches the timeline-edit wording
* feat(lint): flag nested structure, missing timing and caption tracks on the root composition
One rule set for the root composition: nested layout inside a top-level
element, a timed element or media without data-duration, captions not marked
data-track-kind=captions, and captions spread over more than one track.
Findings are errors when the host is Studio and warnings elsewhere.
Sub-composition files are left free to hold layout.
* test(lint): keep the structure rules silent on the shipped catalog and skills
Runs the rules over every registry and skills html file and asserts no
findings, and treats a bare media element with no timing as a static layer.
* fix(lint): run the structure rules as Studio in both adapters and skip non-root files
The Vite Studio adapter now lints as the studio host, and HTML the project
lint did not treat as a root (components, snippets) is linted as a
sub-composition. Style, script and template content no longer count as nested
layout, sub-composition hosts need no duration, and a caption row without a
lane is not a second track.
* fix(lint): count legacy data-end as a length and pin the catalog test to real roots
* feat(studio): the timeline shows one row per top-level element and no child rows
* test(studio): cover the top-level id collection the timeline rows come from
* chore(studio): shorten the row-elements comment
* fix(studio): keep implicit layers as rows and say why a nested element cannot be hidden
* refactor(studio): drop implicit layers; rows are exactly what topLevelElements returns
* test(studio): untimed root wrappers are not timeline rows
* fix(studio): an empty timeline message clears the rows; drop the unused implicit contract
* fix(studio): an empty clip manifest reaches the timeline and clears the rows
* fix(studio): an empty clip manifest does not shrink the transport to its 1s floor
* chore(ci): allow deleting the child-row expansion hook and its test
Dragging a file over Studio covered the screen with a "Drop to add at the
playhead" box, but a drop lands at the pointer time on the target row. The
box is gone; the timeline now draws a landing preview at the exact start and
track the drop will use (a new row below the last lane when that is the
target), taken from the same placement function the drop commits.
verify-packed-manifests scans packed JavaScript for relative import
specifiers with a set of regexes that do not strip comments, so the
`require('./canvas-shim.cjs')` inside the stub's doc comment read as a real
import of a file that is not in the studio tarball, and the Build job failed.
Describes linkedom's try/catch guard in prose instead. The same literal was in
the test file's header comment; CI only reported the first match, so both are
fixed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
linkedom's HTMLCanvasElement constructor calls createCanvas(300, 150) from the
Node-only `canvas` package, behind this guard:
try { module.exports = require('canvas') }
catch { module.exports = require('./canvas-shim.cjs') }
The guard only works with a runtime require. Vite resolves the specifier
statically, so the bundled form is `try { exports = <module> } catch {...}` — a
plain assignment that cannot throw. The catch never runs, linkedom's own shim is
bundled but unreachable, and createCanvas lands undefined. Verified in the built
artifact: the canvas module namespace carried only a `default` export.
Parsing any <canvas> tag therefore threw inside openComposition, so Studio lost
its SDK session for the entire composition. Every edit then fell back to the
server path, and the resolver shadow did not run either, which is why this was
invisible before v0.8.47 added studio:sdk_session_unavailable. In the 72h after
the flip it accounted for 1065 of 1544 session-open failures, across a large
share of 197 users. Canvas is common in compositions: Three.js, shaders,
particle effects, charts.
Aliases `canvas` to a local browser stub that mirrors linkedom's shim
(measurement-only; nothing in the SDK rasterizes through it). Reimplemented
locally rather than pointing at linkedom's commonjs/canvas-shim.cjs, which its
exports map does not expose.
Confirmed against a rebuild: the namespace now carries createCanvas, so the
call returns a stub instead of throwing.
Note the unit tests here pass with or without the fix — vitest resolves the
require at runtime, so only the bundled build was ever broken. The regression
guard therefore pins the alias itself, since nothing else in the suite can
observe the bundler.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(studio): preview ruler and safe-margin toggles
* test(studio): cover guides placement in the preview pane and the caption band box
* feat(studio): safe margins are two nested 90 and 80 percent boxes with edge ticks
* fix(studio): safe box ticks centred on the line, no halo, tighter skill wording test
* feat(studio): scope UI preferences per project
Panel widths, timeline zoom, and other UI preferences shared one
localStorage entry across every project. Key by the active project id
(from the existing hash route), falling back once to the pre-scoping
entry so an existing project's first load doesn't look reset.
* docs(studio): trim a comment past the repo's 4-line cap
* fix(studio): make per-project preference scoping opt-in
Auto-deriving the project id from the URL by default would have
silently flipped every existing caller into per-project mode, most of
which read once at mount and never again across a live project
switch, and some of which (playback rate, mute, snap) are documented
as deliberately project-agnostic in playerStore's reset. Require an
explicit projectId instead so today's 9 call sites are unaffected.
* 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.
* feat(studio): a clip dropped onto an empty main track lands at 0
AD96 PR 1: the existing-clip-drag path (timelineCollision.ts) forces
start=0 for a clip landing on the main track for the first time, since
a track with one clip has no leading gap by convention. The file-drop
path is deferred to a follow-up once #4067 clears its file.
* refactor(studio): share the AD96 main-track drag test setup
Fallow flagged the two new tests as a 22-line clone; factor the shared
DraggedClipState builder into one helper.
* fix(studio): don't retime the rest of a multi-selection on the main-track snap
Adversarial review caught it: forcing the grabbed clip's previewStart to 0
fed straight into resolveMultiSelection's group delta, silently dragging
every other selected clip's time on a vertical-only gesture. Skip the
magnetic snap for a multi-selection drag; generalize
resolveMainTrackDropStart to also serve the upcoming file-drop path
(originTrack/isAudio instead of a full dragged element).
* feat(studio): dropping a new asset or file onto an empty main track lands at 0
AD96 PR 1, file-drop half (unblocked now that #4067 merged): the same
magnetic snap the existing-clip-drag path got now also covers a
catalog-asset drop and an OS file drop, applied once at the single DOM
drop dispatcher (timelineDragDrop.ts) so a multi-file batch still
sequences correctly after the snapped first placement. Composition and
block drops are left untouched — out of scope, per the original scout.
* refactor(studio): share the drag-over-then-drop test act() block
Fallow flagged the repeated act(() => { dragOver; drop }) pattern as
two clone groups; factor it into one dropAt helper.
* fix(studio): a mixed audio+video file drop always snaps, regardless of order
Adversarial review caught it: classifying a multi-file drop by only
the first file's kind meant [song.mp3, clip.mp4] and [clip.mp4,
song.mp3] snapped differently, purely from Finder selection order.
Classify the whole batch as audio only when EVERY file is audio, and
add the missing all-audio and mixed-order test coverage.
* refactor(studio): tighten main-track snap comment and dedupe file-drop array
* fix(studio): snap a top-gutter track-insert that lands on literal track 0
Adversarial review caught it: dragging a clip into the top insert
gutter creates a fractional lane that's never literal track 0 during
preview, but normalizeToZones can renumber it to literal 0 on commit
when the topmost visual lane was non-zero (main track genuinely
empty). That persisted start was never routed through
resolveMainTrackDropStart. Apply the same snap at commit time, using
the real post-normalize track, guarded against multi-selection the
same way the drag-preview path already is.
* fix(studio): compute the track-insert snap once so the z-sync sees it too
The previous fix snapped only the persisted edit after the fact, so the
stacking sync still read the unsnapped start. Resolve the snap inside
buildTrackInsertEdits from the final renumbered track and feed the one
value to both the edit and the z-sync candidate.
* refactor(studio): drop internal ticket codes from main-track snap comments and test names
* refactor(studio): simplify main-track snap
* fix(studio): snap the first main-track clip on its final lane and append new drops
One landing rule now runs in both the drag preview and the commit, after the
top-gutter renumber and the expanded-child-to-host conversion, so the ghost
matches the commit. New file and asset drops onto a filled main track land
after its last clip. The unused excludeKey check is gone.
* refactor(studio): keep media-panel drops out of this change
* refactor(studio): simplify main-track landing
* test(studio): pin the main-track snap on a plain lane change at commit level
The first 24h of v0.8.47 telemetry showed 30 declines against 25 successes on
the dom family, and 26 of those 30 were `ineligible_operation` -- a single
opaque string standing in for four unrelated causes. That made the number
unactionable: a structural edit the SDK has no vocabulary for (expected, and
permanent until the op mapping grows) looked exactly like a reserved-attribute
or unsafe-attribute decline (narrow, and possibly worth fixing).
`sdkCutoverIneligibleReason` now names which check failed: unsupported_op_type,
child_scoped_op, reserved_attribute, unsafe_html_attribute, no_ops, or
target_unaddressable. `shouldUseSdkCutover` is derived from it rather than
duplicating the conjunction, so the predicate and its explanation cannot drift
-- a test pins that agreement across every case.
Also separates the two conditions that are not properties of the op batch at
all: with the dom family disabled, sdkCutoverPersist reported
`ineligible_operation`, which read as "the SDK cannot express this edit" when
the truth was "this family is switched off". It now reports feature_disabled
and session_unavailable distinctly, matching the other four chokepoints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>