Skip to content

feat(studio): read overlay geometry from an opaque preview over its channel - #7628

Merged
jrusso1020 merged 2 commits into
mainfrom
hardening/studio-overlay-geometry
Nov 13, 2026
Merged

jrusso1020 merged 2 commits into
mainfrom
hardening/studio-overlay-geometry

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

On a preview frame Studio cannot script, Studio now reads overlay geometry over the runtime's editing channel. That covers the selection, child, group and hover boxes, the root scale, and the box a press held across a preview change starts from. Same-origin frames still read the DOM directly. The measuring code they use moves to core, so both kinds of frame measure an element by one rule. Nothing turns the opaque mode on yet.

This PR also lands the follow-ups from the review of #7613 that have to be in before that mode is switched on.

The captures come from real Studio, with boxes drawn from the measured rects. Cyan is the selection, white the child outline, green the group items (one of them hugged to its inset crop), and red the hover box on the element rotated 18°.

Before

Same-origin preview: boxes from the DOM reads the overlay already does.

same-origin

After

The same preview reloaded as an opaque sandbox="allow-scripts" frame: boxes from geometry over the editing channel. They match the same-origin boxes to 0.001px.

opaque

Claude Opus 5.5

What

Runtime: new geometry { handles, children? } request (packages/core/src/runtime/editingGeometry.ts, editingPort.ts, editingAgent.ts)

  • Each handle gets one entry, in order. The entry is null for an unknown or removed element, as in describe. Each entry carries:
    • rect: the client rect.
    • angle: the rotation the element paints under, composed over its ancestors.
    • corners: its painted border-box corners, for a rotated element only.
    • visible: whether it and every ancestor render.
    • clipPath: inline first, then computed. It is null when it is longer than 2,000 characters.
    • source: the nearest source boundary's rect and declared data-width/data-height.
    • members: a data-hf-group wrapper's member rects, up to 256.
  • children names one handle. Its descendants' boxes come back too, when it has 1 to 60 of them, which is the bound the overlay already drew child outlines to. Otherwise the list is empty.
  • Params are checked like the other kinds: exact keys, 1 to 256 well-formed handles, and children must be a well-formed handle when it is present.
  • The pieces of measuring code the overlay already used move to core unchanged, and Studio imports them from there: the transform walk (domEditOverlayTransform.ts, renamed to core/src/runtime/elementTransform.ts), the visibility walk, the transform snapshot, rotation decomposition and the corner math.

Studio

  • computeOverlayRootScale scales an opaque frame by the stage size its runtime posts.
  • domEditOverlayGeometry places frame-px measurements on the overlay with frameRectToOverlay, frameCornersToOverlay and orientOverlayRect. The same-origin functions call these with the same reads as before. hugRectForClipPath splits out of hugRectForElement in the same way.
  • New previewOverlayRects.ts builds each box from geometry with that arithmetic:
    • oriented group-aware boxes for the selection and the hover box;
    • axis-aligned boxes for group items, hugged to the crop;
    • for child outlines, the crop-hugged oriented box, dropped when it is 2px or smaller.
  • useDomEditOverlayRects takes the channel path for an opaque frame. At most one geometry read is in flight. A frame that fires while a read is outstanding skips its read. An answer is dropped if the selection, group, hover or frame changed while it was in flight.
  • previewGestureStarts: on an opaque frame, a press held across a preview change waits until the preview settles. It then re-finds the pressed selections on the page now shown, by query and describe, and measures them in one geometry read. The press starts from that, or is dropped if any of them is gone.

#7613 review follow-ups

  • Stand-in element reads: opaque selections carry hasAudio and src, read off the description. domEditSelectionToFacts and the image fill field prefer them. Same-origin selections leave both unset and read the element as before.
  • Truncated descriptions: a description whose attribute or inline-style values may have been cut builds no selection. A value is treated as cut when it is at the length limit, is a data: value cut to its header, or the entry count is at its cap. Cut text or children do not block, since neither is written back from a description. Fetching the full value was the alternative; blocking is the simpler of the two safe options.
  • A failed computed-styles read now builds nothing, where it used to build with {}. A failed composition-root read builds nothing too, where it used to fall back to the document element's rect.
  • Group refresh passes the iframe it started on. It writes the group only if the group is still the one it was asked to refresh.
  • findClosestByAttribute had no caller left after feat(studio): timeline select and selection refresh read an opaque preview over its channel #7613, so it is removed.

Package size

Why

This is the next slice of letting Studio's preview run in an opaque-origin frame. Studio cannot read layout off such a frame, so the overlay reads it through the frame's runtime instead.

Related work

Refs #7602, #7603, #7613. #5675 also edits useDomEditOverlayRects.ts. This PR adds a channel branch beside the existing update and leaves update itself unchanged.

Not in this PR: marquee, snap targets, the layers panel and the motion path still read the same-origin DOM. So do the gesture bodies themselves (drag, resize, rotate and crop writes), canvas click and hover, and the timeline click path in the running app.

Test plan

  • Unit tests added/updated:

    • editingGeometry.test.ts: rect, painted corners for a quarter turn, visibility, clip-path, source boundary, group members, descendant boxes and their bound, and null once an element is removed.
    • editingAgent.test.ts: 12 malformed geometry params are rejected with invalid-params.
    • previewOverlayRects.test.ts: the same hook runs over a same-origin frame and over an opaque frame served by the real editing agent, and the two outputs are compared on the same DOM. The cases are a source host with child outlines, a group and a clipped member, a rotated hover, a rotated selection, and a hidden selection. The other tests check concrete numbers, one read in flight across several frames, and a held press that is re-found and measured over the channel or dropped.
    • previewSelection.test.ts: hasAudio for muted, data-has-audio="false" and audible videos; src on an image; cut attribute and style values build nothing while long text does not block; failed computed-styles and root reads build nothing; a group the user changed during a refresh is left alone.
  • Mutation probes: 15 of the changes were reverted one at a time, and a test failed for each.

  • Full Studio suite: 748 files passed. Full core suite: 294 files passed. Typecheck, oxlint, oxfmt, fallow audit and comment-ratchet are clean.

  • Manual testing in real Studio, on a dev server with a three-element composition: a card with a child, an element rotated 18°, and an element with an inset crop.

    • First the boxes were measured on the same-origin preview with the live functions the hook calls.
    • Then the preview frame was reloaded as sandbox="allow-scripts", so its document was unreadable, and the same boxes were measured with measurePreviewOverlay over the real port, using the posted stage size.
    • The selection, child, group, hugged-crop and rotated-hover rects matched to 0.001px.
    • Cyan is the selection, white the child outline, green the group items, and red the rotated hover box:

    The captures are under Before and After above.

    Clicking a timeline clip on the sandboxed frame did not select it, because the timeline's click path in the running app still reads the frame's document. That is left for the next slice, so this check calls the measuring function directly.

  • Comments follow CONTRIBUTING.md "Comments"

— Rames Jusso

🤖 Generated with Claude Code

…hannel

The runtime's editing channel gains a `geometry` request: per element
handle its client rect, the corners it paints at when rotated, whether it
renders, its clip-path, its source boundary's size, a group wrapper's
member rects, and optionally one element's descendant boxes. On a preview
frame Studio cannot script, the selection, child, group and hover boxes,
the root scale (from the mirrored stage size) and a press held across a
preview change read that geometry, with one read in flight at a time.
Same-origin frames keep reading the DOM directly; the measuring code they
use now lives in core so both paths share one rule.

Also from the review of #7613: opaque selections carry `hasAudio` and
`src` read off the description, a description with a cut attribute or
inline style builds no selection, a failed computed-styles or root read
builds none either, and the group refresh threads its frame and leaves a
group the user changed meanwhile. `findClosestByAttribute`, left without
a caller by #7613, is removed.

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

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 99 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (1)

  • seqplainkeys-none-pct-r0-nested-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

…try read

The producer ships the preview runtime, which now carries the editing
channel's `geometry` request and the measuring code it shares with Studio.
The packed tarball measured 888,810 bytes in CI against an 880,000 budget.

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

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 blocker · 1 important · 0 nits

Opaque mode is still off, so the one finding (↓ inline) is for before it's turned on. The channel rects match the same-origin update() branch by branch: selection visibility, child bound and 2px floor, group crop hug, hover dedupe. Stale answers are dropped, and the held-press channel wait stays bounded by the probe and request timeouts.

Scope

Read end-to-end: editingGeometry.ts, editingPort.ts parser and types, previewOverlayRects.ts, useDomEditOverlayRects.ts (both paths), domEditOverlayGeometry.ts/Crop.ts extractions, previewGestureStarts.ts, previewElements.ts readers, previewSelection.ts, useDomSelection.ts group guard, fill/facts reads, size budget. Trusting: the test files (CI green on d11007b: CI, regression, preview-regression, CodeQL). No prior reviews on the PR.

— Genesis

const attr = (element: PreviewElement, name: string) =>
element.attributes.find(([key]) => key === name)?.[1] ?? null;

const mayBeCut = (value: string) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important The runtime's cut() (editingHandles.ts:90) cuts every data: attribute to its header, whatever its length, and marks it truncated. So with this guard, an <img src="data:image/svg+xml,…"> of even 200 bytes builds no selection on an opaque frame. The element can't be selected at all, not even to move it, and nothing is written back from src in that case. The tests only cover a 3,000+ char data: value. Suggested fix: have cut keep whole any data: value at or under MAX_ELEMENT_VALUE, so only one that is really too long still reads as cut here. Or, if you want to keep this behavior, add a test that pins it and say in the body that it applies to all data: srcs.

@jrusso1020
jrusso1020 added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit faff4e1 Nov 13, 2026
98 of 99 checks passed
@jrusso1020
jrusso1020 deleted the hardening/studio-overlay-geometry branch November 13, 2026 00:46
@miguel-heygen miguel-heygen mentioned this pull request Nov 13, 2026
3 of 5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant