An element dragged past the edge sits out in the grey, and the rubber band
refused to start there — it only began when the press landed inside the
composition rect. The one gesture that could reach those elements could not be
begun near them, so the timeline was the only way to select something plainly
visible on screen.
The collecting half never had that limit: it compares rects in overlay space and
never clipped to the frame, so those elements have always been selectable once
the band could begin. Only the start gate had to go.
A press in the grey that never travels still commits an empty selection, which is
the deselect it used to be, so the old behaviour of clicking out there to clear
is unchanged.
Your Jam confirmed the first-frame jump is gone — pointer "0,0" now reads
applied "0,0" — and caught what was left: two milliseconds after each drop, a
`[hf-select] clear` with the group still holding three, then four members.
Every pointerup trails a click. The group gesture ref is cleared before the
commit runs, so by the time that click arrives the box no longer looks busy and
it reaches the canvas as an ordinary click — landing in the gap between the
members, resolving to nothing, and clearing the selection the drag just moved.
The under-threshold path already ate that click; the committed path never did.
The flag is now set before the two paths diverge, so neither can forget it. The
test drives a real pointerup through the handlers and fails on the committed
path with the flag moved back down.
Your log caught it on the first frame of the drag: pointer "0,0", applied "4,-3",
and all four members jumped 12,-8 composition px before the pointer had moved at
all. An element resting within the 6px snap threshold of a guide is already
snappable, so the snap computed on frame one closes that gap immediately —
picking the selection up moves it.
Snapping now sits out until the gesture has travelled the same 4px a drag needs
to count as a drag rather than a click, on both the group and single-element
paths. Nothing below that distance moves anything, and a real drag snaps exactly
as before.
The test builds a box resting 4px from a guide and asserts the ungated call still
returns dx 4 — the very displacement from your log — while the gated one returns
0 for a pointer that has not moved.
A link to a bug hit with several elements selected only reproduced one of them,
so the report read as "works for me". The hash now carries the rest as selGroup
and reopens the whole selection; members whose element is gone are dropped rather
than failing the others. Verified end to end in a real browser: select three,
copy the hash, open it fresh, the same three come back.
The drag trace also gains a rigidity check. A group moves as one object, so every
member travels the same distance; one that does not IS the fault. Drift was being
computed but only printed on every eighth frame, which is exactly how a
single-frame divergence hides — it now prints on the frame it happens.
The frame handler moves to its own module on the way past. It had grown a snap
block and a trace block inside a function already juggling four gesture kinds,
and it was over both the complexity and file-size gates.
Not fixed: the jump itself. Two headful runs driving a real group drag showed the
members staying rigid to the pixel, at the drop and 900ms after, so I have not
reproduced it yet and will not guess at a fix.
After a move the preview re-syncs and the selection is re-resolved against the
new document. When the primary could not be found there, both re-resolve paths
cleared the entire selection — so a group of five, all still on screen, was
deselected because one of them failed to resolve. The trace showed the clear
landing 600ms after the drop with five members still held, and the timeline sync
running afterwards on an already-empty canvas, which ruled it out as the cause.
A live group now re-resolves as a group and keeps whoever survived, picking a new
primary from them; it only clears when nobody did. That is what
refreshDomEditGroupSelectionsFromPreview was written for — it existed and was
never called.
Both clears also say which one they are and how many members were held, so if
this is not the last of it the next trace names the path immediately.
The drag trace showed the group landing exactly where it was dropped and staying
there — no snap-back at any settle sample, and the pointer and the applied delta
never more than 2px apart — but two milliseconds after the drop the selection was
cleared with seven members still in it.
The clear comes from the timeline sync deciding the timeline holds nothing, and
that branch said nothing. It says so now, along with whether it is about to act
on it. The mirror alongside it reports how many members it managed to publish and
whether the anchor was among them, because a member with no timeline row of its
own resolves to null and is dropped silently — publish none and the sync reads it
back as an empty selection.
A drag that jumps is a position that changed without the pointer asking for it,
and nothing on that path says anything today, so the frame it diverges can only
be guessed at. `hf-drag-debug` (localStorage, off by default) records the whole
gesture: the mapping and start position each member got, the pointer delta
against the delta actually applied on every eighth move, what each member was
told to commit, and where they all sit at the drop, once the commit resolves, and
120/400/900ms later.
That last group is the point of it. The source write, the preview reload and the
timeline resume all land within a few frames of the drop, and any of them can put
the elements back where they started before the new position arrives — a
snap-back shows up as a settle sample reverting to the gesture-start reading.
A gap between `pointer` and `applied` instead means snapping pulled the group off
the cursor, which is a different fault with a different fix.
Every canvas selection is mirrored onto the timeline, and the timeline syncs
back — whatever it holds replaces the canvas selection a moment later. The
mirror announced only the primary and anchored it with preserveSet, but
preserving a set that does not contain the id empties the set, and an empty set
syncs back as "nothing is selected". Adding a second element, or re-resolving a
group after moving it, could therefore drop the whole selection rather than keep
it.
One helper now owns the mirror: publish the members, then anchor. A single
selection keeps the previous contract deliberately, so a late async primary
still cannot collapse a live group and a fresh click still collapses a stale
one. The group re-resolve path also gains the ancestor id fallback the other
callers already had — without it a member with no direct timeline row resolved
to null and deselected everything.
Two tests: a second element joining a selection, and a marquee, both assert the
full set reaches the timeline. Both fail against the announce-the-primary-only
version.
The marquee built the group correctly and then threw it away. It announced only
the primary to the timeline, and the timeline is the source of truth for what is
selected: the sync back to the canvas saw one selected id against a group of
several, decided the canvas was stale, and replaced the group with that single
element a moment after the drop. Drag a box around four things, get one.
The whole set is announced now, and the primary goes in as its anchor rather
than as a new single selection, so the set it just joined survives. This is the
same reason the single-select path already anchors with preserveSet.
A test drives applyMarqueeSelection with two elements and asserts both reach the
timeline; it fails against the old single-id announce.
Shift-click read the hover cache and used it without checking what it described.
That cache is filled asynchronously as the pointer moves, so passing over one
element on the way to another leaves it naming the element you left. The
shift-click then added THAT element, and because the same branch prevented the
default and set the suppression flags, the mousedown path that would have
resolved the point correctly never ran. Multi-select looked like it grabbed
things at random, or like it did nothing.
Reproduced on the canvas with a trace: hover #card, shift-click #dot-b, and the
group gained #card. Same gesture after: the guard rejects the cache, the
mousedown path resolves the point, and the group gains #dot-b.
The cache is still used when it is provably about the point clicked, including
when it names a clip ancestor of the element there, so the fast path survives for
the common case of clicking straight at something.
Adds `hf-select-debug` (localStorage, off by default) recording which selection
branch ran and what it decided, and pulls the flag/format shared with
`hf-reload-debug` into one place rather than copying it.
An element that had never been dragged skipped the movement measurement and took
the canvas zoom as the whole screen mapping. Nothing above the element was
considered, so any parent transform broke the drag: a card at rotationY 180 with
scale 1.2 maps a rightward drag to -1.2x the zoom, meaning the text walked LEFT
while the overlay followed the pointer, and the overlay only snapped onto the
text at drop, when it re-measured.
Measured on the live element in that card: one unit of drag offset moved it
-0.757 px on x and +0.757 on y, where the skipped path assumed +0.631 on both.
The measurement it skipped already handles this — it moves the element, watches
where it lands, and inverts that, which is right for rotation, mirroring, scale
and perspective alike. So the special case is gone and every drag measures. Same
element after: a 120x80 pointer drag moves it 120.3x80.2.
Rewrote the test that asserted the skipped path's identity matrix for an
unmovable element. It now asserts the honest outcome: an element with no
measurable movement is reported unmeasurable whether or not it carries a path
offset, and the caller's existing fallback covers it.
The box around a text layer inside the playground card stopped mid-word. The
layer is 260px wide and paints 313, because its parent carries `scale(1.2)`,
and the chrome read only the element's OWN transform. The top-left looked
right, since the corners are anchored to the real bounding rect, so only the
right and bottom edges fell short, by exactly 1/1.2.
The same read decides whether to draw the box rotated at all, so an element
whose parent is rotated got an upright box over a rotated one.
The transform is now accumulated from the element up to the composition root.
Only the linear part matters: each transform's origin contributes translation,
and translation is already discarded by matching the corners to the element's
bounding rect, so composing the matrices is enough and no per-ancestor origin
has to be unpicked. The walk stops inside the composition document, because the
canvas zoom lives on the iframe in Studio's own document and is applied
separately.
The fake DOMMatrix the geometry tests use gained the `multiply` it now needs.
* fix(studio): stop a Studio edit from reloading the preview as if it were external
Every mutation route wrote the file without leaving a write receipt, so the
watcher's broadcast of Studio's own edit arrived with no identity on it. The
external-change coordinator could not tell that echo from an agent or an editor
writing the file behind Studio's back, so it took the safe branch and did a full
iframe reload. That reload hides the stage for the length of the reload, which is
what the flash after a text edit was.
Every mutation write now goes through one helper that records the receipt, and
the client claims the write before the request goes out rather than after it: the
server writes and the watcher fires while the request is still in flight, so a
token marked from the response can arrive after the echo it was meant to match.
Reproduced in the browser before and after, with the reload path traced end to
end. Before, a patch-element write logged `token: null` then a reload from the
coordinator; after, the same write logs the token and `suppressed: own write
token`, with no reload.
Adds `hf-reload-debug` (localStorage, off by default) alongside the existing
`hf-resize-debug`: it records each file-change decision and its reason, plus the
stack of whoever asked for a full reload.
* fix(studio): claim the timeline and caption writes too, not just the DOM ones
The receipt only helps when the client marked the token it sent, and the GSAP
mutation writers never sent one. A drag commits through gsap-mutations, so the
server minted a token the client had never seen, the change came back looking
like someone else's, and the preview did the full reload the receipt was meant
to prevent.
Same one-line claim on both GSAP mutation writers, the timing sync's mutation
call, and the caption auto-save PUT.
The rollback call stays deliberately unclaimed and says why: it runs because a
mutation did not converge, so the preview is on bytes nobody can vouch for and
the reload is the point.
Verified live: a drag-shaped update-properties on the timeline now logs
`suppressed: own write token` with no reload, where it logged a coordinator
reload before.
* refactor(studio): keep timelineTimingSync under the size cap
Claiming the timeline writes pushed this file one line past the 600-line
gate. Same change as the branch made later, landed with the commit that
caused it.
* fix(studio): cover remaining write receipt paths
* fix(studio): preserve batch write receipts
* fix(cli): emit every file in a watcher burst
loadBpmDetective cached the promise returned by dynamic import even when
that import rejected. A transient failure (network hiccup, bundler issue,
missing module at first access) was therefore cached as null for the rest
of the session, silently disabling BPM detection.
- Reset the cached promise on import failure so the next call retries.
- Only cache the default production import; custom loaders bypass the cache.
- Make loadBpmDetective testable by accepting an optional importFn.
- Add regression tests for failure/retry and module/default resolution.
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard
in render.ts together, leaving the producer's default-ON in place. Net effect
for users: the parallel drawElement router is on for everyone again.
## Why, and why not a ramp
Gating at 5% was itself the regression. Measured 2026-08-08, the day after
v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of
non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are
explicitly disarmed and #2840 deleted the everyone-armed trial in the same
change. 2,537 installs lost a feature they already had. Severity is speed
only, never output, and nothing is persisted to disk.
PR #2840's body claimed "the canary does not make exposure smaller; it makes
it chosen and revertible." That was true of the end state and false of the
first step. This lands the end state.
Entry and guard go together deliberately: at >=100 the evaluator
short-circuits ahead of the CI/seedless exclusions, so removing only the entry
would have flipped whatever still resolved false at deletion time, unstaged.
## Both stated blockers are void
- **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0
of 4,281 across every CPU tier, software GL gates it out — and the router
requires it. No percentage could ever expose Docker, so no ramp closes that
gap. ≤4 CPUs yields ~42 drawElement candidates in three days.
- **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93,
0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore
this router. It is real, still live on 0.7.101, and belongs to the
screenshot/beginframe path. 11 reproduction runs across four configurations
on the enriched profile (darwin/arm64 25.5.0) came back clean.
## Safety unchanged
The per-install circuit breaker and the per-render self-verify are untouched;
`HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary
data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) —
consistent with the 2.75-3.16% baseline.
Revert path is now a code revert rather than a registry edit. That is the
trade this shape accepts in exchange for one release instead of two.
## Corrects two claims that shipped wrong
`~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and
`~11% of installs already route` was an OUTCOME (the share clearing
eligibility and the old 25-render cap), not an exposure setting — read as a
rollout knob it inverts the arithmetic, which is how gating at 5% came to cut
exposure rather than ramp it. Both are recorded in render.ts so they are not
reintroduced.
## Tests
Removed the core wiring assertion and the two CLI canary-gating tests, which
pinned a gate that no longer exists. Added the inverse guarantee in its place:
an ordinary install must come out of the breaker with the var UNSET so the
producer default applies — writing "false" there is precisely what disarmed
the fleet at 5%.
core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures
in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area).
oxlint and oxfmt clean.
Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router`
and `canary_reason_de_parallel_router` are emitted from the registry, so the
`Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go
blank once this ships. Watch drawElement engagement on 1807532 instead.
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard
in render.ts together, leaving the producer's default-ON in place. Net effect
for users: the parallel drawElement router is on for everyone again.
## Why, and why not a ramp
Gating at 5% was itself the regression. Measured 2026-08-08, the day after
v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of
non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are
explicitly disarmed and #2840 deleted the everyone-armed trial in the same
change. 2,537 installs lost a feature they already had. Severity is speed
only, never output, and nothing is persisted to disk.
PR #2840's body claimed "the canary does not make exposure smaller; it makes
it chosen and revertible." That was true of the end state and false of the
first step. This lands the end state.
Entry and guard go together deliberately: at >=100 the evaluator
short-circuits ahead of the CI/seedless exclusions, so removing only the entry
would have flipped whatever still resolved false at deletion time, unstaged.
## Both stated blockers are void
- **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0
of 4,281 across every CPU tier, software GL gates it out — and the router
requires it. No percentage could ever expose Docker, so no ramp closes that
gap. ≤4 CPUs yields ~42 drawElement candidates in three days.
- **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93,
0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore
this router. It is real, still live on 0.7.101, and belongs to the
screenshot/beginframe path. 11 reproduction runs across four configurations
on the enriched profile (darwin/arm64 25.5.0) came back clean.
## Safety unchanged
The per-install circuit breaker and the per-render self-verify are untouched;
`HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary
data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) —
consistent with the 2.75-3.16% baseline.
Revert path is now a code revert rather than a registry edit. That is the
trade this shape accepts in exchange for one release instead of two.
## Corrects two claims that shipped wrong
`~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and
`~11% of installs already route` was an OUTCOME (the share clearing
eligibility and the old 25-render cap), not an exposure setting — read as a
rollout knob it inverts the arithmetic, which is how gating at 5% came to cut
exposure rather than ramp it. Both are recorded in render.ts so they are not
reintroduced.
## Tests
Removed the core wiring assertion and the two CLI canary-gating tests, which
pinned a gate that no longer exists. Added the inverse guarantee in its place:
an ordinary install must come out of the breaker with the var UNSET so the
producer default applies — writing "false" there is precisely what disarmed
the fleet at 5%.
core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures
in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area).
oxlint and oxfmt clean.
Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router`
and `canary_reason_de_parallel_router` are emitted from the registry, so the
`Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go
blank once this ships. Watch drawElement engagement on 1807532 instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(player): stop re-encoding the composition query
Every src the player sets goes through withShaderQueryParams, which parsed
the author's whole query with URLSearchParams and re-serialised it with
toString(). That is a form encoder: it writes a space as +, while callers
percent-encode and read back with decodeURIComponent. Those two codecs are
not inverses, so any space in any query value arrived corrupted.
It ran even when there was nothing to inject. With no shader attributes
both params are deleted, so the round-trip was pure loss, on every src,
for every consumer.
Append the two params to the raw query instead of re-serialising it. The
player now hands a composition its query back byte-identical.
Empirically space was the only casualty: plus, ampersand, equals, hash,
percent, question mark, quotes and non-ASCII all survived a URLSearchParams
round-trip. That is narrow, but a space in a headline or in SVG path data
is the common case, and invalid path data renders nothing at all.
Latent until now: no shipped consumer depended on query preservation, so
this surfaced only once compositions began carrying variable payloads.
* fix(cli): serve the runtime ahead of every author script
injectRuntime appended its script before </body>, so it landed after any
inline script the composition carried. At the moment a composition's own
script ran, window.__hyperframes was undefined and getVariables() was
unreachable: our documented API did not exist at the point authors are
told to call it.
Served order was gsap at line 6, the composition's init script at 20, the
runtime at 37. A probe inside the composition's IIFE recorded
hfTypeAtInit undefined with no variable keys, and the element rendered
its hardcoded fallback rather than the declared value.
The runtime is designed to load early. Its entry assigns __timelines,
installs the authored-opacity capture (whose own comment says it must run
while the document is still parsing), and exposes __hyperframes
synchronously, deferring real work to DOMContentLoaded. End-of-body
injection defeated all three, and nothing in it needs a parsed DOM, so no
defer is wanted.
Injects at head start instead, reusing the placement cascade
injectScriptsAtHeadStart already implemented rather than adding a fourth
copy of it. Head start rather than the closing tag so the runtime also
precedes author scripts inside head.
injectRuntime has exactly one consumer, the play server's composition
route. Every other surface reaches the runtime through the bundler, which
already injects into head, or deliberately serves raw.
Two registry blocks had independently worked around this by parsing the
authored attribute themselves. Those stay, but the workaround is no
longer the only way to read a variable at init.
`hyperframes skills update` deleted skills that the same command had just
installed, from every agent directory on the machine, and reported them as
"no longer published".
`skills add --skill '*'` installs every skill in the repo — including the
repo-native ones under `.claude/skills/` and `.agents/skills/` — and the
upstream lock attributes all of them to `heygen-com/hyperframes`. The published
manifest is generated from `<repoRoot>/skills` only (gen-skills-manifest.ts), so
it never lists those. detectRemoved read that silence as "removed upstream" and
pruned them, so `check || update` could not converge: `add` reinstalled them and
the next `update` deleted them again.
Scope removed-detection to skills the manifest is actually authoritative for,
using the lock's `skillPath` — the only field that separates a skill installed
from `skills/` from one installed out of the same repo's other skill roots
(`source` is identical for both). An entry with no `skillPath` is treated as not
covered: this is a delete path, so unknown provenance fails safe.
Also resolve the prune's manifest canonically. Its notion of "still published"
could otherwise come from any `skills-manifest.json` within 16 parent
directories of cwd, which — since HyperFrames' own manifest declares
`source: heygen-com/hyperframes` — matches lock attribution and drives deletion.
The install-side check already did this (#2176); the deleting path did not, and
the comment claiming that was deliberate and "tested separately" had no such
test. An explicit `--source` still wins.
Verified end to end against the real CLI in a sandboxed HOME. Before: `add`
installed 25 skills, `update` printed "Removing 6 skill(s) no longer published:
captions-overlay, changelog-video, cut-the-curve, motion-doctrine,
oversized-cursor, seam-craft" and deleted all six (27 dirs -> 21). After: no
removal line, 27 -> 27. Both new regression tests fail on the pre-fix source.
Fixes#3111
Review catch. The baseline was taken from the first colour tween unconditionally,
so a tween declared "active" at index 0 set the reference its undeclared siblings
were compared against -- and the genuinely dim tween beside it was classified
active and given the wrong override. A partial migration could therefore end up
worse off than a composition that declared nothing.
The reference is now a declared "dim" tween if one exists, else the first
undeclared one: the heuristic stops drawing its inputs from records the
declaration has already spoken to.
Also pins the fallback for a malformed declaration -- a typo, a number, a null, or
a non-object `data` -- so a future tightening of the accepted union cannot quietly
turn an unrecognised value into a broken composition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Classification by colour equality has to guess: it takes the first colour
tween's value as the dim baseline and calls everything else active. A
composition whose two states share a colour therefore has every tween
classified dim, and the caller's activeColor is silently dropped -- a real
failure, now covered by a test that fails without this change.
A tween may declare its state as data: { captionState: "dim" | "active" }.
GSAP passes unknown vars through untouched, so declaring costs nothing at
runtime, and resolution is per tween -- a composition can declare some and
leave the rest to the fallback, which is unchanged for anything undeclared.
This is the composition telling us what it built rather than us inferring it
from what it happens to look like. The data-driven caption templates already
author their state tweens from resolved values and never guess; this closes
part of that capability gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What
Fixes three Studio crashes. All three throw into React and drop the user on the full-screen "Something went wrong" boundary.
**1. `NotFoundError: Failed to execute 'removeChild' on 'Node'`** — the highest-reach of the three. The `Player` mount effect appends a `<hyperframes-player>` into its container and tears it down with `container.removeChild(player)`. By the time that cleanup runs the element may already be detached: the container can re-render, a crossfade refresh can swap it, or a translation extension can reparent it. Switched to `player.remove()`, a no-op when the node has no parent. `utils/clipboard.ts` had the same unguarded `document.body.removeChild(textarea)` and is fixed with it — those are the only two `removeChild` call sites in non-vendor source.
**2. `SecurityError: Failed to read the 'localStorage' property from 'Window'`** — `getPersistedTab()` read `localStorage` unguarded and runs as a `useState` initializer. Chrome throws on the *property read itself* when site data is blocked for the document, so a profile with storage blocked lost the whole editor instead of one remembered tab. Routed through the existing `safeLocalStorage()` helper with the access guarded too, matching the pattern `telemetry/config.ts` documents. The `setItem` on tab switch was unguarded the same way and is fixed with it.
**3. `TypeError: s.indexOf is not a function`** — `pruneKeyframeCacheToFiles` calls `key.indexOf("#")` on a key that is not a string, though `keyframeCache` and `gsapAnimations` are both typed `Map<string, …>`.
## Why
None of the three loses real work — they are incidental teardown, persistence, and cache-pruning paths taking down the whole editor. The `removeChild` one reaches by far the most users.
## How
### Locating #3
The Studio build ships no sourcemaps, so the reported frame in a minified chunk was not traceable as-is. Checking out the `v0.7.90` tag and rebuilding it reproduces the same asset filename hash **byte-for-byte**, which confirms the rebuild is the same code the crash came from. Decoding the frame against that bundle lands on `gsapKeyframeCacheHelpers.ts:198`.
### Fixing #3
`elementCacheKeys` owns the key-variant list every cache write sets. Two of its three keys are template literals and coerce on their own; the bare-id key was passed through raw, so a non-string `elementId` reaching it put a non-string key into both maps, which prune then choked on. It now coerces that key.
Review caught that it was not yet the *only* write gate: `useGsapTweenCache` built the same key list by hand at two sites, so a non-string id there still reached the maps uncoerced. Both sites now loop `elementCacheKeys`, and their matching reads use the same list instead of a second hand-rolled copy. That also closes a drift the helper's own doc comment warns about — the per-element writer omitted the `index.html#<id>` fallback key its siblings all set, so a reader falling back to that key saw a stale entry. The only remaining direct writers are in the dev-only timeline performance fixture, which generates its own string ids.
The coercion **reports** the offending value's `typeof`, constructor name, and source file as `studio:cache_key_non_string` rather than swallowing it. This is deliberate: every writer that reaches `elementCacheKeys` was traced and each one produces a string, so **which caller supplies a non-string id is still unknown**. Rather than guess at a producer, this hardens the single gate that can guarantee the maps' declared contract, and makes the next occurrence name its own producer. Only the value's shape is reported, never its content.
Fixes 1 and 2 are both the smaller diff *and* the root fix: one guard where every caller routes through, rather than one per call site. No behaviour change on any happy path.
## Test plan
- [x] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
Six regression tests, every one verified to fail without its fix:
- `Player.test.ts` — detaches the player element, then unmounts. Without the fix: `DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.`
- `LeftSidebar.storage.test.ts` — makes the `localStorage` property getter throw, then calls `getPersistedTab()`. Without the fix it fails with the same `SecurityError` the crash reports carry.
- `gsapKeyframeCacheHelpers.test.ts` — four cases: keys stay strings, the violation is reported, the normal string path stays silent, and a prune after a non-string write does not throw. Without the fix the last one fails with `TypeError: key.indexOf is not a function`.
Full Studio suite green: 3559 passed, 335 files, 0 failures. `oxlint`, `oxfmt` and `tsc --noEmit` clean.
Manual testing is unchecked deliberately: none of the three reproduces on a normal local profile, which is why they only surfaced in crash reports. The tests exercise the exact throwing boundaries instead.
## Not covered
Two other crash signatures reviewed alongside these are **not** fixed here: one occurs almost entirely on locally-built dev Studio rather than released builds, and the other has not appeared on any recent release.
**Follow-up worth its own PR:** ship sourcemaps for the Studio build. Rebuilding a tag to decode one frame worked, but it should not be the process, and it is the prerequisite for diagnosing the next minified crash.
It is not a user opt-in — execute.ts arms it automatically on the CLI render
path, so ~11% of installs already route without anyone choosing it. The
opt-in is at the CALL SITE: the flag defaults off and only the two CLI sites
set it, excluding programmatic renderLocal consumers because the mechanism
mutates process.env. That polarity guards embedding contexts, not users.
Calling it opt-in understates today's exposure, which changes how a reviewer
judges the ramp: it is not protecting users from a feature they chose, it is
governing exposure already happening without their choice.
Leaves the accurate uses alone — 'explicit user opt-in' means someone setting
HF_DE_PARALLEL_ROUTER themselves, and the call-site flag is genuinely opt-in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(cli): track which registry items `add` installs
`cli_command` records that `add` ran and nothing about what it installed, and
the registry is served from raw.githubusercontent.com, which gives no per-item
counter either — so there is no way to tell which block or component people
actually pull, and no way to know what is worth building more of.
Emit one `registry_item_added` event per item written into a project, from
`runAdd` after the install succeeds. That is the single choke point: the bulk
`add <tag>` path re-enters it per item, and a failed or compatibility-refused
install throws before it, so a refused install is never counted as a download.
`requested` separates the item the user named from the transitive
`registryDependencies` pulled in behind it; without it a popular dependency
outranks everything that depends on it.
Item names are public registry identifiers, never user content or project data,
and the event goes through `trackEvent` — an install that opted out via
`hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK`
sends nothing.
* test(cli): cover `add` telemetry end to end against the built CLI
The unit tests assert the emit seam and nothing past it. `shouldTrack()`
short-circuits whenever `isDevMode()` is true, and that is true for any `.ts`
entry, so under vitest a real event and no event are indistinguishable and the
transport is never exercised at all.
Drive the built CLI instead and assert on the HTTP body it actually produces:
one event per installed item, the dependency reported with `requested: false`,
an opted-out install sending no request at all (not merely one without this
event), and a refused install counting nothing.
Two fixtures, because neither case is reachable through the real registry. The
registry origin is a first-class project setting, so a local one supplies the
`registryDependencies` edge that no shipped catalog item declares today; and
`globalThis.fetch` is wrapped to capture the batch rather than send it. The
faked 200 is load-bearing: only a failed flush leaves events queued, and only a
non-empty queue spawns the detached `flushSync` child that would bypass the
hook and reach production analytics.
Verified the check can fail — forcing `requested: true` for every item turns it
red on exactly the dependency assertion.
## Why
#3094 fixed one way the mount and render paths disagreed, and added the gate that catches disagreement. It deliberately left the rest.
Four divergences are still live. Each one means a composition assembles differently depending on whether it is being previewed or rendered — the same class of defect that shipped three catalog components unstyled, just with smaller blast radii.
## How
Both paths now derive root discovery, scope identity, asset sources and order, hoisted links, variable carriers and nested-host enumeration from the shared module #3094 introduced. Each keeps its own I/O, which is where they genuinely differ. The compiler's local depth cap and root lookup and the runtime's three pre-filtered head parameters are gone; the runtime hands over the head node and lets the module decide what comes out of it.
Four behaviour changes, each stated by what actually differs rather than by the edit:
**Inline `<head>` scripts.** The compiler looped head scripts with a `src` branch and no `else`, so an inline one was silently discarded on render while the runtime ran it. That is losing code, not holding a convention — the runtime's answer wins. Head and content scripts now share one loop, head first, order preserved. A non-templated sub-composition with an inline head script went from **0 collected scripts to 1**, wrapped, body intact.
**`<link>` hoisting.** Conditional on render, unconditional on mount, so a templated sub-composition's webfont link was dropped in video and kept in preview. Hoisting is the superset and matches what the author declared. A templated composition with a stylesheet link went from **no external links to that link**. The parity fixture that previously recorded this shape as a known exclusion now gates it.
**Anonymous hosts.** With a host naming no id, the compiler fell back to the first declared composition and scoped to it; the mount left the content unflattened and injected its stylesheet into the host `<head>` **unscoped**, so a composition's CSS leaked into whatever mounted it. The compiler's answer wins. The injected rule went from a bare `.label { … }` to `[data-composition-id="scoped-text"] .label { … }`.
**Scope ids.** The compiler splits the CSS scope id from the script composition id; they differ only when a host names an id the content does not declare, and there the scripts follow the declared id so their self-referencing queries resolve. The runtime used one for both. The split wins: a host naming `captions-comp` over content declaring `captions` now emits scripts bound to `captions` while its CSS still scopes to `captions-comp`.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
Core 1694 passing, producer 574 passing, the parity contract now gates the two divergences it can observe (the other two carry no contract field, so they are gated by unit tests naming the exact before/after). Lint 0, `typecheck:runtime` and the runtime preview guards clean, package cycles unchanged.
Characterization-first: both suites were run and recorded green before any decision moved, so a behavioural drift would surface as a red test rather than a silent difference.
**One assertion changed, deliberately.** A runtime test asserted that an anonymous host's composition is *not* flattened, and documented that as intentional. That premise is now false. What the test actually cared about — the root and its content present under the host — still holds and is still asserted; the "not flattened" claim flipped, and the test now also asserts the scoping that was missing.
## Not covered
The variable-carrier divergence and its `TODO(template-var-carriers)` are untouched by design, as is recursion on the mount path — a sub-composition containing its own `data-composition-src` is still silently dropped in live preview. Both are behaviour changes with their own units, and both are now one-line-ish changes because the shared module already reports what they need.
`runtimeScopeCompositionId` no longer falls back to the authored scope id. This is a functional change beyond the four above, surfaced in review: for an anonymous host with authored variable defaults, the runtime previously stashed them under the declared id, and now does not. It removes a runtime-vs-compiler divergence in the correct direction — the runtime was doing work the compiler never did, and the compiler is authoritative for a shipped composition — but a caller relying on runtime-only variable exposure loses it.
The three copies each of the flattened-root helper and the id assignment are left alone: they look mergeable and are not cheaply, and they touch the instancing contract the pixel harness guards.
## Worth knowing
The parity test's compiler arms import core's **built dist** while the mount arm imports source, so core must be rebuilt before that lane means anything after a compiler change. Skipping it produces a phantom divergence that looks exactly like a real one.
Apply the --disable-gpu-compositing workaround to every software capture, not just BeginFrame ones. SwiftShader's compositor re-presents stale raster for a partially invalidated layer, so successive screenshot captures accumulate copies of earlier seeks; alpha renders are forced onto the screenshot path and were the only ones left unprotected.
Refreshes the byte-strict png-sequence alpha baseline for the resulting antialiasing delta (content unchanged, min PSNR 41.3 dB).
Fixes#3049.
* fix(scripts): render template-only blocks in catalog previews
The catalog preview renderer treated any file containing `__timelines` as a
standalone composition and rendered it as index.html directly. The 12 VS Code
snippet blocks register their timeline inside a `<template>`, which stays
inert until a host mounts it, so every one of them failed with "Composition
has zero duration" and no preview could be produced from the registry at all.
Six of the previews on the docs CDN were hand-made from a project still
mounting Monokai, so Dark+, High Contrast, High Contrast Light, Solarized
Light, Visual Studio Dark and Visual Studio Light all showed Monokai's video.
Detect standalone-ness on the document with template content stripped, mount
the mirrored install-layout copy so a block's own `../assets/*` references
resolve, and capture posters opaque: `format: "png"` is the engine's
transparent mode and forces `background-image: none` on every composition
root, which erased the desktop backdrop these blocks paint.
Publishing gets the missing half too: preview URLs are stable and the objects
are uploaded `immutable` with a one-year max-age, so a re-upload alone never
reaches a reader.
* fix(scripts): install ffmpeg in the preview job and fix the sibling renderer
The canary this PR added caught its own regression: the poster transcode
shells out to ffmpeg, which ubuntu-latest does not ship and this job never
needed, so both canaries failed with `spawnSync ffmpeg ENOENT`. Install it
the way every other render job does. `encodeForWeb` has always shelled out to
the same binary; the job only got away with it because `--skip-video` skipped
that path.
generate-template-previews.ts captures posters through the same transparent
`format: "png"` mode, so any template painting its own backdrop loses it
exactly as the code snippets did. Fixing one renderer and leaving its sibling
on the broken call would just move the bug.
Also fold the three separate parses of registry-item.json into one read: they
had drifted into three different failure behaviours for the same file.
Rebased onto main (was 308 behind) and gated the new default-on behaviour on
the de-parallel-router canary, at 5%.
Default-ON without a ramp is a ~17x exposure jump: from ~6% of eligible
renders today to all of them, landing on profiles the opt-in trial never
covered (<=4 CPUs and Docker, ~12% of eligible renders between them).
0.7.60-0.7.64 is why that matters — every unclamped render reverted for five
consecutive releases and nobody noticed.
The gate reuses the breaker's own disarm: non-enrolled installs get an
explicit HF_DE_PARALLEL_ROUTER=false, because with default-ON polarity
deleting the var means ON. Setting the registry percentage to 0 is therefore
a full fleet-wide revert with no release.
Today's ~11% of installs routing is emergent — the product of eligibility
rules and a capped trial — so it drifts with fleet composition and cannot be
turned off without shipping. The point of the canary is that the number
becomes chosen and revertible, not that it is smaller.
Also replaces the registry test that pinned the percentage to 0. Its intent
was 'ramp only alongside the circuit breaker', but pinning 0 blocks the ramp
forever and never checks the wiring it names. It now asserts the wiring
directly, and fails if either the canary gate or the breaker consult is
removed.
Hold at 5% until PRINFRA-372 resolves: --workers auto crashes every worker on
macOS arm64 while --workers 1 is clean, and the router forces 3 workers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ownership detection classified ANY defined HF_DE_PARALLEL_ROUTER as a user
choice, but both parsers read empty/whitespace as "unset -> default ON".
Launching with `HF_DE_PARALLEL_ROUTER=` therefore routed the render (empty
parses as ON) while exempting the install from its circuit breaker: after a
verified fallback applyDeParallelRouterBreaker() no-op'd, so the install kept
retrying the failing router instead of latching off. That is the exact
first-fallback protection this PR exists to provide, lost on a documented
default path. Ownership now uses the same normalization as the parsers.
Also: only announce a trip the breaker could act on. With an explicit user
opt-in the breaker is deliberately a no-op, so "now off for this install" was
factually wrong — and reprinted on every later revert, since the user's value
keeps the router active.
Tests: set-but-empty and whitespace both latch off and persist the fired flag
(fault-injection verified — restoring the old check fails both); explicit
"true" survives a fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DE parallel router (HF_DE_PARALLEL_ROUTER) becomes default-ON. The soak
answered the safety question it was gated on: zero damaged frames shipped —
every fallback was the self-verification net catching a bad frame and
recovering on the screenshot path. Verify PSNR p10 sits flat near 40 dB
against a 32 dB floor. The residual 2.31% revert rate is an efficiency cost
(a revert forfeits the speedup, never the output), accepted in exchange for
parallelizing the >=700-frame band — roughly 80% of all DE capture
wall-clock, frame-weighted.
Default-ON is safe because the per-install circuit breaker stays underneath
it. That distinction matters: 9.8% of installs hit a revert, and they are
latched off permanently after the first one. Without the breaker those
installs would go from "one slow render, then protected" to "every eligible
render is slow".
The breaker, adapted for a default-ON flag:
- Writes an explicit HF_DE_PARALLEL_ROUTER=false and persists it to
~/.hyperframes/config.json, so the install stays off across processes.
Absent no longer means off, so the switch has to be written, not unset.
- Trips only on a real fallback, never on render count — a healthy install
keeps the speedup indefinitely.
- Independent of telemetry state: opting out of analytics must not cost a
user the faster renderer. Telemetry governs reporting, not behavior.
- An explicit user value wins in both directions, latched before the breaker
can write the var and make the two indistinguishable.
- The user is told when it trips and how to re-enable.
isDeParallelRouterEnabled() parses the kill switch properly: false/0/off/no
(case- and space-insensitive) disable; unset or empty is the default. A bare
`!== "false"` would silently ignore every spelling but one and hand parallel
DE to a user who asked for none.
Refs PRINFRA-384
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(studio): stop a resize writing size into the tween that carries scale
Resizing a scale-driven element failed with "animation not found", and the
element could not be saved again at all.
The tween resolved for the resize's group is, for such an element, the one
carrying `scale`. When it is an instant hold the code handed it straight to the
size commit, which wrote `width` and `height` into it. One tween now spanned two
property groups, so the parser classified it as neither — it lost its group
suffix, and its id with it. Every later edit looked for a scale tween and a size
tween, found a tween with no group at all, and had nothing it could address.
Size goes to a size hold of its own now; the scale hold is left alone. Where the
damage has already happened it is repairable: splitting the mixed tween into
property groups gives back a `scale` tween and a `size` tween.
* fix(studio): let the resize say whether it settled the drop point
Resizing an element whose scale is an instant hold saved the new size and then
snapped the element back to its authored position, every drag.
Whether the caller persists the drag offset was inferred from the element's
tweens: a scale-group tween meant "the resize settles its own position, hold
the offset back". That is true of the scale route, which commits a scale and
then measures where centre-scaling put the box. It is not true of an element
whose scale is an instant hold — that has a scale-group tween and still
commits width/height. So the offset was withheld, nobody wrote it, and the
position tween re-asserted the authored value a frame later.
The outcome carries the answer now. A resize that moved the element says so;
everything else leaves the anchor to the drag, which is what already handles it.
* test(studio): sweep every animated shape a resize can be handed
Both faults on this branch were found one composition at a time, which is a
bad way to find the third.
Drives the real intercept across the cross-product of what an element's tweens
can look like — scale absent, an instant hold, a real tween, longhands; size
absent, a hold, a tween; position absent, a static hold, a tween; plus the 3D
and rotation set a card carries and a tween that already spans two groups —
and holds all 108 to the two rules that were broken: never address an
animation the source does not have, and never leave a tween spanning two
property groups.
The server stand-in answers the way the real one does, rejecting an id it
cannot find, and applies what it is told, so a run that corrupts the animation
list is caught by the next mutation in the same run.
* fix(studio): decide a uniform resize in pixels, not in scale
A free corner drag whose two axes happened to land within 0.01 of each other
was committed as one `scale` value for both, and gave back a box shorter than
the one dropped — 326x213 became 326x211.
The threshold was a fixed amount of scale. That is invisible on a 40px box and
two pixels of height on a 408px one, and the question was never about scale: it
is only ever whether using one value for both axes would move an edge. So it
asks that, in pixels, against the axis the collapse would distort.
Found by a geometry sweep added alongside: 120 runs over the routes a resize
can take, six rotations from none to 180 degrees, and four drops from near-zero
to an aspect flip, each checking the committed scale or size reproduces the
RENDERED box the user dropped — and, where the resize reports it owns the drag
offset, that the box lands on the drop point too. Six runs failed before this
change, all of them the near-uniform shrink, at every rotation including none.
Rotation was the suspect and turned out to be innocent.
* refactor(studio): split the resize sweeps into named steps for the audit gate
* test(studio): pin which tween a resize edits when the element has several
A composition animates the same property more than once — a scale-in early, a
scale-out late — and the one the user means is the one under the playhead.
Editing the wrong one changes a moment they are not looking at and leaves the
moment they are looking at unchanged, which reads as "the resize did nothing".
Six playheads across two scale tweens, including both sides of the midpoint
between them and a time past the end of both. Verified against a stubbed
selection that always takes the first tween: three of the six fail.
* fix(studio): only claim the drop point on the route that settles it
Review caught the inverse of the fault above it. The three returns that report
`ownsDragOffset` hardcoded `true`, and they are reached by the size-tween route
too — a real, non-hold size tween with no scale group. That route never
captures the element, so the finalize step no-ops, nothing writes the position,
and the caller withholds an offset it would otherwise have forwarded. The
release frame looks right because the live DOM was already settled; the
persisted state reverts on the next seek.
Fixed the same way the fault above it was: the finalize step reports whether it
settled the drop point rather than the caller assuming from where it was
called. It answers false when it is not the scale route, false when it cannot
measure, and TRUE when the box is already on the point with nothing to write —
forwarding an offset on top of that would move it off.
The geometry sweep accepted this silently, and the reviewer said why: its live
pose starts at the drop, which is where the gesture leaves it, so a route that
moves nothing trivially "lands" there. Each route now declares whether it
settles the drop point and the sweep holds it to that, which fails on 24 of the
120 runs with the old hardcoded `true`.
## Why
A composition mounted as a sub-composition lost its entire stylesheet and scripts whenever they were authored as siblings of the composition root inside its `<template>`. That shape is legal and common, so three catalog components — `oversized-cursor`, `device-frame-stage`, `touch-indicator` — rendered **completely unstyled** in the live preview.
`oversized-cursor` drew its pointer at 1280px against an authored `7cqw` (~134px at 1920), because `width: 7cqw` was never declared at all. Confirmed in the mounted document, where only the host's own `<style>` was present.
The rendered video was correct the entire time. This was a preview-versus-render divergence, and it survived a fully green test suite.
## How
**The fix.** `mountCompositionContent` collected assets from the composition root element, so sibling nodes were invisible to it. It now collects from the source node — a superset of the root, and the single point every mount path routes through (external fetch, inline template, nested). It also strips the mounted *clone* rather than the source: the previous code removed extracted nodes from the node it was handed, which on the inline-template path is a live `<template>` still in the document, so a remount would have found it emptied.
**Why nothing caught it.** Every CLI gate — `check`, `lint`, `validate` — reaches the compiler path through `bundleToSingleHtml`, and the compiler always collected from the whole template. Nothing in the CLI exercises the mount path, which is reachable only through the player and Studio. The repo's own parity test assembled a fixture two ways and deep-equalled a contract across them, but **both arms were static-compiler paths** — which is exactly why the runtime could drift unnoticed.
**The gate.** A third arm mounts the same fixture through `loadExternalCompositions` and extracts the same contract. Three fixtures run through all three arms, one authoring its assets as root siblings — the shape that broke. `authoredStyleSignatures` was already in the contract and is exactly the signal that was missing, so no contract field was added.
**The owner.** Both paths answer the same questions — which nodes are a composition's assets, in what order its scripts run, how its CSS is scoped, which head elements hoist, how nested hosts are discovered, which element carries variable defaults. They now have one module to answer them from. It holds decisions only, never I/O: the two paths differ at their boundary in ways that are essential (Node + linkedom + synchronous + strings; browser + fetch + live DOM + script *execution*), and the runtime ships as a bundle to a CDN, so anything it can reach is weight and risk. Hence zero imports, a structural input type rather than `Document`, and a test asserting the import surface stays empty.
Routing both paths through that module is deliberately **not** in this PR — it changes behaviour in four places (below) and belongs where each can be judged and reverted on its own.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
Every claim here was verified in both directions rather than assumed.
The fix's regression test fails on pre-fix code and passes after — run both ways. The parity arm was proven able to fail: with the fix reverted, the sibling fixture fails and names the composition's own scoped selector against an empty list, while the other two fixtures stay green, so the arm is targeted rather than blanket-red. Restored, 7/7 pass.
`bun run lint` exits 0. Core: 1690 tests passing, plus `typecheck:runtime` and `lint:runtime-preview-guards` clean. Producer: 571 tests passing. The shared module's own defect was reproduced by mutation — collecting from the composition root instead of the whole template fails three of its 17 tests, including the sibling case.
## Found while doing this, not fixed here
Deriving the shared decisions surfaced **four more live divergences**, none of them the reported bug, each a behaviour change to decide deliberately:
- The compiler silently drops inline `<head>` scripts — it handles the `src` case and has no `else` — while the runtime executes them.
- `<link>` hoisting is conditional on render and unconditional on mount, so a templated sub-composition's webfont link is dropped in video and kept in preview. This one reproduces under the new parity arm and is explicitly excluded from its contract, with the reason recorded in the file.
- For a host naming no id, the compiler falls back to the first declared composition and scopes to it; the runtime mounts the content whole, unflattened and unscoped.
- The compiler keeps two scope ids, CSS and scripts, so a script's self-referencing query resolves when a host names an id the content does not declare; the runtime keeps one.
Separately: the mount path **does not recurse at all**, so a sub-composition containing its own `data-composition-src` is silently dropped in live preview. The compiler has a dedicated recursive-discovery suite; the runtime has no nesting, circularity or depth coverage.
Each is recorded with its evidence in the commit messages here, and sequenced so the behaviour-changing ones land separately, after this gate exists to catch a mistake in them.
## Not covered
This does not heal the published docs by itself. Previews load `@hyperframes/player` unpinned, but the player bakes a version-pinned core runtime URL at build time, and core and player publish in lockstep — so the live catalog only recovers after both ship. There is no hotfix path short of a release.
parseFontFamilyValue() split the family stack on every comma, so
`font-family: var(--brand-font, inherit)` became two tokens:
`var(--brand-font` and `inherit)`. The var() guard from #1655 only
skips tokens starting with `var(`, so the orphan fragment was treated
as a requested family, failed every resolution path, and aborted
fail-closed distributed renders with:
FontFetchError: [Compiler] Unresolved fonts in fail-closed mode:
inherit). Distributed renders require all fonts to be resolvable.
Split on top-level commas only, so a var() expression (including a
nested one) stays a single token. Quotes are tracked as well, both so
parentheses inside a quoted family name cannot skew the depth counter
and so a legal quoted comma no longer splits.
Closes#3066
Both conflicts were import/export unions in the engine package, resolved by
keeping both sides:
- packages/engine/src/index.ts — main widened the urlDownloader re-export
(fetchPublicHttpsText, safeDownloadUrlIdentity, writeUrlDownloadTelemetry and
their types) while this branch added the notMediaPayload exports.
- packages/engine/src/services/audioMixer.ts — main added UrlDownloadError and
writeUrlDownloadTelemetry to the urlDownloader import; this branch added
isNotMediaPayload.
In audioMixer's prepare path both intents compose in order: main's download
telemetry and typed download failure, then the STUDIO-5433 non-media sniff
before the probe.
Panel sizes are reconciled against the window on every resize, with the preview holding a 360x200 floor that panels yield to before it gives.
Measured preview pane: 760px window 192 -> 433, 560px window 2 -> 516. Windows at or above 1280px are unchanged.
- fitPanels owns the who-yields decision for both axes
- panel caps are window-relative, replacing a flat 600px inspector cap
- below 860 the sidebar rails, below 700 the inspector collapses too
- auto-collapse is derived render state and never writes leftCollapsed
(localStorage) or rightCollapsed (synced into the shareable URL)
- the rail and header toggles act on the effective state, so neither is a
dead click that silently persists a collapse the user never asked for