* feat(core): sanitize rich text on the way into a composition
Studio's patch vocabulary was inline-style, attribute, html-attribute and
text-content. text-content assigns textContent, and the text-field model
escapes markup on the way out and refuses a change in child structure, so a
styled span had no route into a composition file.
Adds a rich-text operation with one, guarded by a single sanitizer called on
both ends of the trip: in the browser so the preview shows what will be
saved, and on the server because that is where the file is written. Tags and
style properties are a small allowlist, and an unexpected tag loses its
formatting rather than its words. Spans an edit adds get their ids in the
same write, so a follow-up write cannot race it.
No UI yet — this is the persistence contract the editor is built on.
* fix(core): document and test the sanitizer boundary
* fix(core): harden rich text sanitizer traversal
## What
Editing anything in the canvas on the dev server reloaded the preview iframe. It no longer does.
## Why
The write receipt exists to prevent exactly this: Studio marks its own writes so the file-watcher echo can be told apart from somebody editing the file underneath it. The receipt is matched on the file's current bytes as well as its path, so `consumeFileWriteReceipt(absPath, expectedVersion)` takes a version.
The dev plugin called it with the path alone. `expectedVersion` was `undefined`, the version comparison never matched, and so every Studio write looked external and reloaded the preview. The CLI server — which is what ships — has always passed the version, so this is dev-server only.
## How
The plugin reads the file and passes its version, the same way `studioServer.ts` does, and treats a deletion (no readable bytes) as unmatched.
## Test plan
Driven on the dev server against a real composition, with `hf-reload-debug` on:
- Before: a drag logged `file-change` with a full external path, then `reload`, then `refreshPlayer`, and the iframe navigated — one reload per edit.
- After: the same drag logs `file-change` carrying the write token, then `suppressed: own write token`. Iframe reloads are zero across drag, resize and an inline text edit.
- Full studio suite (3727), format and lint green.
Found while chasing a flash after every canvas edit. The other half of that flash was Vite's own HMR full-reloading the page, fixed separately in #3163; with both in, the canvas stops flashing.
## What
Studio's feedback prompt now fires when a render finishes or fails, instead of on a session counter, and the reports it collects carry enough context to act on.
- Replaces the 32px inline bar with a card in the existing toast stack
- Adds `studio_feedback_shown` / `studio_feedback_dismissed` / `studio_feedback_interview_click`, so the funnel is visible
- A failed export and a crash skip the 0-10 score and ask what happened
- Adds a crash prompt to the error boundary
- Attaches a breadcrumb trail, render settings and outcome, and how the project was created
## Before / after
<img width="1500" alt="Before: a 32px feedback strip pinned under the preview. After: the feedback card in the toast stack, with one-tap answers, and the red variant for a failed export." src="https://github.com/user-attachments/assets/29e2373f-ee96-42c4-b006-ab9a0e56a60a" />
The old bar's rating numbers are `neutral-600` on `neutral-900/80`, which is why it reads as a disabled row rather than a control.
**In the running Studio** — bottom-right, sharing the toast stack, hovering a chip explains it on the line above the input:
<img width="1500" alt="The feedback card in the bottom-right of the running Studio, with the rotated follow-up question and a hovered chip explained inline." src="https://github.com/user-attachments/assets/3969da50-cc7a-466a-bbf9-b151a1bc1d5d" />
**On the crash screen** — the prompt the error boundary renders, asking what the user was doing rather than what went wrong, since the stack trace already covers the latter:
<img width="1200" alt="The Studio crash screen with the feedback card below the Try again and Reload Studio buttons." src="https://github.com/user-attachments/assets/4d287011-9c19-4b94-99d0-07a0388fb39c" />
| | Before | After |
|---|---|---|
| **Trigger** | Every 10th session | A render finishing, failing, or a crash |
| **Placement** | 32px inline bar, pushes preview up | Card in the toast stack, no layout shift |
| **Rating targets** | 11 bare buttons, `neutral-600` | Native radios, resting fill, `neutral-400` |
| **Follow-up** | Free text or nothing | One rotated question, four to seven one-tap answers |
| **Option help** | None | Inline hint on hover and focus |
| **Press feedback** | None | `active:scale-[0.97]`, 150ms ease-out |
| **Keyboard** | No exit path | Escape closes, Enter sends, arrows move the rating |
| **Auto-dismiss** | 20s, always | 30s, cancelled the moment you interact |
| **Failure case** | Same NPS question | Its own question, no score, error quoted back |
| **Crash case** | Nothing | Prompt on the crash screen |
| **Visibility** | Submissions only | Shown / dismissed (with reason) / submitted / interview click |
| **Report content** | Rating, comment | Plus breadcrumbs, render settings and outcome, project provenance |
## Why
The old bar fired on a session count, so it interrupted at a moment with no subject: nothing the user had just done, nothing to have an opinion about. It also emitted nothing when it appeared or when it was dismissed, which made the collection rate impossible to diagnose. A prompt nobody answers and a prompt that never renders looked identical from the outside.
Visually it read as a disabled row: 11 buttons at 11px in `neutral-600` on a dark strip, with no resting affordance. And appearing mid-task pushed the whole preview stack up, which the old code carried a comment apologising for.
Separately, the reports it did collect were not actionable. A comment says what went wrong; it almost never says how to get there.
## How
**One trigger, one owner.** `feedbackTrigger` owns eligibility and nothing else does: once per tab, thirty days after an answer, seven after a dismissal, never when telemetry is off (prompting someone whose response we would then drop wastes their attention). `VITE_HYPERFRAMES_NO_FEEDBACK=1` still disables it entirely.
**One hook, every failure path.** The trigger watches the render job list rather than each of the four places a render can finish (server rejection, unreachable server, SSE terminal event, SSE connection drop), so paths added later are covered without touching the trigger. Renders loaded from disk history never fire it.
**Reuses what exists.** The card wears `StudioToast`'s glass treatment and joins its stack, so there is no second visual language and no new CSS. The rating row is native radios, which gives arrow-key navigation, grouping and labels for free.
**One question each, rotated across users.** A corner card that asks three things gets answered by nobody. Each person gets one follow-up with one-tap answers, explained on a reserved line rather than a floating tooltip (the card is 340px in a corner; a bubble above the chips lands on the question, below lands on the input). Detractors are never given a rotated question, because they already have a specific complaint. Every option was checked against the code: an option naming a feature Studio already has would collect taps meaning "I could not find it", which is indistinguishable afterwards from "it does not exist".
**Breadcrumbs cost one line.** Every studio event already flows through `trackEvent`, so recording the trail there needs no new instrumentation and stays correct as events are added.
**Provenance lives outside React.** A crash unmounts the tree, so it is captured when the project loads and read from module scope when the crash prompt renders.
### Privacy
Breadcrumbs and provenance carry names, enums and counts only. Values are copied from a fixed allowlist of short keys, and anything longer than a slug is dropped rather than truncated, so comments, file paths, stack traces and project titles cannot reach them even if a future event carries one. Tests assert this.
### Where these responses land
Studio feedback goes to PostHog and nowhere else, which is what it did before this change too.
Worth stating because the CLI behaves differently: `hyperframes feedback` also forwards to the backend feedback endpoint via `submitFeedback`, on top of its PostHog event. Studio has never used that path, before or after this PR, so if you read CLI feedback anywhere other than PostHog, Studio responses will not show up there.
Nothing here changes that either way. Whether the two surfaces should share a delivery path is a product question, not a defect in this change, and closing it would need a field on the backend DTO: it is shaped around `cli_version`, and Studio reports from a crash or a failed export deliberately carry no rating.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
**Unit** — 39 new tests: trigger eligibility and cooldowns, the detractor override, rotation, preset shape and the no-brands rule, breadcrumb rolling and privacy, provenance parsing and its failure modes, and the crash boundary rendering the prompt with no rating input.
**Live** — both render paths driven end to end against a running Studio on a production bundle, with real renders. Every PostHog request was intercepted and dropped, so nothing reached the project. Verified the emitted payload for a finished render, a failed export, the rotated follow-ups, each chip's hint, and the interview link.
**Not covered** — no live capture of a spontaneous crash. Three attempts to force one failed because Studio's guards held and it kept rendering, so the crash path is verified by component tests rather than by driving it. Touch devices see chip labels without hints, since the hint is revealed on hover and focus.
* fix(studio): read the rotate property when measuring an element's angle
Turning an element with Studio's rotate handle left every piece of overlay
chrome square across it: the selection box, the crop outline and the child
outlines all drew upright while the element underneath was clearly rotated.
The handle writes the CSS `rotate` property. `rotate` is an individual
transform property, not part of `transform`, so `getComputedStyle(el).transform`
reports nothing for it and both places that measure an element's angle — the
overlay geometry and the crop frame — read the element as upright.
Both now read `rotate` alongside `transform` and compose them the way CSS
does, individual properties first. A rotation about any axis but z has no
single in-plane angle, so it reports nothing and the caller keeps its
axis-aligned fallback rather than drawing chrome at a plausible wrong angle.
* fix(studio): stop the crop outline refusing the transforms GSAP writes
The crop outline still drew square on a rotated element after the rotate-
property fix, because it refused the transform outright: it accepted only
`matrix(...)`, and GSAP writes `matrix3d(...)` for an ordinary 2D move or spin
(force3D). A composition that mirrors an element writes one with a negative z
scale, and the negative determinant that follows was refused too.
Both are ordinary planar transforms. The outline now reads the same 2D
projection the rest of the chrome takes through DOMMatrix, and sizes a
flipped element from the magnitude of its determinant. Only a perspective
term still falls back, because that is where the mapping stops being affine
and no single angle describes it.
The test that asserted "a 3D matrix means give up" asserted the bug: its
fixture was the identity written as matrix3d, which is as planar as a
transform gets. It now checks the behaviour that replaced it, alongside the
perspective case, which still falls back.
* fix(studio): draw the crop outline at the angle the element paints under
Selecting a text layer inside a rotated card drew its crop outline across the
text at roughly a right angle. The outline read the element's own transform,
but what the user sees is that composed with every ancestor's — the layer
carries its own spin and its parent turns it again.
It now walks to the composition root and composes each level, the element's
`rotate` property before its `transform` and an ancestor outside its child,
which is the order CSS applies them in. Nothing transformed anywhere still
falls back to the caller's axis-aligned rect, since that comes from real
layout and describes the element exactly.
The chrome test stubbed getComputedStyle to answer "rotated 30deg" for every
node in the document, so composing read the same turn once per ancestor. The
stub now answers per element, which is what it always meant.
* fix(studio): stop the dev server reloading the page on every canvas edit
A composition lives under this package's root, so Vite's HMR saw a write to
one as an html page dependency changing and full-reloaded the browser. That
reload is the flash after every edit in the canvas: the whole app remounts,
taking the preview iframe with it.
The decision was never Vite's to make. Studio already knows whether a write
was its own — that is what the write receipt is for — and refreshes the
preview itself when it needs to. Vite's watcher now ignores the project data,
and the dev plugin watches it on a watcher of its own, announcing changes as
hf:file-change exactly as before.
Measured on a drag: Vite full reloads went from one per edit to none, and the
receipt now reports 'suppressed: own write token' where it previously never
saw a matching path.
* refactor(studio): compose an element's transform in one walk, not two
Review: the crop frame hand-composed ancestor matrices while the geometry
file did the same walk through DOMMatrix. Both were right, but the next
individual transform property CSS grows — `translate`, `scale` — would have to
land in both, and a miss puts the crop outline back at the wrong angle while
the selection box draws the right one.
The walk now lives in one place and takes the arithmetic as a parameter. The
geometry file keeps DOMMatrix, because it goes on to transform corner points
and needs the translation; the crop frame keeps plain 2D components, because
it only needs an angle and a scale. Which transforms count, and in what order,
is stated once.
Also from review: the nested case was verified by hand only, so the composed
walk is now covered on both sides — a child inside a rotated parent reports
the angle it paints at, the parent's rotation alone when the child has none,
and the walk stopping at the composition root. And `hasAttribute?.` was dead
on a narrowed HTMLElement; it only survived because the crop test's fake
element was not one. The fake now models an element and the guard is gone.
* style(studio): format the shared transform module
oxfmt formats markdown, and the file added in #3165 was not run through it.
main's Format check has been failing since that merge, which also fails every
open PR, since CI checks the merge with main.
Working in packages/studio for the first time costs a day rediscovering things
the source does not show: that the chrome is a measurement drawn in Studio's
document over an iframe, that some gestures cannot be synthesised at all so a
driver needs window.__studioTest, that the diagnostic channels exist and are
off by default, that bare `bun test` reports failures that are not real, and
which gates reject a PR.
Scoped to the package, following docs/AGENTS.md, and pointed at from the
project-structure list in both root files so it is found before the first edit
rather than after.
* fix(studio): size the selection box by the transform the element actually paints under
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): drag by the movement the element actually makes, not the one assumed
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.
* fix(studio): shift-click adds the element under the pointer, not the last one hovered
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.
* fix(studio): keep every element a marquee caught, not just the first
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.
* fix(studio): stop a group selection from erasing itself on the timeline
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.
* chore(studio): trace what moves a dragged group and when
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.
* chore(studio): name the path that clears a selection after a group move
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.
* fix(studio): losing one member of a group no longer deselects all of it
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.
* feat(studio): carry a multi-selection in the URL, and name the member that breaks away
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.
* fix(studio): stop snapping from moving a selection you have not dragged yet
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.
* fix(studio): a dropped group stays selected
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.
* feat(studio): marquee from anywhere on the canvas, including outside the frame
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.
* refactor(studio): keep the selection files under the size cap
The selection work above pushed four files past the 600-line gate. Same
split the branch made later, landed with the changes that caused it.
* fix(studio): preserve selector groups in share URLs
* fix(studio): close multi-selection review gaps
* fix(studio): stabilize selection store reads
* fix(studio): preserve canvas-only group anchors
* fix(studio): stop a group drag from jumping one element back
Dragging several elements at once and dropping them made one of them snap
back to where it started for a frame or two, then jump forward again.
Each member of the group is written separately, and every write patched the
live GSAP tween in place and then seeked the player. A seek re-renders the
WHOLE timeline, not the tween that changed, so the members still queued
behind that write got repainted from their un-patched tweens: back to their
pre-drag position, where they sat until their own write landed. Only members
whose tween actually renders at the playhead showed it, which is why a group
of three flashed one element and left the others still.
The group commit now defers the seek for every member but the last, so the
queued members keep the transform the gesture left on them and the whole
group repaints once, from the fully patched timeline.
* perf(studio): commit a group drag in one request
Dragging N elements cost N writes and 9 reads for a three-element group: each
member fetched the composition's parse to preflight, fetched it again to
resolve its tween, then wrote the file on its own round trip. Every one of
those writes re-read, re-parsed and re-serialized the whole composition.
Three changes, same behaviour:
- The parse endpoint shares an in-flight request per file, so callers asking
for the same composition at the same moment get one request. Only
overlapping calls share — the entry is dropped as soon as it settles, so a
read after a write still gets a fresh parse.
- The group preflight runs its members together instead of one at a time. A
preflight writes nothing, so there is nothing to order.
- Members' mutations are queued and sent as one batch write. Anything that
re-reads the file flushes the queue first, so a member resolving a shared or
stale tween never reads a composition missing writes it is about to build
on. The batch carries each member's runtime patch, and only the last one
re-renders.
A three-element group drag now issues 2 reads and 1 write, down from 9 and 3.
* fix(studio): harden batched drag commits
* fix(studio): carry deferred preview fallbacks
* chore(studio): name whoever puts the pre-resize size back
Resizing the card commits correctly — the source and a fresh load both read
273x181 — but 200ms after the drop, mid-commit, the element renders at 395x261
with the studio size vars still holding 273x181. Something writes the
pre-gesture size back inline while the reload is still in flight, and every
writer of that size was silent.
Both are traced now under the existing hf-resize-debug flag, each with the size
going in, the size being replaced, and a short stack. Restoring the pre-gesture
size is right on a cancel and wrong after a successful commit, and the function
doing it cannot tell the two apart from the inside — so the caller has to be
named before this can be fixed at the right end.
* fix(studio): hold a resized element's size while the timeline is rebuilt
Your log caught it across two resizes. The first commits 305x202 and the element
is 305x202 at the drop; 200ms later it renders 395x261, its stylesheet size,
while --hf-studio-width still reads 305. The second gesture then starts with
`actual` at 305 against a live box of 395, and its very first move — a pointer
delta of 0.1px — snaps the element back to 305. That snap is the jump.
The gap belongs to the soft reload: it reverts the old timeline before building
the new one, and GSAP hands back each tween's recorded starting width on the way
out. Nothing held the size in between, because the seek reapply that exists for
exactly this stands aside for elements GSAP animates.
Standing aside is right for the offset — those channels compose, and applying
both doubles the move — and wrong for size, where both channels write width and
height so the later write simply wins on the same committed number. It applies
now. Only an element mid-edit carries the vars, so nothing else is touched.
A test seeks an element whose size GSAP owns after the revert put the stylesheet
size back, and fails with the skip restored.
* refactor(studio): keep the resize files under the size cap
* docs(studio): fold the resize note into the size-reapply comment
* fix(studio): rotate the child outlines with the element they outline
Selecting a rotated element drew upright dashed boxes across its children:
the chrome co-rotated with the element and the child outlines did not, so a
text layer inside a rotated card got a square outline lying across the
rotated glyphs.
The chrome already measures an oriented box; the child outlines were still
measured axis-aligned. They now use the same oriented measurement and render
with the same rotation. An unrotated element measures identically to before,
since the oriented rect returns the plain bounding box at angle 0.
* 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
## 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>