4548 Commits
Author SHA1 Message Date
Miguel Ángel ac503920cb Merge pull request #4202 from heygen-com/release/v0.8.52
chore: release v0.8.52
v0.8.52
2026-09-19 18:50:49 -04:00
Miguel Ángel e778100b48 Merge branch 'main' into release/v0.8.52 2026-09-19 18:33:29 -04:00
Miguel Ángel 2060e13f2d fix(ci): bound the gcp-cloud-run image's bun install so a hang cannot stall it (#4206)
Same shape as fix/regression-docker-build-timeout: bun install can print a tarball error and hang instead of exiting (run 35468046055).

This Dockerfile ships a real prod deploy path, so a reviewer should look twice.
2026-09-19 18:33:06 -04:00
Miguel Ángel 420fc118cc fix(ci): bound the regression image's bun install so a hang cannot stall it (#4204)
* fix(ci): bound the regression image's bun install so a hang cannot eat an hour

bun install printed a tarball error then hung instead of exiting (run 35468046055).

Timeout+retry the install and cap the whole build step at 20 min.

* fix(ci): correct the build step's stated normal duration

Review measured 18 real samples at 2m28s-3m40s (median ~3m), not 5-8m.

Also: neither hang hit the 60-min timeout; both were manually cancelled.
2026-09-19 18:32:54 -04:00
Miguel Ángel 0cde884330 fix(cli): macOS 12 users get Chrome 150 automatically and can render (#4185)
* fix(cli): pick a macOS 12 capable Chrome on macOS 12 instead of the pinned build

* test(engine): stop building subprocess eval scripts from strings
2026-09-19 18:08:29 -04:00
Miguel Ángel 038851c7a8 feat(core): add an inlineAssets option to skip base64 asset inlining (#4198)
* feat(core): add an inlineAssets option to skip base64 asset inlining

A same-origin caller can now keep every font/image src as its authored path, not a data: URL.

Default true, unchanged for every existing caller.

* fix(core): also stop inlineAssets: false from inlining LUTs

The same bug (a path that reads back as data: URL) applied to LUT references too.

Composes with the pre-existing inlineColorGradingLuts flag; default is unchanged.
2026-09-19 17:57:17 -04:00
Vance Ingalls 4d62e80921 chore: release v0.8.52 2026-09-19 14:38:56 -07:00
Vance IngallsandClaude Opus 5 3685b76a07 fix(studio): a stale project id in the URL no longer blocks editing for the tab (#4188)
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>
2026-09-19 14:33:54 -07:00
Miguel Ángel f9fc552188 feat(studio): move undo and redo into the timeline toolbar (#4196)
* 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.
2026-09-19 17:14:18 -04:00
Miguel Ángel f24ff83834 feat(catalog): add the seven 3D-motion pieces as installable registry blocks (#4056)
* feat(catalog): add the seven 3D-motion pieces as installable registry blocks

Each piece ships as a registry block with a CDN poster and preview, a live
catalog detail page and its source. Scripts travel inside the catalog payload
(shared vendor libraries fetched once, item scripts inline, the one HDR as a
data URI) because the docs host only serves fonts, images and JSON.

NO_ESSAY_COMMENTS_OK: the long comment blocks are in vendored libraries and
the ported frost source under registry/blocks, not hand-written here.
HF_MAX_NONLFS_KB=6000: four hosted texture PNGs under docs/public/catalog/items
are runtime assets the docs host must serve as files.
fallow skipped locally: it audits against origin/main and this branch is
stacked on an older main.

* chore(catalog): regenerate gallery data and nav after rebasing onto main

* fix(catalog): read the install manifest through the bounded project reader

* fix(catalog): a script the inliner missed fails the payload instead of shipping a dead URL

* refactor(catalog): the leftover-script check reuses the reference finder

* fix(catalog): frost and glass textures load from the CDN and the iteration presets are gone

* chore(catalog): regenerate the frost and glass payloads and nav

* fix(catalog): frost and glass textures are hosted through the registry url field, not hardcoded

* chore(catalog): regenerate the frost and glass payloads against the hosted texture URLs

* fix(catalog): the hosted-reference rewrite refuses a bare name it cannot safely replace

* ci(catalog): verify changed catalog payloads in headless Chrome and check the docs catalog

* feat(catalog): every item page follows the shared layout with a Tune panel and tabs

* fix(catalog): let the texture-mask item host its masks so its tile plays live

* chore(catalog): regenerate item pages, gallery data and the texture-mask payload

* chore(catalog): regenerate item pages after merging main

* fix(catalog): declared-only variables get the Tune panel, Code and Docs tabs capped

* feat(catalog): give the blend-difference caption a demo scene so its tile plays live

* chore(catalog): live payload for the blend-difference caption

* perf(catalog): heavy 3d pieces compile and draw before ready, not on the first visible frame

* feat(catalog): the avatar promo card hosts its videos, audio and fonts on the CDN

* feat(catalog): the avatar promo card tile plays live from its CDN-hosted media

* chore(catalog): the avatar promo card plays live, no allowlist left

* chore(catalog): shorten two comment blocks

* chore(catalog): trim first-party comment blocks to the comment-check limits

* chore(catalog): shorten two frost source comments

* fix(catalog): the Docs tab renders as prose again, and the detail page honours reduced motion

Frame-mode pages carry no prose wrapper, and the detail component sat inside
not-prose, so the Docs slot's headings, paragraphs, links and lists had no
styling at all. The slots now carry their own prose classes and the chrome
around them keeps not-prose.

The recorded clip also gets the reduced-motion guard the docs-snippet check
requires, and the generator reads stability from the source metadata type so
the scripts typecheck passes.

* fix(catalog): docs-tab paragraphs render as blocks

Mintlify emits paragraphs as inline spans, so two paragraphs in the Docs slot ran together.

* fix(ci): drop the duplicate docs_catalog job output

git's line-based auto-merge silently duplicated the outputs key when
main and this branch each added it on non-adjacent lines during the
rebase; oxfmt's yaml parser rejects the repeated map key.

* fix(catalog): close four CodeQL alerts in the frost source

* chore(catalog): allowlist the avatar-promo-card CDN asset deletions, format sync-docs-catalog

check-no-main-deletions.mjs was rejecting 11 local mp4/font files this branch
already deleted in favor of CDN hosting; scripts/sync-docs-catalog.mjs had one
line over the 100-col print width from an earlier edit in this branch.

* fix(catalog): detail tabs use docs prose spacing, Install first; previews skip no-WebGPU runners

* fix(catalog): the docs tab scrolls with the page instead of a capped inner box

* refactor(catalog): split the payload asset and verify scripts into single-purpose helpers

* refactor(catalog): split the preview generator into single-purpose helpers

* ci(catalog): fail when the committed catalog differs from generator output

The docs host serves the committed catalog and nothing regenerates it, so a stale
copy shipped silently. The catalog payloads job now generates into a temp dir and
compares it with the committed tree.

* chore(catalog): regenerate docs/public/catalog with the refactored scripts

One full regeneration from an empty output tree with the current scripts. The tree grows
from 58.4 MB to 65.1 MB. Two hosted assets are about 2 MB each (an avatar png and a wav);
the same bytes are already committed under assets/, so HF_MAX_NONLFS_KB is raised for
this commit only.

* fix(catalog): run the catalog script tests under the runner they are written for

The rebase put three node:test files in the vitest half of test:scripts, where they report
"No test suite found". They now sit in the node --test list.

* fix(catalog): a WebGPU item without an adapter shows its recorded clip and says why

* ci(catalog): the drift check runs from a committed fetch mirror with no network

Generation now serves every font and CDN script fetch from scripts/catalog-fetch-mirror and fails
when a fetch is not in it, even if the caller swallowed the error. CATALOG_FETCH_MIRROR=record
refreshes the mirror from the live network. The docs_catalog filter also watches packages/core,
packages/producer and the mirror, and the 13 re-hashed font files are listed in ALLOWED_DELETIONS.

* chore(catalog): clean up the temp font cache and sort the mirror index without a locale

* fix(catalog): hide Tune when the clip stands in, time out the adapter request, share one check

* fix(catalog): the adapter probe survives a sync throw and a hang; test the clip guard

* fix(catalog): keep the adapter probe inside the component, Mintlify evaluates only exports

* fix(catalog): run the detail and page generator tests in CI; no Tune flash while probing WebGPU

* docs(catalog): credit vendored libraries and make each SKILL.md list what the block installs

CREDITS.md names every vendored dependency with its upstream and licence, and flags the two leaf
textures whose origin is still to be confirmed. Each SKILL.md Files table now comes from the block
manifest and its install and render paths point at compositions/<name>/. A test checks that every
path a SKILL.md names is a manifest file that exists.

* fix(catalog): a declared WebGPU item passes verify by its manifest tag, not by console wording

verify-catalog-payloads forgave a missing adapter only by matching the markup and the message, which
library-reported errors and widgets never satisfy. The webgpu tag in the item manifest is now the
owner: a tagged item ignores its page and console errors, an untagged one gets no forgiveness, and a
marker payload with no html is listed instead of crashing the run.

* docs(catalog): correct the credits per block and guard the mount and render paths

CREDITS.md states the three.js version and vendored-or-CDN status per block, adds d3-delaunay,
Clipper on the CDN and the Google Fonts loads, and lists every texture with no known origin.
The SKILL.md test now also checks the mount and render paths against the manifest composition.

* test(catalog): read each block manifest once in the SKILL.md file test

The two checks shared a copied manifest read, which fallow flagged as a clone with a high score.

* fix(catalog): commit the hosted item files a gitignore rule kept out, and check git not the disk

The compositions/ and *-ad/ ignore rules matched folder names under docs/public/catalog/items,
so 33 hosted fonts, textures and marks were generated but never committed and would 404 on
the docs host. The rules now have a negation for that tree and the files are added.

The drift check read the checkout, where git-ignored files still exist, so it passed locally
and failed only in CI. It now unpacks HEAD with git archive and compares that.

* fix(catalog): a tile keeps its poster until the composition has painted

* fix(catalog): a tagged WebGPU item forgives only the adapter-absent error, and test the wiring

The manifest webgpu tag now decides whether an item may lack an adapter, and the error wording
decides which lines are that absence. Any other page or console error on the item, and every
failed request, still fails. The per-item decision is one exported function the tests drive,
marker payloads are parsed by a tested helper, and the SKILL.md variable counts are checked
against their tables (cuboid-carousel said forty-nine for forty).

* fix(catalog): the pinned group's posters load eagerly at high priority

* fix(catalog): type the tile reveal test so the scripts typecheck passes

* fix(catalog): stop the adapter signature matching an unrelated failed adapter request

failed to request .*adapter also matched errors about other adapters. It now matches only a
request for the WebGPU adapter, so a tagged item cannot hide an unrelated failure behind it.

* test(catalog): a tagged item still fails on an error about some other adapter

* feat(catalog): the install command sits above the preview, as on the live catalog

* fix(catalog): find the install slot through Mintlify's child wrapper

* fix(docs): catalog copy buttons keep their size when the label swaps to Copied

* refactor(docs): name the shown copy label once in the catalog copy button

* fix(docs): primary catalog button keeps a readable label on hover and after copy

* fix(docs): inset the catalog tab strip and tab content to match the boxed panels
2026-09-19 16:53:29 -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 b09619faa8 refactor(studio): preview consumers follow the live iframe from one shared owner (#4182)
* 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.
2026-09-19 16:08:15 -04:00
Miguel Ángel 9bec64a293 feat(studio): header and inspector tabs on the shared primitives (#3624)
* 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
2026-09-19 15:49:17 -04:00
Miguel Ángel fd6049f356 refactor(studio): one hook owns file undo and redo (#4189)
* 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
2026-09-19 15:33:12 -04:00
Miguel Ángel 3ec26b5e27 feat(studio): shared menu, popover and value-control primitives (#4191)
* 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.
2026-09-19 15:33:01 -04:00
Miguel Ángel e505dcc5c5 feat(studio): dock dividers and tabs work from the keyboard and side columns fit narrow windows (#4186)
* 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
2026-09-19 15:10:54 -04:00
Miguel Ángel c610d8491c fix(cli): the contact sheet png test no longer waits on a Windows font cache build (#4181)
* fix(cli): keep the contact sheet png test within its budget on slow runners

sharp's bundled Pango/Fontconfig backend enumerates the OS font
directories on first text render and builds an on-disk cache before it can
draw the sheet's labels. On Windows CI that first-render scan is
unbounded (measured 12s-103s here for otherwise-millisecond work), which
is what the 60s ceiling was really absorbing. Point Fontconfig at an
empty, per-run config on win32 so label rendering has nothing to scan,
and bring the budget down to reflect the real raster work.

* test(cli): keep the createContactSheet timing label to measure the Windows font fix

Diagnostic: the Windows job on this branch prints createContactSheet's wall time

with the one-font Fontconfig override, to compare against 3.7-18.4s without it.

* test(cli): temporary diagnostic, time the first svg and first svg text render

* test(cli): temporary diagnostic, first text render in a child process

* test(cli): set the Windows Fontconfig file in the vitest config, before workers start

* test(cli): drop the contact sheet timing diagnostics

* fix(cli): the vitest Fontconfig dir is created with an unpredictable name and removed on exit
2026-09-19 14:28:31 -04:00
Miguel Ángel 01aa446edf feat(studio): shared Button, IconButton, Tooltip and Tabs primitives (#3622)
* 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
2026-09-19 13:59:30 -04:00
Miguel Ángel fd020ca27c feat(studio): host apps can import the render queue from the package (#4184)
* feat(studio): host apps can import the render queue from the package

* feat(studio): export FfmpegStatus and exercise it in the host import test
2026-09-19 13:21:52 -04:00
Miguel Ángel 8d07221156 feat(scripts): fail a PR whose After capture is a Before capture (#4177)
* feat(scripts): fail a PR whose After capture is a Before capture

The captures check now downloads each Before and After asset in the PR
body and fails when any After asset has the same content hash as a
Before asset, or when an asset cannot be downloaded. A Before clip
attached under After passed the old check because it only looked for
media, not for what the media was.

* refactor(scripts): return the hashed captures directly

* fix(scripts): the capture hash check only fetches GitHub attachments

The check fetched any URL a PR body named and followed redirects, so a
body could make the runner request an internal host. It now downloads
only github.com/user-attachments assets and follows redirects only to
GitHub and its signed storage. A 429, 5xx or network error is retried
with backoff, other statuses fail at once, and unreadable assets and
identical assets are reported under separate headings.

* fix(scripts): cap capture downloads, match GitHub's asset bucket, pass forks through

The redirect allowlist now names GitHub's asset bucket instead of all of amazonaws.com.
Each asset and the whole check are capped in bytes while streaming.
A fork PR whose capture cannot be downloaded is skipped with a named reason, never failed.

* fix(scripts): one download deadline, https only, same link caught before fetching

Reviewer findings on the capture hash check:
- each asset gets one deadline across its retries
- downloads run one at a time so a large asset cannot starve a sibling
- a link under both headings is reported without a download
- a read error is no longer reported as a download failure
- the CLI fork skip and exit code are tested through a preloaded fetch

* fix(scripts): a byte-cap refusal blocks even on a fork, retries refund their bytes

- our own cap tripping is reported apart from an unreadable download and always fails
- a failed attempt gives its bytes back to the shared budget
- trailing punctuation and query strings no longer change which asset a link names
- more than 12 capture links is refused, and the job timeout is raised to fit

* fix(scripts): strip markdown wrappers from capture links, count shared links once

The twelve-link limit is tested at its edge.

* refactor(scripts): split the capture download code into small functions

Fallow scored the download, redirect and reporting functions as untested because the scripts
suite runs under node --test. Each function is now small enough to pass without coverage.
Links the duplicate check cannot compare are now named: an http attachment link is refused,
other media links are listed.

* fix(scripts): compare links by identity, name the refusal, pin three behaviours

An explicit port or upper-case host no longer reads as an uncomparable link.
2026-09-19 12:45:17 -04:00
Miguel Ángel aa05278b78 feat(cli): render events carry the browser's build and install-path shape (#4180) 2026-09-19 12:31:33 -04:00
Miguel Ángel 2d8d44151a fix(cli): a Studio render refuses with the browser check's own message (#4176)
* fix(cli): a Studio render refuses with the browser check's own message

* test(cli): the Studio browser refusal test no longer loads the real producer
2026-09-19 12:25:08 -04:00
Miguel Ángel 93ff9a994d feat(studio): fail the suite on a class that resolves to nothing (#3623)
* 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
2026-09-19 12:02:52 -04:00
Vance Ingalls 1dce615c56 chore: release v0.8.51 (#4178) v0.8.51 2026-09-19 08:51:27 -07:00
Vance IngallsandClaude Opus 5 ff078accda feat(studio): say why the SDK session's file read came back empty (#4161)
* 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>
2026-09-19 08:34:01 -07:00
Miguel Ángel fa44d7621d feat(core): a composition with no authored length takes it from its clips (#4174)
* feat(core): a composition with no authored length takes it from its clips

A new resolver in @hyperframes/parsers returns a composition length as authored,
derived from its latest clip end, or unresolved, and counts clips whose length is
still pending. The runtime uses it in its two existing max-end loops with identical
results, and as a last resort only when no timeline, floor or caller length exists,
so a composition that has a length today keeps it. The derived source is published
on window.__hf.durationSource for render telemetry.

Lint reports a root that has timed clips but no data-duration as a warning that says
where to author it, and still errors when there is nothing to derive from.

* refactor(core): resolve the runtime duration floor in one call

The declared duration and sub-composition ends were resolved separately and
maxed by hand; one resolver call over both gives the same latest end.

* fix(core): a derived composition length waits for every clip length and reports honestly

Review follow-up. The runtime returns a content-derived length only when no clip is
still pending, so a renderer that reads duration once cannot lock in a short one.
window.__hf.durationSource is cleared whenever a timeline or floor supplies the length,
and the diagnostic fires only for a derived length. Lint says "at least Ns", counts
reference-start clips as known only at runtime, and ignores a data-end before data-start.

* fix(core): a sub-composition with no known length counts as pending

A host whose own timeline is not registered yet no longer lets the content-derived length
show early. Tests pin the pending host and that the diagnostic fires only for a derived length.

* fix(core): a loaded Lottie with no registered animation keeps the length pending

Lottie registers from author scripts with no DOM marker, and nothing re-derives the length
after it registers. A composition with clips and a loaded Lottie library therefore stays at
zero, as before, instead of locking in the clips length and cutting the animation short.

* test(core): a registered Lottie supplies the length and the clips length is not used

Pins the other side of the pending Lottie rule. Once an animation reports a length it enters the
duration floor, so the content-derived fallback is never consulted.

* test(core): the declared and DotLottie pending branches are covered
2026-09-19 11:27:33 -04:00
Miguel Ángel ac0d8ced2a fix(cli): timeline lists every clip of a kind in one flat list, nested ones included (#4173)
Text: one absolute-start list per kind, nested clips marked with host and file.

JSON: a track's rows hold every clip of that kind; children are index pointers, not copies.
2026-09-19 10:36:30 -04:00
Miguel Ángel f4e00ce2b5 fix(runtime): a finished film rests on its last frame instead of a blank one (#4158)
* fix(runtime): a finished film rests on its last frame instead of a blank one

The clip-visibility check was half-open (start <= t < end), so a clip whose
authored end reached the composition's total duration hid at exactly that
instant. The player parks there when playback ends, so a finished film
went blank instead of holding its last frame, in both the published
player and Studio's scrub-to-end.

Extracts the shared half-open comparison used in seven places into
isInClipWindow (packages/core/src/runtime/clipWindow.ts) and adds
isClipVisibleAt, which additionally treats a clip reaching the
composition's terminal instant as visible. Only the DOM-visibility gate
uses the terminal rule; playback and media-activation windows keep the
plain half-open check, so two back-to-back clips still never both show
at their shared boundary and audio never re-activates past the end.

* fix(runtime): hold a terminal video on its last frame at the composition end

* refactor(player): route the proxy clip-window checks through the shared predicate

Makes the composition duration required on syncRuntimeMedia so a caller
cannot drop the terminal rule, exposes the predicate as a core subpath,
and adds a nested-timeline float-noise test.

* build(core): declare the runtime/clip-window subpath in the package-subpaths descriptor

* test(runtime): a speed-ramped video that runs to the composition end holds its last frame

Covers the composition of the terminal hold with a rate lane: at a time
past the clip end the video sits at the source time of its own end.
2026-09-19 10:35:26 -04:00
Miguel Ángel 3f3f3b7f10 fix(engine): audio source-not-found names the file and shares one src resolver with lint (#4172)
* fix(engine): audio source-not-found names the file and shares one src resolver with lint

* fix(engine): audio source-not-found prints the resolved path with forward slashes on Windows

* fix(parsers): the shared src resolver ignores a media fragment like the browser
2026-09-19 10:07:22 -04:00
Miguel Ángel 7597b80c95 fix(cli): timeline reports real media durations instead of unauthored (#4169)
* fix(cli): timeline resolves real media durations instead of printing unauthored

Media rows call the shared resolveMediaDuration with an ffprobe source length.

Each row shows its duration source, or pending with the reason a probe failed.

Absolute time (absStart/absEnd) is carried across sub-compositions.

Remote, absolute and parent-relative srcs are never probed.

* fix(cli): timeline probe leaves the authored-duration rule to the resolver

The gate test now launches jobs while slots are held.

* fix(cli): timeline reports a zero-length probe as pending instead of a measurement

A still image or duration-less container is pending with a reason, never duration=media.

The runOneLiner test helper now cleans up its own fixture directory.

* fix(cli): timeline probe is injectable and the fixture path works on windows

Two unit tests used real ffprobe, which the Ubuntu Test job lacks, and the audio

fixture path doubled the drive letter on Windows. describeProject now takes the

measure function, the tests pass a recorded fake, and one real-ffprobe test remains.
2026-09-19 09:27:28 -04:00
Miguel Ángel 2f24573395 docs(skills): an agent adds media with the duration Studio gives a dropped file (#4144)
* docs(skills): an agent adds media with the duration Studio gives a dropped file

* docs(skills): add-media recipe matches Studio's full-frame geometry and z-index

* docs(skills): recipes start with hyperframes timeline to see tracks and clips

* test(skills): split the add-media drift test so each check stays small and formatted

* test(skills): split addMediaFacts into single-purpose helpers to clear the CRAP-score gate

* docs(skills): add-media recipe needs only data-start for video and audio

* docs(skills): add-media intro names the video and audio difference and a test guards it

* docs(skills): add-media image duration is optional and defaults to 3 seconds

* chore(skills): regenerate the skills manifest after rebasing onto main

* docs(skills): tracks-and-clips says image duration is optional, and a test pins the 3

* test(skills): split the add-media example check into small helpers

Clears the Fallow complexity finding on the test callback.
2026-09-19 09:07:32 -04:00
Miguel Ángel dc94692441 feat(studio): dock engine for a movable Studio layout, not yet wired in (#4165)
* 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
2026-09-19 08:42:15 -04:00
Miguel Ángel 09aadd86a5 feat(core): video, audio and image length come from one shared resolver (#4166)
A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.

The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.

The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.
2026-09-19 08:01:28 -04:00
Miguel Ángel bdecae066a feat(engine): render speed ramps with the same source-time mapping as the preview (#4143)
* feat(engine): render speed ramps with the same source-time mapping as the preview

The frame extractor, frame lookup table, coverage check, compile-time duration and audio mixer
read a clip's rate lane through sourceTimeAt, so a rendered frame at t shows the source frame
the preview shows. Audio bakes a ramp as consecutive source slices stretched by their mean
rate, keeping pitch. The compile-time attribute reader now handles single-quoted and
entity-encoded JSON attributes. Adds a speed-ramp regression fixture.

* test(producer): regenerate the speed-ramp golden audio with the current engine

The stored baseline predates the per-slice audio fix, so its audio drifted 1 to 3 percent in
pitch from the expected source-time track and the CI render correlated at 0.61. Rendered in the
CI test image, the new baseline tracks the expected pitch to 0.1 percent median, and the compare
now reports visual 0 failed frames and audio correlation 1.000.

* refactor(core): drop data-preserve-pitch, pitch is preserved by construction

The engine always time-stretches audio with atempo, so an opt-out read only by
the preview made preview and render disagree. Remove the attribute and its
reader so both paths keep pitch.
2026-09-19 08:01:17 -04:00
Miguel Ángel 45f92e5572 fix(cli): a failed docker render names the container's last error (#4168) 2026-09-19 07:31:39 -04:00
Miguel Ángel 35a4fe1da7 fix(producer): stop disk capture early when frames cannot fit on disk (#4164)
* fix(producer): stop disk capture early when frames cannot fit on disk

* test(producer): a measured disk shortfall is never retried as a browser death
2026-09-19 06:27:54 -04:00
Miguel Ángel 7f452ad276 feat(core): speed ramps as a rate lane in data-automation (#4129)
* 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
2026-09-19 06:27:42 -04:00
Miguel Ángel 86667203ce feat(studio): trimming a clip shows the snap guide and the frame at the dragged edge (#4163)
* 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
2026-09-19 06:08:23 -04:00
Miguel Ángel a94466256e ci: require before and after captures on studio and player PRs (#4131) 2026-09-19 05:41:33 -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 e8fdfd0d65 fix(cli): install onnxruntime-node and google-genai on first use (#4156)
* fix(cli): install onnxruntime-node and google-genai on first use

Both carry deprecated transitive dependencies (boolean, node-domexception)
that showed up as npm warnings on every npx hyperframes install. They move
to devDependencies (types and the Intel-binding check still need them at
build time; the bundler externalizes them) and load through a new
on-demand loader that installs into the CLI's own cache dir the first
time a feature needs them, with one stderr line, no prompt, and a
manual-install command named in the error if the network is unavailable.

hyperframes doctor lists each as installed (with version) or
not-installed-yet.

* fix(cli): sweep stale install staging and bound network retries

* fix(cli): optional-package install only sweeps staging dirs whose process is dead

Staging dirs now carry a per-call suffix after the pid, and the sweep skips any dir
whose pid is alive, so concurrent first runs no longer delete each other's install.

The install error suggests the network only for network failures, and the manifest
path has one owner. install() takes an injectable npm runner so tests drive the real code.

* fix(cli): optional-package sweep also clears old-format staging dirs and tests a live foreign dir

The concurrent-callers test shared one pid, so it never exercised the sweep rule; a live foreign
staging dir test now does. Old-format dirs of a dead pid are swept, and a failed cleanup no
longer aborts a viable install.

* fix(cli): optional-package sweep names a stale dir it could not remove

A locked leftover is still skipped so the install proceeds, but the warning makes it visible.

* docs(cli): shorten the install docblock to the comment limit

Comment-only change.
2026-09-19 05:27:35 -04:00
Miguel Ángel 339dc76026 feat(studio): move every design value into one theme file (#3621)
* 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
2026-09-19 04:48:05 -04:00
Miguel Ángel f50df37671 feat(studio): the Lint badge pulses while an error finding exists (#4152)
* 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
2026-09-19 04:47:53 -04:00
Miguel Ángel 17b0c26896 fix(lint): video and audio take their length from the file, only an img needs data-duration (#4154) 2026-09-19 04:47:39 -04:00
Miguel Ángel 0a5c235173 feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* 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.
2026-09-19 04:27:45 -04:00
Miguel Ángel 73f39862f2 fix(producer): retry a dead browser tab once on the single-worker capture path (#4162)
* fix(producer): retry a dead browser tab once on the single-worker capture path

* fix(producer): keep the caller-owned dedup perf list untouched on sequential retry

* test(producer): extend the streaming-stage orchestrator mock with the retry exports
2026-09-19 04:27:34 -04:00
Miguel Ángel 0915affe97 fix(studio): button children lay out with the consumer's gap (#4159)
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.
2026-09-19 03:23:51 -04:00
Miguel Ángel 607775bb3a fix(engine): make urlDownloader retry tests independent of wall-clock time (#4155) 2026-09-19 03:07:27 -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 559e57d295 fix(parsers): unroll keeps tween order and label positions the parser read (#4157)
* test(parsers): pin unroll order and side-effect soundness

* fix(parsers): resolve helper tweens in source order and unroll only pure tween helpers

* fix(parsers): unroll keeps a helper whose tween chain ends in a non-tween call

A helper like tl.to(...).call(fn) was treated as adding only tweens, so unroll
dropped the callback. Only chains of set/to/from/fromTo count now.
Also reuses isFunctionNode and names the unroll scope.

* fix(parsers): keep chain order, label positions and chain callbacks when unrolling

Review found three ways unroll still changed a timeline:
- a helper call positioned by a label was read at 0 (label order used raw loc)
- a tween chain added by a helper ran in reverse (outer call stamped first)
- an inner chain link with a callback was unrolled and lost the callback

* fix(parsers): resolve a label defined inside an inlined helper at its call site

addLabel was never tagged during helper expansion, so a cloned label kept
its declaration-body offset while its sibling tweens were remapped to the
call site. Comparing the two put every such label before every tween.
addLabel now gets the same site/order stamp, with order as the tie-break
when a label and its own tween share a call site.

* test(parsers): assert the sibling-label unroll rewrite matches its parsed start

The only literal-rewrite path this fix actually exercises (the label-defining
helper itself is never unrolled, since onlyAddsTweens rejects addLabel).

* refactor(parsers): unroll's effect scan reuses acorn-walk like its sibling

containsEffect hand-rolled a generic object walk; hasUnsafeLoopIndexUse in
gsapWriterAcorn.ts already solves the same shape with acorn-walk.full.
2026-09-19 02:47:20 -04:00