Commit Graph
201 Commits
Author SHA1 Message Date
Miguel Ángel d290c05b09 fix(cli): --yes in an unattended run no longer overrides a no saved by another process (#4547)
* fix(cli): --yes in an unattended run no longer overrides a no saved by another process

* fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no

* fix(cli): the thin-results download offer also needs a real terminal

* fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes

* fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no

* fix(cli): a settings lock that cannot be released no longer fails a write that landed

* fix(cli): a leftover settings lock is never taken over, and doctor names it

* fix(cli): a background update that cannot be recorded is not launched again every run

* fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing

* fix(cli): the thin-results offer stops when a yes cannot be saved

* fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once

* fix(cli): the leftover-lock warning follows the attended-terminal rule
2026-09-27 01:39:17 +00:00
Miguel Ángel 8798e40da8 fix(registry): carousel cards fall back to their sample image inside the assets folder (#4543)
* fix(registry): carousel cards fall back to their sample image inside the assets folder

When an image variable resolves to nothing, the 25 carousel blocks loaded the
sample card by bare file name, which 404s beside the block and draws a white
card. The fallback now keeps assets/carousel-images/, the folder every card
image is installed into. A guard test fails on any block with a bare fallback.

* test(registry): the carousel fallback guard covers every block that uses it

The guard now picks each block that reads an image variable and fails when its
fallback no longer matches, instead of silently skipping it, and ignores stray
files in the blocks folder.
2026-09-26 15:26:19 +00:00
Miguel Ángel c238abfe6f fix(media-use): tests never write into the person's media library (#4459)
* fix(media-use): tests never write into the person's media library

* fix(media-use): guard the media home only in this repo's test runs

* test(media-use): probe the media home from a plain child process

* fix(media-use): keep miss logging best-effort when home is unresolvable

* test(media-use): miss logging survives an unresolvable media home

* docs(media-use): describe the media library without naming an app

* chore(media-use): refresh the skills manifest hash
2026-09-26 14:13:56 +00:00
Miguel Ángel 81d8a03d2e fix(repo): keep one fallow audit base cache per checkout instead of one per main commit (#4525) 2026-09-26 07:36:06 +00:00
Miguel Ángel fbaa9f9205 ci: stop running CodeQL on merge queue groups, where its upload races the merge (#4492)
* ci: stop running CodeQL on merge queue groups, where its upload races the merge

The code scanning rule does not apply to merge queue groups, and the queue deletes
a group ref when it merges, so the upload failed on about half the groups.
Pull requests and pushes to main keep scanning.

* ci: catch merge_group in any trigger form and say the upload race is intermittent
2026-09-25 16:50:55 +00:00
Miguel Ángel 828c434f76 refactor(studio): drop recordEdit's kind, which the project history never read (#4474) 2026-09-25 10:48:46 +00:00
Miguel Ángel 36f8bd0e91 feat(studio): undo and redo step through the project history, whoever made the change (#4456)
* feat(studio-server): an undo taken from Studio is not mistaken for an outside edit

* feat(studio): undo and redo step through the project history, whoever made the change

* feat(studio): the dev server keeps each project history, in the same folder as preview

* fix(studio-server): an undo that deletes a file reads as Studio's, and dev histories close

* fix(studio-server): a claim keeps an agent's earlier write the agent's and takes back Studio's own

* chore: allow the removal of Studio's browser edit history files

* test(studio): mock the project history in the runtime smoke

* fix(studio): step past a jammed undo and say why a step fails

* fix(studio-server): an agent turn cut by a claim commits its part before the claim

* fix(studio-server): a claim's cut ends a held drag and takes only the cut files out of a turn

* fix(studio-server): a held drag ends when someone else changed a file it already holds
2026-09-25 08:43:04 +00:00
Miguel Ángel fc8cb86ef6 ci: check TODO owners, public URLs and source links in comments (#4453)
* ci: check TODO owners, public URLs and source links in comments

* fix(ci): a TODO's issue link must be a github.com issue URL
2026-09-25 04:43:59 +00:00
Miguel Ángel 7c634426fd docs(cli): explain how catalog search works and which model it downloads (#4462)
* docs(cli): explain how catalog search works and which model it downloads

* docs(cli): name the model catalog search was chosen against

* docs(cli): keep only live rules in the word-search header comment

* docs(cli): correct the hosted vector size in the local vector build comment
2026-09-25 04:43:54 +00:00
Miguel Ángel 53e2e20a15 fix(registry): six blocks load their own files and variables when mounted (#4452)
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl

* test(core): a template composition inside a mounted file resolves assets from that file

* docs: assetUrl is for paths beside the composition, not project-root paths

* docs: say assetUrl misses a project-root path

* fix(core): a mounted composition's import map and module scripts run as such

* refactor(core): scope a mounted module script under the same condition as a classic one

* fix(core): a mounted module script loaded by src stays a module

* fix(core): a mounted import map keeps the trailing slash of a prefix address

* fix(registry): six blocks load their own files and variables when mounted

* fix(registry): keep frost's variable declarations on its html element

* fix(registry): orbit-card uses its own variables; frost font and catalog maps follow

* chore(core): keep the module-script helpers' comment share at the base

* fix(catalog): frost's payload runs its driving script, not the asset-base one

* test(catalog): check import-map keys by membership, not a built regex
2026-09-25 04:43:43 +00:00
Miguel Ángel 0e34d69641 ci: comment checks grade branches cut before their scripts existed (#4466) 2026-09-24 23:28:36 -04:00
Miguel Ángel 3520c8225c ci: a package file's comment share may not rise (#4445) 2026-09-24 22:06:50 -04:00
Miguel Ángel 3194c2ae87 ci: grade the comments each PR touches (#4444) 2026-09-24 20:26:15 -04:00
Miguel Ángel 8dabc8af63 fix(registry): glass-shard-title renders when mounted, and 4 skipped blocks install again (#4423)
* fix(registry): glass-shard-title renders when mounted, and 4 skipped blocks install again

* fix(registry): glass-shard-title loads in Studio and ?play, and its docs payload fails loudly

* test(scripts): pin the glass payload's hdr guard for any quote style
2026-09-24 13:08:15 -04:00
Miguel Ángel 096a586b41 ci: editing a PR's text no longer cancels its running checks (#4420)
* ci: editing a PR's text no longer cancels its running checks

CI, regression and Windows render re-run on pull_request edited (for base changes) with cancel-in-progress, so a body or title edit cancelled the running checks and the fail-closed Test aggregator went red on the cancelled run. Only a push or a base change cancels now; a text edit queues behind the running run.

* test(ci): read the workflows folder by path, not as a module URL

Fallow treated new URL('../.github/workflows/', import.meta.url) as an unresolvable import. Use import.meta.dirname and join, as scripts/check-skill-mirror.mjs does.
2026-09-24 11:19:00 -04:00
Miguel Ángel d1048cf98f fix(studio): ship component styles as their own stylesheet for embedders (#4393)
* fix(studio): ship component styles as their own stylesheet for embedders

Studio's component rules (timeline clips, loaders, range inputs, toasts and
the rest) lived only in the app sheet, so a page that embeds Studio's
components with theme.css got unstyled clips and dark-on-dark labels. They
move unchanged into components.css, exported as
@hyperframes/studio/components.css; studio.css keeps the app shell and
imports it, so Studio's compiled CSS has the same rules.

* test(studio): read moved component rules from components.css

* test(studio): catch class rules nested in the app sheet's layers and media blocks
2026-09-24 01:35:04 -04:00
Vance IngallsandClaude Sonnet 5 8d8ea810b6 fix(scripts): retry a fresh GitHub attachment's transient 404 in the captures gate (#4362)
* fix(scripts): retry a fresh GitHub attachment's transient 404 in the captures gate

Reproduced this week: the same attachment URL 404'd, then 200'd minutes
later with no edit to the PR in between, and whichever asset had been
attached most recently was always the one that failed. The gate already
retries 429/5xx with backoff, but treats every other status as final —
so a fresh upload lagging GitHub's own read-path failed the check on
attempt 1.

404 is now retryable, scoped to a GitHub attachment host (isAttachmentUrl)
so a real 404 elsewhere (a typo'd link, a deleted gist) still fails fast.
403 stays non-retryable everywhere, including on an attachment host — it
means something else (auth, a private-repo asset another workflow can't
see), not propagation lag.

Widened the retry window to match: 5 attempts, delays out to 60s, 3-minute
deadline (was 3 attempts / 45s) — the observed lag was up to ~3 minutes.

Tests: split the old combined 404-and-403 test; added the attachment-host
404 retry case and the non-attachment-host 404 still-final case; updated
the backoff test's attempt/delay assertions for the new constants. 73/73
green, oxlint/oxfmt clean.

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

* fix(scripts): stop the captures-gate retry tests from sitting through the real backoff

Review on #4362: `runCli()`'s no-network preload stubs `fetch` but not time,
so the two CLI tests exercising a genuinely-undownloadable capture now sit
through the full 5-attempt/110s backoff per asset (two assets each) — 220s+
per test, pushing `bun run test:scripts` from 15s to 446s and `Test (support)`
(timeout-minutes: 10) to 10m0s with zero margin.

Preload now also collapses every `setTimeout` delay to the next tick, so the
retry logic itself still runs for real (same attempt count, same terminal
"could not download" outcome) — it just doesn't wait. 73/73 tests, 1.1s total.

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

* fix(scripts): split isRetryableStatus to clear fallow's CRAP-score gate

CI's fallow audit flags `isRetryableStatus` at CRAP 30.0 (cyclomatic 5,
threshold 30.0) — pre-existing since the original commit, not something the
prior test-timing fix touched. Split into `isServerRetryable` (429/5xx) and
`isRetryableAttachment404` (404 scoped to the attachment host), each at
cyclomatic 2-3, composed by the still-exported `isRetryableStatus`. Same
retry decision, no behavior change. 73/73 tests, oxlint/oxfmt clean.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:55:04 -07:00
Miguel Ángel 29fc95395d fix(skills): keep script imports inside installed skill directories (#4356)
* fix(skills): keep script imports inside installed skill directories

* refactor(skills): simplify standalone module checks

* fix(skills): carry the brand-logo lookup into the media-use copy of media-fetch
2026-09-23 19:09:09 -04:00
Miguel Ángel 922be55e55 fix(skills): preserve captured font subsets (#4358) 2026-09-23 17:19:08 -04:00
d49e7b95e0 feat(media-use): resolve brand logos from theSVG (#4291)
* feat(media-use): resolve brand logos from theSVG

Replace the svgl and simple-icons tiers of the `logo` cascade with a
single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand
SVGs with color, mono, light/dark and wordmark variants.

The tier fetches one manifest pinned to a glincker/thesvg commit on
jsDelivr, memoizes it per process, and matches entities locally by
normalized slug, title, or alias. That drops the hand-kept svgl alias
and simple-icons slug maps. GitHub org avatar and favicon stay as the
fallbacks for brands theSVG lacks.

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

* fix(media-use): sanitize fetched SVGs and gate theSVG by license

freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized;
the theSVG tier is now the sole SVG-returning logo provider, so an
inline script tag or on-event handler would land verbatim in a user
project. Route .svg destinations through the existing Figma sanitizer
(new @hyperframes/core/sanitize-svg subpath, synced via
package-subpaths).

thesvgMatch never checked theSVG's own license field: entries theSVG
itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an
explicit no-redistribution-license string) could still resolve and
freeze into a project. Gate matches to the ten SPDX ids theSVG's
submission form accepts; a rejected entry falls through to a lower
ranked theSVG match, then to the github/favicon tiers.

Also caps the theSVG manifest fetch at 20MB, streamed, matching
freeze.mjs's existing cap discipline. It had a timeout but no bound.

* chore(skills): regenerate skills-manifest for media-use/motion-graphics

The original submission edited skills/media-use and
skills/motion-graphics docs without regenerating skills-manifest.json;
its own CI never ran (fork approval gate), so the drift went
unnoticed until this pass's first CI run caught it.

* fix(media-use): drop the sanitizer's workspace import, add parity test

freeze.mjs importing @hyperframes/core/sanitize-svg broke the
test-skills CI lane: packages/cli/src/media-use tests run with node
builtins only, zero install, by design (media-use is vendored
standalone into other projects, e.g. packages/desktop). Port the
sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the
now-unused core export.

Parity between the port and packages/core/src/figma/sanitizeSvg.ts is
enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs,
run in the test:scripts lane, which does have an install) over a
hostile-SVG corpus, so the two cannot drift silently.

* fix(media-use): close three sanitizer bypasses, add fetch timeout

Independent review of the maintainer pass found the SVG sanitizer
(packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own
port) was itself bypassable: an unquoted href/xlink:href survived,
inline style="..." attributes were never checked, and a href bound
through a renamed XML namespace prefix (xmlns:x=".../1999/xlink",
x:href=...) slipped past the literal "xlink:href" match.

Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of
literal-spelling regexes: parse each tag's attributes in any quoting
form, resolve the local name after stripping any namespace prefix,
then drop by local name (any on* handler, any *href whose value is
not a #fragment or data:image/ embed, any style attribute containing
url(, @import, expression( or javascript:). Ported the fix into
media-use's svg-sanitize.mjs identically. Legitimate url(#id)
references on non-style attributes (clip-path, fill) are untouched.

Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching
every other fetchMedia call in this package - it had a size cap but
no deadline.

New cases for all three bypasses in both sanitizeSvg.test.ts and the
parity corpus, each reproducibly failing against the prior head.

* fix(media-use): replace regex SVG sanitizer with a DOM allowlist

Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses
in the same shape each time (unquoted href, style= url(), aliased
xlink prefix; then presentation-attribute url(), unquoted '=' handling,
SMIL animate/set href rewrite, self-closing-slash tag corruption,
data:image/ navigation via <a>). Denylisting attribute names against a
hand-rolled tokenizer was the wrong approach regardless of how careful
the regex.

Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real
DOM walk (linkedom, already a dependency of core and cli) with an
allowlist: known-safe elements and attributes only, everything else
dropped by construction rather than pattern-matched as dangerous.
href/xlink:href (any namespace prefix) kept only for a same-document
fragment, or data:image/ off non-<a> elements. Any attribute value or
<style> text containing url() kept only when every url() argument is a
fragment; @import/expression(/javascript: dropped from style content.
Serializing from a real DOM means output is always well-formed, so the
self-closing-slash corruption class of bug is gone by construction.

media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs)
can't import linkedom directly: its CI test lane runs bare `node --test`
with no install step, so even an external dependency fails to resolve,
not just a workspace import. It's now a generated file instead of a
hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core
source (esbuild, minified, zero remaining imports) and
scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build
on drift. Verified the generated file runs with plain node in an empty
directory with no node_modules on the path.

Every payload from both independent-review reports is now a
table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus
in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the
generated media-use copy agree and each independently rejects every
non-local url(), javascript:, data: href on <a>, and on* handler.
Mutation-proved against the real pre-fix blob, not a synthetic case.

Sampled 56 real theSVG icons across all 7 collections to check the
allowlist doesn't over-strip legitimate content: found and fixed three
missing attributes (fx/fy, enable-background, a blanket data-*
allowance) and one real behavioral bug - <style> elements were being
dropped outright, silently unstyling any Illustrator-exported logo that
colors paths via CSS classes. Fixed by keeping <style> on the allowlist
and content-checking its text the same way as an inline style=
attribute, with a regression test using that exact shape.

Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests
39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean.

* fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint

Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs,
generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's
correctness rules 504 times and fails `bun run lint`. Same shape as the
existing registry/blocks/**/*.iife.js exclusions: a generated, minified,
non-hand-edited bundle, not hand-written source.

* fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt

Same generated esbuild bundle that needed an oxlint exclusion in the
previous commit also fails  (minified, no formatting to
check against), cascading Preflight into Player perf, regression,
Windows render verification, and preview-regression on CI. Same
established pattern as the existing registry/blocks/**/*.iife.js and
skills/**/*.min.js entries in .prettierignore.

* refactor(media-use): reduce complexity flagged by fallow audit

isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker
check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's
per-function thresholds after the rebase pulled in a stricter baseline.
Extracted isXmlnsAttribute/isKnownAttribute as named predicates and
survivingUnsafeMarkers as a named per-output helper; no behavior change,
same guard order, same failure strings. Regenerated svg-sanitize.mjs.

* refactor(media-use): make the parity test's marker check table-driven

Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each
function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers
still scored 9 (CRAP 90). The four regex markers are now a table, and the
data:image-on-<a> and non-local url() checks are small named helpers.
Same checks, same order, same failure strings.

* fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg

The url()/@import checks scanned raw text for the literal "url(" and
"@import". A browser decodes CSS escapes first, so fill="\75 rl(...)",
url\28...\29 and @\69mport all loaded an external resource, and
image-set("https://...") loads one with no url() at all. Reproduced in
headless Chrome: 8 of 8 payloads fired a request through the old code.

CSS can only load a resource through a function or @import, so CSS text
now passes only if it has no @import, every function is on a compute-only
allowlist (color, transform, filter and math functions), and every url()
is a same-document fragment, all checked on escape-decoded, lowercased
text. Same fix reaches media-use through the regenerated bundle.

Checked against all 13,384 files of the pinned theSVG corpus: 13,383
sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)".
The parity oracle now fails on any surviving attacker host, so it no
longer shares the sanitizer's parsing blind spot.

* refactor(media-use): share one capped body reader for freeze and manifest

fetchJson copied freezeUrl's declared-length check and streaming size cap
line for line. Both now call readCappedBody in media-fetch.mjs, which both
already import; it has its own tests, since freeze's cap had none. The
freeze cap errors now read "freeze failed for <url>: ..." (no caller or
test matches the old wording). Also fixes freeze.mjs's comment, which
still called the sanitizer a lexical pass.

* style(media-use): format the parity test corpus

* fix(media-use): make the sanitizer's CSS scans linear on hostile input

The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function
check /([-\w]*)\(/: on attacker padding these backtracked cubically and
quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000
character word took 7.7s), and freeze runs the sanitizer synchronously on
bodies up to 256MB. Both are now single-pass indexOf scans (a million
characters in under 15ms), with the same verdict on ordinary input. A url()
value holding "(" is rejected, so a nested url( can't hide inside one.
A size test holds both payloads under 2s.

* test(media-use): size payloads that fail the old scans fast instead of hanging

* fix(media-use): stop the freeze timeout from aborting long downloads

The 10 s AbortSignal.timeout on freezeUrl also aborted the response body,
so any stock video, generated video or LUT that took longer than 10 s to
download failed. The timer now covers only the wait for response headers
and is cleared once they arrive; the 256 MB cap still bounds the body.

* refactor(media-use): trim logo-provider comments to the repo limit

The tier list and the thesvgMatch doc block ran past the four-line comment
cap that the merge check enforces. Same facts, shorter blocks.

* refactor(media-use): name the header wait and restore the variants note

Renames the freeze timeout controller to headerWait so it no longer reads
as HTTP headers, puts the theSVG variants fact dropped by the comment trim
next to the line that picks the default mark, and keeps thesvgMatch on the
file's JSDoc style.

---------

Co-authored-by: Imad Phelps <imad.phelps@heygen.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-23 16:56:16 -04:00
Miguel Ángel fb0d33c490 fix(ci): scope catalog dispatch and fail closed on detection errors (#4347)
* ci: scope catalog dispatch and fail closed on detection errors

* fix(ci): distinguish full catalog validation dispatches
2026-09-23 14:56:59 -04:00
Miguel Ángel a57e095468 feat(studio): panel tabs take the decided Current design (#4266)
Studio's panels become cards on a dark shell, and each panel's tabs take the "Current" design: the open tab is a fill in the card's colour that slides to the tab you pick, only the open tab shows its icon and close glyph, and a strip that is too narrow scrolls with a soft edge instead of hiding tabs behind a menu. This replaces the pill, underline and close treatment that was on this PR before.

## Before

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

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

## After

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## What changed

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

## Timeline viewport gate

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

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

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

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

## Tests

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

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

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

## Independent review

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

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

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

## UX checklist

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

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

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

## For Miguel

- **Paper (light) theme:** Studio's paper block only re-themes the timeline today, so the panels are dark in both the Before and After paper shots. Panel paper values are Desktop's to set (design principles, section 7); this PR adds the token names Desktop can override.
- **Action slot:** the design review asked for the action slot to be reserved on every strip, so tabs never move when focus changes panel. That reservation is what clipped "Ca" next to empty space, so it now holds room only on the focused panel. The cost: when a strip that is already too narrow gains focus, its actions take 76 px. The strip then scrolls so the shown tab stays whole, and a neighbouring tab can go under the edge fade. At the default 1440 x 900 nothing clips either way.
- **Tab spacing:** side padding is 6 px (10 on the design page) and tabs have no gap. The design's 10 px would need 322 px on macOS; the left strip has 282 px beside its actions at 1440. A wider default left column would allow the design padding again. A narrower window scrolls the strip with the edge fade; per the decision there is no overflow chevron.
- **Close glyph space:** the open tab keeps its close glyph's space at rest, so it is 16 px wider than on the design page, where the label shifts on hover.
- **Not in this PR:** inspector section headers and the focused-card border step (later build steps).
2026-09-23 14:34:10 -04:00
Miguel Ángel ca4af78184 ci: dispatch required checks for catalog publication (#4345) 2026-09-23 13:15:38 -04:00
Miguel Ángel b1cf88aeba ci: generate catalog artifacts through a standing publish PR (#4337)
* ci: generate catalog artifacts through a standing publish PR

* fix(catalog): reconcile obsolete publication snapshots

* refactor(catalog): report failures after staging cleanup

* refactor(catalog): separate generation and publication decisions

* fix(catalog): unpublish deleted items with their sources

* docs(catalog): keep scaffolding guidance concise
2026-09-23 12:09:46 -04:00
Vance IngallsandClaude Sonnet 5 b32b9a7dab feat(runtime): data-vfx-chain — a seek-deterministic per-pixel effect element (#4306)
* feat(core): data-vfx-chain defs and chain parsing (mirrors data-fx-chain)

Mirrors audioFx.ts's shape for the per-pixel VFX chain: a versioned
JSON chain attribute (data-vfx-chain), a def registry with typed
params (number/enum/bool/ref), and parse/serialize/normalize helpers.
HF_VFX holds one def, fractal-noise, with a placeholder frag; the
kernel and its consumers (packages/core/src/runtime/vfx.ts) land in
Task 1.2/1.3 of the vfx-chain-runtime plan, so vfx.ts is added to
.fallowrc.jsonc's entry list for now.

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

* feat(runtime): paint data-vfx-chain canvases on every seek

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

* feat(vfx): fractal-noise kernel (Basic/fBm approximation) with CPU reference

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

* feat(vfx): self capture through layoutsubtree with the page-composite readiness protocol

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

* feat(vfx): wave-warp kernel (Sine)

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

* feat(vfx): displacement-map kernel (self-referential map)

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

* test(vfx): chain order is nesting order; preceding CSS effects are captured

...and following ones apply outside. The plan's one-line message for Task 2.4
is 105 characters; commitlint caps the header at 100, so the tail moved here.

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

* test(vfx): seek determinism and CPU-reference PSNR for fractal-noise

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

* fix(vfx): size the output canvas from the host's layout box, not its transformed AABB

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

* feat(telemetry): vfx node count, capture class and types on render_complete

Threads vfxNodeCount, vfxCapture and vfxTypes from a static scan of the
compiled composition's data-vfx-chain hosts (mirrors the existing
compositionElementCount/colorGradingCount scan) through
RenderPerfSummary.drawElement into the render_complete event, next to
de_capture_mode. Exports chainCapture/parseVfxChain/HfVfxCapture from
@hyperframes/core's index for the producer to consume. The PostHog
dashboard tile is not created here — left for the coordinator.

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

* docs(vfx): data-vfx-chain guide — element shape, capture table, Studio flag, known constraints

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

* fix(vfx): register chain hosts that arrive with a sub-composition mount

`initVfx(document.body, fps)` ran once, synchronously, three lines after
`void loadExternalCompositions(...)` — so every `data-vfx-chain` host that
enters the DOM only when a `data-composition-src` mount resolves was never
registered. An unregistered host paints nothing and reports nothing: no
`[HyperFrames] composition script error`, the `.hf-vfx-out` canvas keeps its
300x150 default, and `paintVfx` never even arms the page-composite flag.
Measured on a real multi-file export served raw: `#main-l5-text`'s output
canvas stayed 300x150 and `window.__timelines` never gained the sub-comp.

Re-scan in the loader's `.finally`, before `maybePublishRenderReady()`, so
registration precedes readiness. `initVfx` now releases the outgoing
registry's programs, capture textures and ping-pong targets first — the
output canvas hands back the same WebGL2 context on a re-scan, so the
previous pass's GL objects would otherwise leak.

Two browser cases in `vfxDeterminism.test.ts`, both against the real runtime
bundle: a host whose `.hf-vfx-in` IS the mount (already worked — the mount
replaces the wrapper's children, not the wrapper, so the registered source
node survives), and a host that arrives INSIDE a mounted sub-composition
(failed with `armed: false` and no error; passes now).

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

* fix(vfx): runtime tsconfig typecheck passes

Under noUncheckedIndexedAccess, resolveCaptureSource's null return
widened VfxEntry.src past its VfxCaptureSource | undefined type; the
function is internal-only, so its return type moves to
VfxCaptureSource | undefined instead. Indexing PingPong.framebuffers
by a non-literal index produced | undefined against
WebGLFramebuffer | null, coalesced to null. No behavior change.

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

* fix(vfx): structural DOM predicates instead of realm-bound instanceof

vfx.ts checked `instanceof HTMLCanvasElement`/`HTMLElement` directly, which
returns false for inner-realm nodes when the Studio previews across an
iframe realm. domRealm.ts had no canvas predicate yet, so this adds
isCanvasElement mirroring the existing isImageElement pattern, then swaps
all three vfx.ts checks to the structural predicates the lint guard requires.

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

* fix(vfx): paint self-capture hosts under hyperframes snapshot, not only the engine path

`paintVfx(t, { engineMode: true })` armed the page-composite protocol and
returned, betting that every capturing chain runs under `frameCapture.ts`.
`hyperframes snapshot` (and check/compare/validate/layout, and Studio's
thumbnail capture) seek through the same `renderSeek`, never read
`__hf_page_composite_pending` and never call `__hf_page_composite_resolve` —
so a `self` chain painted nothing there, in silence, because its content
lives in a `layoutsubtree` canvas the browser never paints directly.

Engine mode now arms the preview-side capture as well and paints on whichever
completes first. A seek token stops a superseded async capture from repainting
a newer frame, and a registry-identity check stops it painting an entry
`initVfx` already released. `resolveVfxCapture` stays non-idempotent on
purpose: the engine resolves only after its own before-capture hooks have
mutated the DOM, so its capture is still the authoritative one.

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

* fix(vfx): an exporter-emitted .hf-vfx-out gets the box the spec fixes for it

findOrCreateOut adopted a canvas the exporter had already emitted without
applying the layout cssText, so it kept the default position:static;
display:inline and wrapped to the line AFTER the layer-wide .hf-vfx-src
canvas — painting a second, misplaced copy of the layer one full layer
height below the host. Measured on retro-wave #main-l6-text: .hf-vfx-src
rect top 315.1 h 93.0, .hf-vfx-out rect top 409.2, both style=null,
position static (ae-mcp findings Task 3.5b). Hosts with no emitted
out-canvas were never affected, which is why only this one host moved.

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

* refactor(telemetry): vfx_node_count counts hosts, not chain nodes — rename to vfx_host_count

The field increments once per `data-vfx-chain` attribute occurrence, never
per node inside a chain's JSON, so the name promised something the scan does
not measure. Renames the internal `vfxNodeCount` and the wire property
`vfx_node_count` to `vfxHostCount`/`vfx_host_count` across the orchestrator
scan, the perf summary, the CLI render path and the telemetry event. No
dashboard consumes the field yet, so the wire rename costs nothing now and
would cost a migration later.

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

* fix(vfx): join the preview capture to the seek-completion barrier, not race it

`paintVfx` fired `capturePreviewThenPaint` and forgot it, so nothing the CLI
awaits waited for a `self` chain's paint. Every snapshot-family command
(`snapshot`, `check`, `compare`, `validate`, `layout`) seeks through
`seekCompositionTimeline`, which drains `window.__hfWaitForSeekCompletion`
and then allows one settle race before screenshotting — and only the
`"hf-seek"` event's `waitUntil()` could put anything in that barrier. The
screenshot therefore landed on however many hosts happened to have finished,
which varied with host count and machine speed.

Extracts the "track, untrack on settle, retain the first rejection" block
`dispatch()` built inline into an exported `registerSeekCompletion`, and has
`paintVfx` register the preview capture through it. Also awaits the per-host
paints with `Promise.all` instead of sequentially: N hosts cost about two
animation frames now, not 2N.

Measured on the pre-fix bundle with two `self` hosts, both output canvases
read `[0,0,0,0]` at the moment the barrier returned; they painted only
because the settle race that follows happened to be long enough. Both tests
added here fail on that bundle.

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

* fix(vfx): report WebGL context loss loudly instead of silently no-op painting

One WebGL2 context per `data-vfx-chain` host, against a browser cap that is
commonly 16 and shared with `colorGrading.ts`'s per-element contexts. A lost
context makes every later call on it do nothing, per spec — so the chain kept
"painting" a blank canvas with no error, which is exactly what the module's
loud-failure contract exists to prevent.

Adds the `webglcontextlost` listener pair's first half, following
`colorGrading.ts`'s precedent: `preventDefault()`, mark the entry, report
once through `reportVfxError`. The entry is then skipped by `paintVfx`,
`capturePreviewThenPaint` and `resolveVfxCapture` — silently, since the one
loud report already happened and repeating it per frame is spam.

Restore is left unhandled on purpose, with the reasoning in the doc comment:
`initVfx`'s existing re-scan already rebuilds every GL object, and a second
narrower rebuild path for `webglcontextrestored` would duplicate it. The
shared-context redesign the review floated is likewise out of scope here.

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

* perf(vfx): cache uniform locations at pass-build time instead of querying every paint

`setPassUniforms` asked the driver for `u_size`, `u_t`, `u_fps` and every
param's location on every paint of every pass, and `bindPass` did the same
for `u_src` on every bind. `buildPasses` already constructs each `VfxPass`
once at init, right after the program links, and a linked program's
locations never change — so they are resolved there now and read from
`pass.locations` in the hot path.

Same values, same `null`-means-optimised-out semantics (`gl.uniform*(null,
…)` is a defined no-op, which is what the per-frame query produced too), so
the existing vfx.test.ts and the browser determinism suite cover it
unchanged — both still green, the latter against a rebuilt runtime bundle.

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

* fix(vfx): bound the paint wait per host so a stalled compositor can't hang the seek barrier

`paintVfx` registers `capturePreviewThenPaint` into the shared seek-completion
barrier (48aaa2bbb), and that promise settled only via `awaitCanvasPaint` — a
race between the canvas `paint` event and a double-rAF chain. Under BeginFrame
control (Linux headless-shell, `drawelement` capture) neither can fire before
the barrier is drained: `frameCapture.ts` awaits the barrier in
`prepareFrameForCapture` (~2692), while the frame's
`HeadlessExperimental.beginFrame` is issued later, in the per-frame capture
stage (~3797). Fire-and-forget that was harmless; registered it can stall every
`__hfWaitForSeekCompletion` caller, the engine's render path included.

rAF-under-BeginFrame: CONFIRMED gated, from this repo's own engine notes —
`frameCapture.ts` ~2445 ("waitForFunction uses rAF polling internally, which
won't fire in beginFrame mode") and ~4755 ("headless only fires rAF when a
frame is produced, and nothing produces one until a screenshot asks"). Not
reproduced locally: every environment here is macOS with a free-running
compositor (`beginFrameTimeTicks === 0`). `setTimeout` is not compositor-gated
there — `drawElementService.ts` ~341 describes its 250 ms paint fallback as
burning on every BeginFrame frame, which requires the timer to fire — so a
timer ceiling is a sound escape.

`awaitCanvasPaint` now resolves `"painted" | "timeout"` with a 2000 ms ceiling
(the repo's own `BEGINFRAME_PROBE_TIMEOUT_MS`; ~20x a real two-frame paint,
far short of the ~180 s CDP `protocolTimeout` a hang would burn). The race is
per host, so one stalled compositor no longer delays hosts that would have
painted; a timed-out host is skipped (no capture, no guessed pixels) and
reported loudly. The report is not suppressed in engine mode: `frameCapture.ts`
~2708 skips `__hf_page_composite_resolve` in `drawelement`/`beginframe` modes,
so there is no authoritative second attempt to defer to there.

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

* chore(ci): re-pin test-classification.mjs hash after registering vfxDeterminism.test.ts

An earlier commit added vfxDeterminism.test.ts to INTEGRATION_TEST_FILES;
check-test-reachability.mjs pins a sha256 of that file to force a conscious
review whenever it changes. Reviewed: runner[1]'s glob already covers the
file structurally, the classification move (unit -> integration) is correct
for a Chrome-launching test, and the pinned hash is now current.

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

* chore(core): regenerate position-edits-render-inline.ts for Linux esbuild output

check:position-edits-render (bun run build:position-edits-render && git diff
--exit-code) failed on both Linux CI runners (Test (runtime), Tests on
windows-latest: studio-core) but passed on macOS: esbuild's minifier named
two identically-scoped WeakMap/function bindings differently on Linux than
on the mac that last committed this artifact, and the divergence appeared
only on this branch (main's own current tip passes CI cleanly), so it is
tied to a change on this branch rather than a random flake, even though it
is not attributable to any specific line of that change.

Confirmed stable across two independent Linux CI runs (byte-identical diff
both times) and reconstructed directly from that diff rather than guessed:
the committed content, byte length (3817), and 3/3 existing artifact tests
all match. A future contributor rebuilding on macOS will see this same
diff again in the other direction until the build script's cross-platform
determinism is fixed at the esbuild-invocation level, which is out of this
PR's scope.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 03:14:08 -07:00
Miguel Ángel b73df3549e refactor(studio): remove unreachable expanded-child timeline code (#4219)
* refactor(studio): keep legacy fixture state out of rendered rows

* fix(studio): keep timeline keyboard actor mounted

* refactor(studio): remove expansion telemetry and prove stable nested rows

* test(studio): type nested timeline fixture broadly

* test(studio): remove expansion lane assertions

* test(studio): assert nested row identity by key

* fix(studio): satisfy removal and format guards

* fix(studio): clear fallow findings in timeline cleanup

* test(studio): update timeline removal expectations

* fix(studio): remove stale timeline test helpers

* fix(studio): keep automation lanes mounted without disclosure

* refactor(studio): keep audio lanes while removing clip expansion

* fix(studio): restore audio disclosure state

* test(studio): remove obsolete expanded fixture assertions

* fix(studio): resolve provider rebase without clip auto expansion

* fix(studio): remove inline keyframe expansion rows

* fix(studio): restore automation disclosure state

* fix(studio): remove stale timeline model inputs

* fix(studio): pass animation state to timeline layout

* fix(studio): remove stale expansion tests and helpers

* test(studio): cover shared audio lane labels

* fix(studio): remove unused keyframe callback plumbing

* fix(studio): clear rebase lint leftovers

* fix(studio): remove unused timeline header export

* style(studio): format timeline cleanup

* fix(studio): restore composable timeline exports

* fix(studio): keep audio lanes open across selection

* fix(studio): align timeline lane test contract

* fix(studio): remove stale lane test import

* fix(studio): align track header test callback

* fix(studio): remove stale track header test prop

* test(studio): preserve ruler half pixel alignment

* test(studio): keep audio lane fixtures drawable

* test(studio): assert shared lane owner label

* fix(studio): align audio lane expansion with layout state

* test(studio): use the audio lane owner label

* fix(studio): unify audio lane expansion state

* fix(studio): align automation lane labels

* fix(studio): remove stale lane height import

* test(studio): assert automation labels against the real curve, not a formula

The previous test recomputed the expected top with the same formula
the fix uses, so a regression in TimelineAutomationLaneSlot's own
offset would still read green. Moved it to TimelineLanes.test.tsx,
which mounts both the header and the canvas, and compares their
rendered [data-automation-lane-label]/[data-automation-lane] tops
directly.

* revert(studio): restore the keyframe-row feature from main

Miguel: keep the per-property keyframe rows (diamonds, prev/next
arrows, disclosure, auto-open) exactly as main. This PR's real scope
is removing dead expandedParentStart consumers, not the keyframe-row
UX.

* refactor(studio): remove dead expanded-child timeline code

expandedParentStart is never set anywhere on main (the inline
sub-composition child-row expansion that produced it was already
removed in #4132/7797c5ead). Delete its now-dead consumers: the
TimelinePane move/resize/delete/split rebase wrappers, the ZMirror
call site that routed through them, the timelineDragLanding topology
guards, isMainTrackElement's expanded-child exclusion, laneGapFloor's
host-window-start math, and the paired expanded-clip-edit telemetry.
Keeps every keyframe-row feature (diamonds, prev/next arrows,
disclosure, auto-open, telemetry) untouched.
2026-09-23 01:38:50 -04:00
Miguel Ángel bc1c60ae5b fix(catalog): regenerating the texture-mask page no longer duplicates its section (#4302)
* fix(catalog): regenerating the texture-mask page no longer duplicates its Texture masks section

The page generator carries forward any section whose heading it does not
own, and it emits "## Texture masks" without owning it. Every run kept the
old copy and appended a new one; main's page had three.

* chore(catalog): drop the two duplicate Texture masks sections from the texture-mask page

* chore(catalog): run oxfmt on the texture-masks regression test
2026-09-23 01:09:08 -04:00
dd97478175 fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice (#4179)
* fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice

transcript-cut extracts each kept range to its own final-codec file, then
concat-copies them. That produces two audible artifacts at every cut:

1. No boundary fades. Concat splices raw segment edges together, so the
   waveform steps discontinuously and each cut clicks.

2. Encoder priming silence. Each segment is encoded to AAC independently,
   so the encoder pads every one with priming samples. Stream-copy concat
   bakes that padding in as a silent gap at each splice. Measured 24.4 ms
   and 24.1 ms at the two splices of a three-range cut. It accumulates:
   the nth splice lands roughly 25n ms late, so splice positions drift
   away from the sum of the preceding segment durations.

The second survives the first -- adding fades alone leaves the gap.

Fix: intermediates carry PCM (.mkv for video, .wav for audio-only) instead
of the final codec, so audio is encoded exactly once, at concat. Boundary
fades of 30 ms are applied per segment, scaled down on segments shorter
than 4x the ramp and skipped on degenerate ones.

Measured on the same three-range cut, widest run of digital silence at
each splice:

  before  24.4 ms / 24.1 ms
  after    0.1 ms /  0.1 ms

--copy is unchanged: stream copy cannot filter, so it keeps producing
final-codec segments and a copy concat. Audio-only output is byte-identical
in duration before and after. scripts/lib test suite: 34/35, the one
failure (lut-preset-provider) pre-exists on main and is unrelated.

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

* chore(skills): regenerate manifest for media-use

* fix(media-use): honor the fadeIn/fadeOut flags fadeFilterFor was passed

The caller already computed {fadeIn, fadeOut} per segment, but
fadeFilterFor still took one parameter and always emitted both
afade ramps -- so a single kept segment (no splice at all) and every
segment's true start/end got faded regardless. Extracted the pure
function into lib/transcriptCutFade.mjs, made it honor both flags,
and added its first unit test: first/middle/last/single-segment
filter strings, no ffmpeg needed, fails against the previous version.

Also installs ffmpeg in the Test: skills CI job (same action the
Producer job uses), so transcript-cut.test.mjs's real-tone RED/GREEN
proof runs in CI instead of skipping silently for lack of the binary.

* fix(media-use): fix the packed-CLI import and lint on transcript-cut.mjs

build-copy.mjs replaces dist/skills/media-use/scripts/lib with a copy
of packages/cli/src/media-use/lib, so transcriptCutFade.mjs needed a
byte-identical twin there (and in MEDIA_USE_COPY_NAMES) or the packed
CLI's transcript-cut.mjs imported a file that no longer existed.
Moved its test alongside, matching every other lib helper's tests.

Also drops three redundant Boolean(args.copy) wrappers (a ternary
condition already coerces) that oxlint's no-extra-boolean-cast
flagged.

* style(media-use): reflow the shortened ext ternary to oxfmt's shape

Dropping the Boolean() wrapper shortened the line enough that oxfmt
0.41 now collapses the ternary onto one line; format:check was failing
on the stale multi-line version.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-22 20:28:50 -04:00
Miguel Ángel 2c71a3a112 fix(catalog): registry set-delta check accepts intentional additions (#4287)
check-registry-set-delta.mjs only recognized removals: an added name in
registry.json had no matching allowance, so any pure-addition PR failed
with "unexpected in registry.json: <name>" even though the item's own
registry-item.json was added in the same diff. Mirror the deletion rule
for additions: a name is expected when its own registry-item.json was
added or deleted in the same diff, same condition either side already
required.

CI's audit tool flagged the CLI entry itself (high-crap-score rule,
scripts/check-registry-set-delta.mjs:30 main, CRAP 42.0 threshold 30.0,
cyclomatic 6): main() shelled to git and parsed JSON inline. The git/fs
boundary is now injected as runRegistrySetDeltaCheck(base, { run,
readRegistryJson }), covered directly by a test with a fake run/read, so
main() is a thin print-and-exit wrapper.
2026-09-22 11:24:51 -04:00
Miguel Ángel 94609daf44 feat(studio): audio pills show real peaks and loudness (#4282)
* feat(studio): audio clips draw real peaks shaded by loudness

The pill fill is the track tint at 70 percent, and each bar's opacity follows the cached server peaks. Muting greys the pill in place. Linked video audio shows a 1px tick.

* style(studio): format the audio waveform markup

* fix(studio): map audio peaks across the full clip width

* fix(studio): keep waveform visible through fade wedges

* fix(studio): keep silent waveform bins visible

* fix(studio): keep waveform baseline visible edge to edge

* fix(studio): keep waveform visible over fades

* fix(studio): keep quiet waveform bars visible

* fix(studio): paint waveform above fade wedges

* fix(studio): fit waveform inside audio clip

* fix(studio): preserve waveform contrast tokens

* test(studio): ratchet waveform color literals

* fix(studio): keep loudness floor internal

* chore(studio): document intentional fallow findings

* refactor(studio): share fade handle rendering

* fix(studio): pass fade handle styles explicitly

* refactor(studio): split waveform drawing stages

* test(studio): cover waveform canvas painting
2026-09-22 08:58:21 -04:00
Miguel Ángel 05d09a7452 chore(registry): remove low-use catalog items and variant tails (#4274)
* chore(registry): remove low-use catalog items and variant tails

* fix(registry): remove stale references to deleted catalog items

* style: format catalog fixture

* fix(ci): use surviving catalog preview canary

* chore(ci): allow intentional catalog deletions

* fix(ci): scope fallow audit for catalog fixtures

* chore(catalog): regenerate local search vectors

* chore(catalog): update vector revision

* fix(catalog): validate registry set delta
2026-09-22 07:04:54 -04:00
Miguel Ángel 5203312c34 feat(studio): timeline clips take the designed material (#4270)
* feat(studio): timeline clips take the designed material

Picture clips use an 8px radius and audio clips a 21px pill.
Clips sit one step above the lane. The label is a chip that
drops under 60px. Selection keeps the inset outline plus a
1px inner highlight. Hover background is the only transition.

* style(studio): format timeline clip shadow

* fix(studio): restore handle-bar transition and de-pin box-shadow test
2026-09-22 02:15:55 -04:00
Miguel Ángel c029bf4087 ci: require zero orphan tests without a baseline (#4271)
* ci: require zero orphan tests without a baseline

* fix: keep reachability reports diagnostic-only
2026-09-22 01:30:24 -04:00
Miguel Ángel cff467a352 ci: select the remaining orphan tests in their owning runners (#4260)
* ci: select the remaining orphan tests in their owning runners

* ci: provide FFmpeg for the catalog preview witness

* ci: pass the installed Chrome path to catalog rendering

* test: align creator capability checks with speed ramp support

#4153 added supported speed ramps as rate lanes in data-automation. The
previously unselected capability tests still required preprocessing.
Require the documented rate-lane representation and its precedence over
the constant playback rate, matching the runtime resolver.
2026-09-22 00:54:41 -04:00
Miguel Ángel a80d733219 ci: run media-use tests when media-use changes (#4259) 2026-09-21 22:05:44 -04:00
Miguel Ángel 86e27b00f6 ci(studio): ratchet colour contrast of timeline tokens (#4257)
* ci(studio): ratchet colour contrast of timeline tokens

* refactor(studio): clarify contrast ratchet branches and state citations

* fix(studio): resolve contrast baseline from the repository root

* test: parse contrast channel tokens without color wrappers

* ci: keep contrast gate artifacts outside runtime sources

* test: honor strict indexed access in contrast scripts

* test: require removal of zero contrast baseline entries
2026-09-21 21:35:23 -04:00
Miguel Ángel 0be08f34a2 ci: ratchet test reachability across workflow filters (#4253)
* ci: ratchet test reachability across workflow filters

* fix: fail closed on skipped detection and quoted selectors

* refactor: separate reachability parsing decisions

* fix: keep comments and runner prefixes out of reachability

* refactor: bound reachability parser complexity

* fix: reject conditional shell runner blocks

* fix: reject conditional shell constructs in test routing
2026-09-21 21:14:49 -04:00
Miguel Ángel 3091dcb787 feat(cli): route media search through media-use (#4230)
* feat(cli): move media-use engine into the CLI

* fix(cli): replace media-use compatibility asset

* fix(cli): replace moved media-use tree

* fix(cli): copy moved media-use files cleanly

* fix(cli): copy media-use engine files individually

* fix(cli): keep audio helper compatibility import

* fix(cli): clear copied skill destinations

* test(cli): preserve moved media-use test paths

* fix(cli): preserve media-use script compatibility imports

* fix(cli): preserve media-use telemetry shim

* fix(cli): resolve packaged media-use LUT assets

* fix(cli): resolve packaged bundled SFX assets

* chore(skills): regenerate media-use manifest

* fix(cli): preserve media-use parity coverage

* fix(cli): cover relocated media-use tests

* chore(skills): regenerate media-use manifest

* fix(cli): format bundled media provider

* fix(ci): run relocated media-use tests with node

* fix(ci): preserve fallow script exemptions

* fix(ci): scope media-use fallow exemption

* refactor(cli): trim media-use essay comments

* feat(cli): index media-use assets for catalog search

* feat(catalog): commit media-use search vectors

* chore(catalog): track media vector metadata

* fix(catalog): validate media vector row order

* fix(catalog): validate media artifact freshness

* fix(catalog): cover media metadata and credits

* fix(cli): reject malformed media rows

* fix(catalog): compare complete media rows

* feat(cli): route media search through media-use

* refactor(cli): trim relocated media-use comments

* fix(cli): make media search runtime-safe

* fix(cli): preserve sfx fallback diagnostics

* fix(cli): probe sfx fallback provider explicitly

* chore(skills): regenerate manifest

* refactor(cli): keep typed media ranker source

* fix(cli): align media search checks with local-first flow

* fix(cli): scope vector audit exceptions

* fix(cli): ship resolver registry beside skill

* fix(cli): keep media-use skill scripts standalone

* test(cli): cover local vector validation

* test(cli): correct media vector fixtures

* fix(cli): prefer exact media keys

* fix(cli): stem exact media keys

* test(cli): skip media lint for snippets without media

* fix(cli): align skills test discovery

Keep the CI skills job's bash discovery as the single source of truth so its empty-list guard covers the exact files Node executes. Make hasMediaVectors private because it has no external callers and needs no Fallow suppression.

* fix(cli): close media-use review gaps

Prioritize literal ids before stemmed ids and cover the collision alongside the plural regression. Add command seam tests for engine discovery, actionable failure, and argv passthrough. Add a deterministic parity check for standalone media-use copies, with documented allowlists for the standalone media-fetch shim and self-contained npx resolver. Keep the bash-discovered skills test list as the CI source of truth.

* fix(cli): lower parity check complexity

Split missing-copy and unallowlisted-drift checks so the parity guard keeps its behavior without a threshold CRAP score.

* fix(cli): close media-use review seams

Run the parity test in the skills job so skills-only edits exercise the same check. Export the verb mapping and inject command dispatch for direct coverage. Keep missing-copy diagnostics separate from drift reads.
2026-09-21 18:35:47 -04:00
Vance IngallsandClaude Code 5a8ee663f7 fix(studio): report a rejected read as a network reason, not an open failure (#4240)
* fix(studio): report a rejected read as a network reason, not an open failure

`stage: "open"` is documented as `openComposition` threw — an unparseable
composition, or OOM. Every event in that bucket on 0.8.56 and 0.8.57 carries a
fetch-rejection message instead: "Failed to fetch" (28 users / 11 users), "Load
failed", "NetworkError when attempting to fetch resource.".

The cause is one missing catch. `readProjectFileOptional` awaits `fetch` with no
try/catch, so a rejected request escapes the function and is caught by the
effect's outer `.catch` — the handler written for `openComposition`. The read
path has a five-member reason union precisely so this class of failure is
diagnosable, and the one case that never produces a response was the case
outside it.

That made the largest SDK-session failure class in Studio (2.9% of session users
on 0.8.56, 2.3% on 0.8.57) unaddressable: you cannot fix a network failure while
it is reported as a composition that failed to parse.

Now `readProjectFileOptional` catches its own rejection and returns
`reason: "network"`, so it lands in `stage: read` with everything else, and
`stage: open` means only what its comment says.

Telemetry only. Nothing branches on the stage and no user-facing behaviour
changes. Fifth split of this shape, after `ineligible_operation` (v0.8.49),
`stage: read` (v0.8.51) and `block_detail` (#4214).

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

* test(docs): point the catalog tab-row test at the page gutter #4238 moved the inset to

`catalog-detail.test.ts` asserted the exact strings that #4238 deleted:
`.hf-ve-tabs-row { margin: 20px 0 0 16px; }` and `.hf-ve-body { padding: 2rem 0 0 16px; }`.
That PR removed both insets deliberately — its own comment says "the page gutter is
the page's job, and a second 16px here put everything below the stage on a
different left edge from the title, install block and stage above it" — and
restored the gutter on `#content-area` in `docs/custom.css` instead. The test was
left asserting the old mechanism, so main has been red on it since, unnoticed
because the `Test` workflow does not run for a docs-only change.

The invariant did not change: this content is still inset from the page edge. Only
the mechanism did, so the test now asserts both halves of it — the two rules carry
no inset of their own, and the gutter standing in for them is still present, at both
its values. Asserting only the first would keep passing if the gutter were deleted
outright, which is the regression the test exists to catch; both mutations were
checked and both fail.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-20 19:40:06 -07:00
Miguel Ángel 41b7202bca refactor(studio): make timeline rows use manifest elements (#4213)
* refactor(studio): make timeline rows use manifest elements

* fix(studio): preserve timeline consumer prop names

* test(studio): update asset row expectation

* fix(studio): clear preflight lint nit

* chore: allow intentional timeline hook removal

* chore: refresh PR capture checks

* test(studio): align visibility gate with store rows

* test(studio): preserve canEdit literal type
2026-09-20 10:18:03 -04:00
Vance IngallsandClaude Opus 5 90e67ff5a8 fix(release): a stale local tag can no longer silently widen the changelog (#4208)
* fix(release): a stale local tag can no longer silently widen the changelog

`release:prepare` creates `vX.Y.Z` locally at the branch commit, then the
publish workflow creates the same tag at the merge SHA. After a squash merge
those are different commits and the local one is not an ancestor of main, so
`git describe` cannot reach it and falls back to the previous release without
saying so. `git fetch --tags` does not help: it refuses to move a tag that
already exists locally, and only `--force` does.

On the v0.8.52 cut this produced a `v0.8.50...v0.8.52` draft that re-listed
about a hundred already-shipped commits. The changelog is user-facing, and
nothing in the output said the baseline was wrong.

Adds a guard in the drafter: after resolving the baseline, fail if a stable
tag exists locally that sits strictly between it and the release. `describe`
only ever returns a reachable tag, so a tag in that range is proof it skipped
one. The message names the tags and prints `git fetch origin --tags --force`.
`--from` still overrides, for a deliberate baseline.

Also prints the captures-gate hatch with the release PR instructions. Every
release bumps packages/studio and packages/player, which
scripts/check-pr-captures.mjs watches, so it demands before/after media for a
version bump; its own `## No visible change` section is the intended answer
and applies to a 4-line package.json diff.

Verified end to end by moving a local tag onto the pre-squash commit of
v0.8.51: the drafter fails with the fix, and drafts normally once restored.
Six mutants of the range check are all caught by the tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(release): guard the baseline inside its resolver

`resolvePreviousTag` only runs when `--from` was not supplied, so testing
`!options.from` at the call site repeated a condition the `??` already
expressed. Folding the guard into the resolver drops that branch, which had
pushed `createDraft` to a CRAP score of exactly 30.0 and failed the audit.

The assert sits outside the try/catch on purpose: `fail` exits rather than
throwing today, and this keeps a catch from ever swallowing the guard if that
changes.

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 17:14:51 -07:00
Miguel Ángel 98e8372a0a feat(studio): all panels move onto the dockable, persisted layout (#4192)
* feat(studio): every Studio panel is a dock panel and the shell moves onto it

EditorShell, App.tsx, the left and right panel columns, and the
inspector hooks now render through Dock.Panel instead of the old
fixed-width sidebars and split-pane inspector. usePanelLayout becomes
a thin facade over the dock store; fitPanels, the resize divider and
the split-inspector hook are dead and removed.

* refactor(studio): drop dead inspector flags and stale fallow entries after dock migration

Collapse inspectorButtonActive into inspectorPanelActive, remove unread
layers/design active fields, un-export unused prop types, flatten a nested
ternary, and point fallow ignores at the renamed/deleted files.

* fix(studio): keep right-panel behaviours the dock migration dropped

Block params dismiss on selection or when Design is hidden, the slideshow
panel can be closed, the selection outline follows the inspector intent,
caption editing focuses Design, and a URL slideshow tab opens once the
panel exists.

* refactor(studio): name the inspector-open state once in the header

* fix(studio): right-panel behaviours survive selection, restore and reopen after the dock migration

Block params stay open over an element that was already selected, caption mode wins over block params, the slideshow panel waits for the dock, the Renders tab title survives a reopen, each side column remembers the tab it last showed, tab switches are reported only when the tab changes, and the e2e thumbnail selector follows the dock.

* docs(studio): describe the dock layout and point comments at the renamed right panels

* fix(studio): block params survive a preview reload of the selected element, and panel titles are covered

The dismissal compares the selected element by its target key instead of object identity, because a preview reload rebuilds the selection for the same element. A closed and reopened panel keeps its custom title, with a test, and the e2e selector is formatted.

* fix(studio): block params leave with any other tab pick, and a restored slideshow placement survives loading

Block params are now dismissed by the tab setter the way their old tab was, including a re-pick of the same element, instead of by comparing selections. The slideshow panel is only closed after a file that was a slideshow stops being one, so a restored layout is not discarded while the file loads.

* refactor(studio): drop the duplicate inspector-open computation

The rebase onto main left two identical inspectorButtonActive/inspectorOpen
constants after a conflict resolution; only the first was still referenced.

* chore(studio): allow the dock migration deletions in the guard

StudioLeftSidebar.tsx, LeftSidebar.tsx and its storage test,
TimelineResizeDivider.tsx, useInspectorSplitResize.ts and fitPanels
are replaced by the dock; the CI deletion guard needs each named
with its reason.

* style(studio): oxfmt the deletion-guard entries

* fix(studio): rename Timeline's aria-label so it does not collide with its dock panel wrapper

Every dock panel wrapper now carries aria-label=<title>, matching the "Timeline"
title for the timeline panel. Timeline.tsx also set aria-label="Timeline" on its
own content div, so `document.querySelector('[aria-label=\"Timeline\"]')` found
the dock wrapper first and the timeline viewport gate CI check timed out waiting
for an attribute the real content div actually carried. Renamed to "Timeline
track view" and updated every consumer: the production diagnostics selector,
its tests, and the e2e viewport-gate script.

* fix(studio): restore inspectorButtonActive as a real prop after a rebase regression

Rebasing PR2 onto main replayed an older local commit whose diff did not
textually overlap main's newer inspectorButtonActive prop, so git applied
it with no conflict marker and silently reverted the header to computing
the value itself instead of receiving it. StudioHeader.dom.test.tsx already
asserts the prop is respected; restoring it makes that test meaningful
again. App.tsx now computes it once (panelLayout.rightCollapsed is already
the dock's effective collapse state, no separate railed-by-width case) and
passes it down.

* chore(studio): wrap a long doc comment in useRightPanelIntents
2026-09-19 19:04:16 -04:00
Miguel Ángel 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 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 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 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 a94466256e ci: require before and after captures on studio and player PRs (#4131) 2026-09-19 05:41:33 -04:00
Miguel Ángel 68b61675ed feat(studio): upgrade Studio to Tailwind CSS v4 (#3618)
* chore(studio): upgrade Tailwind CSS to v4 through the Vite plugin

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

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

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

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

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

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

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

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

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

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

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

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

* style(studio): reindent App.tsx after the view-mode wrapper was removed
2026-09-18 23:47:12 -04:00
Miguel Ángel 2db126d4c8 docs(skills): one owner doc for everyday edits and a hyperframes-studio skill (#4125)
* 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
2026-09-18 23:19:40 -04:00