## 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.
* 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`.
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
A near-uniform drag collapses to the `scale` shorthand, but the finalize step
measured the element at the per-axis pair it computed rather than the single
value the commit writes. The element was measured at a scaleY the file never
gets, so the position correction came out tilted by the difference.
Adds a sweep over the shapes a composition produces — shrink, grow, first
resize, rotated, steeply rotated, non-uniform, near-zero, inline-sized, no
position write, animated position, and two drags in a row — each checking the
element renders on its drop point from the PERSISTED scale and position. The
geometry model is calibrated against real gesture traces: the same inputs
reproduce the rects the browser reported to three decimals.
A scale resize measured its drop-point correction while the gesture's own
translation was still applied, but the position commit adds that correction
onto the element's PRE-gesture position, which it reads from the gesture's
base attributes. The two disagreed by the whole drag distance, so the commit
persisted a position a drag-length from where the element was dropped: it
held the drop point for one frame and then slid off.
Move the element back to that base before measuring, so the residual and the
commit share one origin. For an element whose position is a static hold that
usually means no correction at all, which is the right answer: scaling about
the centre already leaves it on the drop point.
An element whose position is animated left the drop point anyway. The
finalize step wrote its correction as a static position hold, and the
element's position tween rendered its own value a frame later and won.
Before that it stood down entirely on such elements, on the grounds that
a keyframed path has no single anchor to preserve, which had the same
visible result: the element moved.
It has an anchor, the frame the user is looking at. The correction now
goes into that tween at the playhead, through commitGsapPositionFromDrag,
which is the same commit a drag on the same element already uses. Static
holds keep the existing path.
This is the difference the debug log showed between an element carrying
position:to, which moved after release, and one carrying position:set,
which did not.
The finalize step measures where the committed scale put the box and
shifts the position hold by the difference. Whether the commit had
actually rendered when it measured was luck: on a first resize the
timeline had not re-seeked, so it measured the element at its natural
size still sitting on the drop point, saw no residual, and skipped the
correction. The scale then landed, GSAP rendered it about the element's
centre, and the element jumped by the whole drag distance. Elements
resized before got a correction only because their previous scale made
the residual non-zero by accident.
The committed scale is now applied to the live element before measuring,
so the measurement means what its comment says either way, and a skipped
correction is logged rather than silent.
Confirmed against a real session: a first resize of a 630px chip now
reports residual -109.93 and lands on the drop point, where it previously
logged no scale-finalize at all.
A uniform drag committed the `scale` shorthand. If the tween's keyframes
already stated `scaleX` and `scaleY`, the commit left both forms in the
same keyframe, and GSAP animates each property name independently, so the
longhands ran alongside the shorthand and won.
The resize therefore computed the right number, wrote it to the file, and
did nothing: the element snapped back to its old size the moment the
handle was released. Reproduced from a real session, where a drop at 384px
on a 630px element wrote {scaleX: 1, scaleY: 1, scale: 0.61} and rendered
at the original size.
The mixing hazard was already known in the other direction, where a
non-uniform drag takes a rewrite path that normalizes every keyframe to
the longhands. This makes the condition symmetric: whenever the tween
already speaks longhands, a uniform drag speaks them too.
Resizing an element whose size is driven by a scale animation committed a
scale computed against a hardcoded 200px fallback, because the only
original size the draft recorded was the element's INLINE width, and a
composition sizes its elements from the stylesheet.
A 630px chip dropped at 1260px wide committed a scale of 6.3 instead of 2,
so it landed at over three times the size it was dropped at. The next drag
compounded it, because that wrong scale then counted as the element's live
one.
The draft now records the box it measured, once, before it writes a width
of its own, and the intercept reads that. The inline attributes keep their
own job of restoring an inline style, which is why they cannot answer this
question.
Review caught that the same anti-pattern was still live in the Studio binding:
canaryEventProperties destructured only `enabled` and dropped the reason. Its
own doc comment promised 'identical shape to the CLI, so a rollout spanning
both reads as one flag' — which the CLI-only fix had just made false.
This matters beyond symmetry. A CLI-launched Studio adopts the CLI's decisions
and shares its bucket seed, so a cohort flip can surface on either surface.
Emitting attribution on only one leaves Studio-observed flips unattributable
and makes the two flip counts irreconcilable — and Studio is the surface most
likely to expose a shared-seed-with-diverging-id pattern, which is the open
question the reason exists to answer.
Also adds the no_unit_id emission test the CLI side advertised but never
asserted, and a Studio pair pinning that a URL override and a cohort roll
produce the same assignment with different reasons.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- A long-lived preview cached its telemetry posture in two places
(readConfig and shouldTrack). Running `telemetry disable` in another
terminal left it resolving canaries and injecting the CLI id for hours.
Both caches are now dropped together at a request boundary.
- Studio minted and shipped a telemetry id for every render regardless of
the browser profile's opt-out, and the server emitted the outcome under
CLI policy, which cannot see localStorage or DNT. The browser now sends
an explicit telemetryOptOut, distinct from an old client's omission.
- Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard,
so even a loopback bind accepted a hostile Host. The guard now holds for
loopback binds and, on a LAN bind, admits only names this machine
answers on.
- sunsetAfter had no reader of the current date. A scheduled workflow runs
scripts/check-canary-sunset.ts weekly, so a failure lands on the
rollout's owner rather than on an unrelated PR author.
- The install-state seed memo outlived `rm -rf ~/.hyperframes`,
resurrecting a cleared cohort. Removed; it only saved a read on a
readConfig cache miss.
Docs updated for the Host rule and the 100% exclusion carve-out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(studio): define timeline viewport budgets and fixtures
* test(studio): gate timeline viewport performance in Chromium
* refactor(studio): isolate clip drag lifecycle
* refactor(studio): extract timeline render contracts
* perf(studio): centralize timeline viewport geometry
* perf(studio): follow playhead across virtualized rows
* perf(studio): add timeline clip-window index primitive
* perf(studio): virtualize timeline clip windows
* perf(studio): stop timeline scroll work when row virtualization is off
The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.
Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.
The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.
Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.
The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.
Verified against a live Studio dev server on the fixture project:
flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
flag off, after: interactionP95 33.6ms, longest task 0ms, 5/5 runs pass
flag on, after: interactionP95 33.2ms, 4/5 runs pass, exit 0
The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.
* ci(studio): run the timeline viewport gate on studio changes
The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.
Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.
Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.
Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.
The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.
Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.
* fix(ci): preserve both timeline gate evidence arms
* ci(studio): report timeline gate arm statuses
* ci(studio): require timeline gate evidence artifacts
* fix(studio): keep dense keyframes readable
* fix(ci): resolve timeline stack audit findings
R5 blockers
- Negative install-state latch was cached for the process lifetime, but
only `true` is monotonic across processes. A long-lived preview server
held a stale `false` and could re-enrol after another process tripped
the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
{ok:true, mirrored:false} to success, so a run that mirrored nothing
reported done with the latch only on the erasable store. It consumes
writeConfigWithResult and retries until both stores carry it.
Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
"anonymous": computed against the shipped hash, 100% of them were
enrolled in calibration-50 rather than 50%, and they merged into one
PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
write-once authoritative, but readConfig took config.json's blindly, so
the stores could hold different seeds until a re-mint flipped every
cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
re-rolling the seed on every command, and the "cohorts will not be
stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
forever — poisoning the exact metric this work exists to produce.
Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
disable` during a running preview server was ignored for hours while
the server kept serving pre-opt-out decisions. The memo is keyed on the
telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
argument, minting and PERSISTING a tracking id for an opted-out profile
— a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
for installs that opted out of telemetry entirely.
Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
stitching and split one user across two PostHog persons. Identity is
now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
non-browser client sets Host freely. It is a browser DNS-rebinding
mitigation, not access control, and now says so.
Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
the fast path, so the registry's "delete the entry at 100" step was an
unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
core suite would go red on 2026-09-15 for every unrelated PR; and `>`
against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
enough to fail ~1 run in 200. Seeded.
Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.
Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
P1 — the required Test lane was red, and it was my test. The
hostile-Host SPA case asserted a 200, which only holds when
packages/studio/dist is built: true on a dev box, false in CI, so it
passed locally and failed there. The Host split moved into a pure
buildStudioHeadScriptsForHost() and is asserted directly; the route test
no longer depends on build state. Verified by running the CLI suite with
the bundle moved aside — 2330 pass.
P1 — studio:* still bypassed most privacy controls. It honoured two
localStorage keys but not navigator.doNotTrack,
VITE_HYPERFRAMES_NO_TELEMETRY, Vite dev mode or API-key eligibility, and
canary enrolment honoured a different single control. New
telemetry/policy.ts is the one answer to "may this profile be measured",
consumed by both transports and by enrolment. It imports only ./config,
so no cycle with the modules that import it. Each control is asserted
individually.
P1 — LAN/remote preview lost the authoritative decisions. Withholding
the whole head script for any non-loopback Host also dropped the safe
{enabled, forced} map, sending a supported HYPERFRAMES_PREVIEW_HOST=
0.0.0.0 Studio back to re-deriving. Identity injection is now gated
separately from decision injection: identity is loopback-only, decisions
always publish.
P1 — the breaker latch was neither authoritative nor truthfully
persisted. syncInstallState swallowed its own failures so
writeConfigWithResult always reported ok, and reads took the flag only
from config.json. The latch is now merged into every effective read,
which makes install-state authoritative and closes both the failed-mirror
and stale-concurrent-writer paths; the write additionally reports
mirrored: false rather than swallowing.
P2 — public contracts. canary-rollouts.mdx said a config wipe loses the
breaker (it does not) and documented the superseded {name: boolean} map
with unconditional CLI precedence; both corrected, with the precedence
ladder written out and the override exception stated explicitly. PR body
rewritten — it still named ~/.local/state, claimed state survives
deleting ~/.hyperframes, and carried stale counts.
Tests: 2330 CLI (bundle absent), 3151 Studio, 24 core. Fault injection:
reverting each fix alone fails 5 CLI / 5 Studio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
P1 — SPA route bypassed the DNS-rebinding guard. Guarding only
/api/telemetry-identity left the catch-all as an open side door: a
rebound origin could fetch `/` and read __HF_CLI_DISTINCT_ID and
__HF_CLI_BUCKET_SEED straight out of the returned HTML. The SPA response
now applies the same isLoopbackHost() check; an untrusted Host still gets
a working Studio, just with no identity, seed or decisions injected.
Route-level regression added.
P1 — a CLI cohort roll could override Studio's own opt-out.
decideStudioCanary() adopted the injected decision before checking
isOptedOut(), so CLI-telemetry-on plus Studio-opted-out still enrolled
Studio. A bare boolean could not express the difference between a
deliberate override and an ordinary cohort roll, so the injected map now
carries provenance ({ enabled, forced }). Forced wins outright — it is
the documented escalation channel and must behave the same on both
surfaces — while a percentage roll now loses to this profile's opt-out.
Full interaction matrix tested.
P1 — the legacy studio:* path sat outside both contracts.
utils/studioTelemetry.ts shipped its own opt-out key and its own send
loop, so the documented hyperframes-studio:telemetryDisabled did not
silence it and its events carried no cohort assignment. It now honours
both keys (the legacy one stays, so nobody already opted out is quietly
re-enabled) and mixes in canaryEventProperties(), making "every
telemetry event carries the assignment" actually true.
P2 — partial salvage could drop a tripped breaker.
salvageInstallState() discarded the whole record when markerAt and
bucketSeed were both unusable, taking deParallelRouterTrialFired with it
and re-enrolling a machine whose router already failed. All three fields
are now independently salvageable.
Docs: canary-rollouts.mdx said "disabling telemetry disables the
reporting, not the enrolment" — exactly backwards since the opt-out gate
landed. Corrected; checked for other copies, none.
Tests: 13 new (4 opt-out precedence, 4 legacy-path opt-out and canary
props, 3 route-level host guard, 2 breaker salvage). Fault injection:
each of the four fixes reverted independently fails its own tests
(2 CLI + 1 Studio + 2 Studio).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes both cross-surface findings with one mechanism. The CLI publishes
window.__HF_CLI_CANARY_DECISIONS ({ name: boolean }); a CLI-launched
Studio takes it as authoritative over its own seed, URL override and the
registry percentage.
Studio re-deriving could not agree with the CLI in three cases:
- Telemetry off. The CLI resolves telemetry_opt_out, but Studio's
opt-out is a separate localStorage flag it cannot see, so it would
evaluate normally and could enrol on a render the CLI excluded. The
previous commit gated each surface independently; that fixed silent
enrolment per surface but NOT the disagreement between them.
- HF_CANARY_* override. Env vars never cross into the browser — Studio
reads only its URL param / sessionStorage — so a support session
forcing a canary on got the CLI forced and Studio guessing.
- No seed injected. Studio falls back to a different unit id, i.e. a
different bucket.
Shipping the decision instead of the inputs makes divergence structurally
impossible: one evaluation, two surfaces. It also exposes strictly less —
booleans about features, rather than the seed buckets derive from — which
is why it is safe to publish with telemetry off, the case it exists for.
Studio still evaluates locally when standalone, or for a canary the CLI
did not publish, and ignores a non-boolean value rather than trusting it.
Tests: 6 Studio (CLI-off wins over unset local flag, CLI-on with no URL
param, beats contradicting override, beats seed, falls back per-canary,
rejects non-boolean) and 4 CLI (decisions with telemetry off and no
identity, alongside identity when on, script-tag escaping on a hostile
canary name, throwing resolver degrades to identity only). Four existing
identity tests asserted the old "nothing when telemetry off" contract and
were updated; the registry is now mocked there so string assertions don't
move when a canary is added or ramped. Fault injection: dropping the
adoption fails 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both reviewers flagged the same gap: seed injection was gated on
telemetryShouldTrack(), but canary EVALUATION was not. An install with
DO_NOT_TRACK=1 was still bucketed and still had real code paths flipped
(e.g. HF_DE_PARALLEL_ROUTER), silently and unmeasurably.
A canary is a measured rollout — we enrol a slice precisely so it can be
compared against everyone else. An install that sends nothing can't be
compared, so enrolling it buys no signal and only changes that user's
code path, on an experimental feature, without their knowledge. That is
the wrong side of an opt-out.
Resolves to a new `telemetry_opt_out` reason BEFORE bucketing, so no
cohort is assigned at all. Distinct from `excluded` because "why is my
canary off" has a very different answer for CI than for opted-out, and
the reason never reaches telemetry by construction.
Covers every opt-out route: persisted preference, the runtime env vars
and dev/telemetry-disabled builds via policy.ts, and Studio's
hyperframes-studio:telemetryDisabled.
An explicit HF_CANARY_* / ?hf_canary_*= override still wins — a
deliberate local choice, not silent enrolment, and the documented way to
exercise a canary with telemetry off.
The CLI check mirrors shouldTrack() rather than importing it: client.ts
already imports canary.ts for canaryEventProperties, so depending on it
would be a cycle. Both read the same two inputs, so they cannot disagree.
Tests: 9 new across CLI and Studio (preference off, each runtime
override, no bucket assigned, override still honoured, flag properties
all-false). Fault injection: removing the CLI gate fails 6, removing the
Studio gate fails 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cohort membership now survives a config wipe. Canaries bucket on a
dedicated bucketSeed (fresh random UUID, distinct from anonymousId by
design) that is mirrored write-once into the install-state file and
inherited at mint: a wipe re-rolls the telemetry id but never the canary
assignment. This removes cumulative-exposure drift for the recoverable
churn bucket entirely — the residual drift comes only from fresh
machines, containers, and genuinely new users — and keeps before/after
comparisons valid across a reinstall.
The seed is never emitted in telemetry (only the resulting true/false
assignments are), so it does not link the old id to the new one
server-side. The residual linker is the flag vector itself (k bits for k
live canaries), documented as such. An explicit reset still works by
deleting the state file, and the no-identity test now also asserts the
seed differs from the anonymousId.
Cross-surface coherence: the CLI's studio server injects the seed as
window.__HF_CLI_BUCKET_SEED (same telemetry gate and script-escaping as
the distinct id, and on the /api/telemetry-identity fallback), and the
Studio binding buckets on it when present — without this the CLI would
bucket on the seed while Studio bucketed on the distinct id, splitting
one machine across cohorts (calibration check 4 would catch exactly
this). Standalone Studio still buckets on its localStorage id: the
browser has no second storage location, so that id doubles as the seed.
Legacy configs are backfilled once (lineage seed if the state file has
one, else minted) and persisted immediately — an unpersisted seed would
re-roll cohorts every process. Safe to ship in the same release as the
first canaries: no prior release emitted canary properties, so the
bucketing-unit change is unobservable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the single `canaries: "a,b"` telemetry property with PostHog's own
flag shape, one property per registered canary:
$feature/canary-de-parallel-router: "true" | "false"
PostHog treats `$feature/<key>` as a first-class flag property, so breakdowns,
funnels split by cohort and the experiment surfaces work on a canary with
nothing configured server-side. The decision still happens locally: the render
path forbids render-time network calls, behaviour must not depend on analytics
being reachable, and neither the CLI nor Studio ships posthog-js (both
hand-roll a batch POST, so there is no SDK to evaluate a real flag with).
Decide locally, analyse natively.
Two decisions worth recording:
- BOTH ARMS ARE EMITTED. A non-enrolled install reports "false" rather than
omitting the property. Absent means "this build predates the canary", which
is a different fact from "this install is control" — collapsing them makes a
ramp unreadable, because you cannot separate a control group from an old
version.
- KEYS ARE NAMESPACED with a `canary-` infix. A real PostHog flag namespace
already exists in this project, owned by the web app (`enable-chat-tab`, set
by posthog-js from `$lib=web` events). Namespacing guarantees a canary key
can never alias a real flag key and have the two fight over one property.
Values are the strings "true"/"false" to match how PostHog records boolean
flag values, so the property is directly comparable to a real flag.
98 core / 1437, 166 cli / 2194, 269 studio / 2982 green; tsc clean across all
three packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Studio (browser) binding so a canary can span the CLI and the editor,
and fixes a bundling mistake the studio test suite caught.
## The binding
Same public API as the CLI — `isCanaryEnabled("name")` — so a call site reads
identically whether it runs in Node or the browser. Three inputs differ:
- UNIT ID: `resolveStudioDistinctId()`, which already adopts
`window.__HF_CLI_DISTINCT_ID` when the CLI launched Studio. A CLI-launched
Studio therefore lands in the SAME cohort as the CLI: a rollout spanning
render and editor is coherent for that user instead of enrolling their
terminal but not their editor. A test pins that the id is passed through
unmodified — prefixing or re-hashing it would silently break that parity.
- OVERRIDE: no `process.env` in a page, so `?hf_canary_<name>=on` mirrored
into sessionStorage. Session scope is deliberate. A URL is the right carrier
(shareable — "support: open this link"), but persisting a URL-borne override
to localStorage would let one click silently pin a browser into a cohort
forever, long after anyone remembers why. Closing the tab is the reset;
`=reset` clears it explicitly.
- EXCLUSION: `navigator.webdriver` stands in for the CLI's `is_ci`. Automated
browsers mint a fresh localStorage id per run, so they would hop cohorts
between runs — noise in the signal, nothing learned about real users. An
override still reaches them, which is how you test a canary under Playwright.
Studio's `trackEvent` now attaches `canaries` to every event, mirroring the CLI.
## The bundling fix
Importing the `@hyperframes/core` barrel into studio browser code broke two
unrelated hook test files with an esbuild TextEncoder invariant violation. The
barrel re-exports the whole core surface (parsers, lint, studio-server), so it
drags a Node-oriented dependency graph into a browser bundle — the test
failure was the symptom, the bundle bloat was the bug.
`@hyperframes/core` now exposes `./canary` and `./canary-registry`, declared in
packages/core/package-subpaths.json (the generated source of truth for exports —
hand-editing package.json is reverted by the sync script) and marked
`environments: [browser, bun, node]`. Both the studio AND cli bindings import
the leaf modules; the CLI gets the same benefit for a different reason, since
this resolves on the startup path — the reason the producer is lazily loaded.
Verified: the two hook files pass again; 269 studio files / 2982 tests, 98
core / 1433, 166 cli / 2194 green, `bun run lint` clean including the subpath
check. Fault-injection confirms both design decisions are pinned — swapping
session for local storage fails the scope test, prefixing the unit id fails the
CLI/Studio cohort-parity test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EaseCurveSection.tsx was 635 lines against CI's 600-line cap, which has
been failing the File size check on main for four consecutive runs and
blocks release cuts.
Moves the two self-contained presentational pieces into a sibling
EaseModeControls.tsx, following the pattern the directory already uses
(easeCurveSvg, easePresetLibrary, EaseParamFields): the mode radio group
(EaseModeToggle) and the preset grid (EasePresetGrid), plus the mode
vocabulary they own — EASE_MODES, the EaseMode type, MODE_LABELS,
DEFAULT_EASE_BY_MODE, and the DEFAULT_CURVE/Pts pair those defaults are
built from. Both components are stateless: they take the current
selection and emit a committed ease string, so nothing had to be
rewired. Only the symbols the parent still references are exported —
EASE_MODES and DEFAULT_EASE_BY_MODE became file-internal, since the
components that consume them moved too.
No behaviour change. EaseCurveSection is now 556 lines, the new file 110.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Renders tab format popover rendered as an in-flow absolute panel inside
the right panel, which is overflow-hidden, so it was sliced at the panel
edge. Portal it to the body and position it with the shared floating-panel
helper instead.
The ui/Tooltip bubble clamped only its centre point to the viewport, so a
wide bubble near an edge still hung off-screen (the timeline Selection tool
tooltip lost 32px on the left). Clamp with the measured bubble width.
## What
Removes six Studio feature flags that have been default-`true` for 7+ weeks. Each is reachable under two env names, so this deletes **12 `VITE_STUDIO_*` env vars**:
| Flag constant | Env names removed | Default-on since |
|---|---|---|
| `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`, `VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | 2026-05-12 |
| `STUDIO_INSPECTOR_PANELS_ENABLED` (+ its `STUDIO_PREVIEW_SELECTION_ENABLED` alias) | `VITE_STUDIO_ENABLE_INSPECTOR_PANELS`, `VITE_STUDIO_INSPECTOR_PANELS_ENABLED` | 2026-05-12 |
| `STUDIO_BLOCKS_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_BLOCKS_PANEL`, `VITE_STUDIO_BLOCKS_PANEL_ENABLED` | 2026-05-18 |
| `STUDIO_GSAP_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_GSAP_PANEL`, `VITE_STUDIO_GSAP_PANEL_ENABLED` | 2026-05-28 |
| `STUDIO_KEYFRAMES_ENABLED` | `VITE_STUDIO_ENABLE_KEYFRAMES`, `VITE_STUDIO_KEYFRAMES_ENABLED` | 2026-06-05 |
| `STUDIO_RAZOR_TOOL_ENABLED` | `VITE_STUDIO_ENABLE_RAZOR_TOOL`, `VITE_STUDIO_RAZOR_TOOL_ENABLED` | 2026-06-10 |
## Why
Every one of these shipped as a rollout gate, went to `true`, and then stayed. Because none of them was ever flipped back, the `false` branch was unreachable in practice while still costing a real import, a real conditional, and a real "what happens if this is off?" question at ~90 call sites across 25 files.
The bigger cost is what the dead branch kept alive. Removing the flags also removes the disabled-Studio code paths that only existed to serve them:
- the greyed-out, `disabled`, "Manual editing is temporarily disabled" Inspector button in `StudioHeader` (and the `STUDIO_MANUAL_EDITING_DISABLED_TITLE` constant behind it)
- the inspector-off reset `useEffect` in `useDomSelection`, which force-cleared selection and redirected the right panel to Renders
- three selection kill-switch early-returns in `useDomSelection` (`applyDomSelection`, `handleTimelineElementSelect`, `applyMarqueeSelection`)
- the tab-redirect branch in `normalizeStudioUrlPanelTab`, whose `options.inspectorPanelsEnabled` parameter had no production caller at all (only tests passed it)
## How
No behavior change: every flag was removed by keeping its default-`true` side.
The call-site edits are three mechanical boolean shapes (`X && rest` → `rest`, `rest && X` → `rest`, `!X || rest` → `rest`), applied by script for uniformity. Everything else (ternaries, `if` guards, unreachable blocks, JSX wrappers that had no other condition) was done by hand and the whole diff was read line by line afterwards.
`resolveStudioBooleanEnvFlag` and the `import.meta.env` / `window.__HF_STUDIO_ENV__` plumbing stay: three flags still use them (`STUDIO_FLAT_INSPECTOR_ENABLED`, `STUDIO_SDK_CUTOVER_ENABLED`, `STUDIO_SDK_RESOLVER_SHADOW_ENABLED`). Its unit tests kept their coverage but now exercise a live flag pair instead of retired env names, so no dead `VITE_STUDIO_*` string is left in the repo.
Net **-191 lines** (236 insertions, 427 deletions across 25 files); most insertions are reindentation of JSX that lost a wrapper.
### Deliberately not in scope
Flags authored by other people are untouched, even where they look similarly settled:
- `VITE_STUDIO_ENABLE_FLAT_INSPECTOR` / `VITE_STUDIO_FLAT_INSPECTOR_ENABLED` (default true, but not mine)
- `VITE_STUDIO_SDK_CUTOVER_ENABLED`, `VITE_STUDIO_SDK_CUTOVER_FAMILIES`, `VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (SDK cutover canary, still soaking)
- `VITE_HYPERFRAMES_NO_TELEMETRY`
Mine but genuinely long-lived configuration rather than rollout gates, so they stay: `VITE_STUDIO_DISCOVERY_PORTS`, `VITE_HYPERFRAMES_FEEDBACK_INTERVAL`, `VITE_HYPERFRAMES_NO_FEEDBACK` (a documented user opt-out), plus the `HYPERFRAMES_*` binary paths, API URLs, cache sizes, and timeouts.
`VITE_STUDIO_ENABLE_MOTION_PANEL` / `VITE_STUDIO_MOTION_PANEL_ENABLED` were already retired from production code before this PR; they only survived as placeholder names inside the resolver's unit tests, and this PR swaps those out.
## Test plan
- [x] Unit tests added/updated - dropped the two tests asserting removed flag defaults; retargeted the `resolveStudioBooleanEnvFlag` cases at a live flag pair; updated `studioUrlState` tests for the narrowed `normalizeStudioUrlPanelTab` signature (now also asserts an unknown tab returns `null`).
- [x] Manual testing performed - see below.
- [ ] Documentation updated (if applicable) - not needed; no removed name appears in `docs/`, `skills/`, or `registry/`. (`docs/changelog.mdx` has one historical entry naming `STUDIO_KEYFRAMES_ENABLED`; changelog history is left as written.)
```
packages/studio: bunx vitest run # 280 files, 3116 tests pass, 1 skipped
packages/studio: bunx tsc --noEmit # clean
bun run build # green (all packages)
bunx oxlint <25 changed files> # 0 warnings, 0 errors
bunx oxfmt --check <25 changed files> # clean
```
Two extra checks, because part of this diff was script-generated:
1. Zero references to any removed flag constant or env name remain anywhere outside `docs/changelog.mdx`.
2. Diffed every string literal in each changed non-test file against `origin/main`. The only differences are the intended removals: the 12 env names, `"Manual editing is temporarily disabled"`, the `"cursor-not-allowed …"` disabled class, the 3-column `"1fr 1fr 1fr"` grid, and the `"renders"` redirect literals. No user-facing label, tooltip, or class string changed by accident.