feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation#829
Conversation
jrusso1020
left a comment
There was a problem hiding this comment.
LGTM — opt-out toggle is well-scoped, state machine is clean.
Audited (deep read)
useManifestPersistence.ts— newmanualEditsEnabledstate +setManualEditsEnabledsetter. Initial valuefalse; reset tofalseon project switch; rehydrates fromparsed.enabled ?? falseon manifest read. Optimistic update + queued save with rollback on error (restores previous manifest, bumps revision, restores local state, shows toast). Coalesce key"manual-edits-enabled"is appropriate. ✓manualEditsParsing.ts/manualEditsTypes.ts— strictrecord.enabled === trueparse (1/"true"rejected); optional in serialized form. ✓PropertyPanel.tsx—needsToggleForElement = !canMove && !manualEditsEnabledgatesmanualOffsetEditingDisabled,manualSizeEditingDisabled, and the rotation field'sdisabledprop. Toggle renders at bottom of panel in both empty + selected states, conditionally ononSetManualEditsEnabledbeing passed. Switch component has correctrole="switch"+aria-checkedsemantics. ✓DomEditOverlay.tsx— drag gateif (!candidate.capabilities.canMove && !manualEditsEnabled) return;short-circuits gesture-init for locked elements; cursor styling andonPointerDownboth honorcanMove || manualEditsEnabled. ✓usePreviewInteraction.ts— blocked-move toast now distinguishes "toggle would unlock this" (canApplyManualOffset && !canMove) from genuinely-disabled cases. Message text points users at the Design panel. ✓DomEditContext+useDomEditSession+App.tsx+StudioRightPanel.tsx+StudioPreviewArea.tsx— pass-through plumbing formanualEditsEnabled/setManualEditsEnabled. Memo deps updated correctly inDomEditProvider. ✓
Non-blocking — path-enumeration on the new state-machine contract
The toggle defines a new invariant: "a non-canMove element can only have its position/size/rotation mutated when manualEditsEnabled=true." The diff gates three sites — drag gesture, property-panel form fields, and the blocked-move toast. Worth confirming the following paths (not visible in this diff) also honor the gate, or are intentionally exempt:
- Arrow-key nudges (if there's a keyboard handler that translates selection)
- Resize handles (if there's a separate handle layer beyond the drag-anywhere gesture)
- Group/multi-select drag (
groupSelectionsis passed through but I don't see a per-selection gate in the gesture path) - Programmatic offset application from agent/AI edits (e.g., a Magic Edit chat result writing to a non-
canMoveelement)
If any of these can mutate a non-canMove element without the toggle, the user-visible promise ("turn this on to move it") is partially defeated. Probably worth a quick grep on applyManualOffset / setStudioPathOffset callers to confirm coverage.
Non-blocking — scope smell
The branch name (feat/studio-preview-pasteboard-bg) and the Player.tsx + NLEPreview.tsx changes (transparent player container + shadow-root style injection adding .hfp-container{overflow:visible} + canvas drop-shadow, viewport bg-neutral-700) are pasteboard-aesthetics work, not part of the manual-positioning toggle. Reviewable in one pass, but in the future it'd be cleaner to keep these as separate PRs — both for review ergonomics and so a revert of one doesn't drag the other.
Non-blocking — CI
Test job failed on both runs (latest at job 75935191801) and Tests on windows-latest also failed. Build/Lint/Typecheck/Smoke all green. Worth a quick look at the test logs before merge — could be a snapshot/visual regression triggered by the new pasteboard bg, or a flake.
Nit
PropertyPanel.tsxline ~182:manualEditsEnabled = trueas the default-destructured value is inconsistent with the hook'sfalseinitial state. Cosmetic only sinceApp.tsxalways passes the real value — but the default would render the toggle as "on" if the prop were ever undefined. ConsidermanualEditsEnabled = falseto match the source of truth.
— Review by Rames Jusso (pr-review)
vanceingalls
left a comment
There was a problem hiding this comment.
Net-new opt-out toggle for manual positioning; absolute-positioned elements stay always-draggable, JSON sidecar is created lazily. Behaviour matches what the PR description promises in the simple case, but the gate hasn't been propagated to every site that satisfies the contract, and one required CI check is red.
Strengths
packages/studio/src/components/editor/manualEditsParsing.ts:129—enabled === true(default-false) is the correct shape for opt-in semantics on legacy manifests that lack the field.packages/studio/src/hooks/useManifestPersistence.ts:485-517—setManualEditsEnableddoes optimistic UI + rollback-on-failure with a coalesce key and history label. Good shape.packages/studio/src/hooks/usePreviewInteraction.ts:127-130— targeted toast wording ("Enable 'Manual positioning' in the Design panel to move this element.") is discoverable and points at the exact remedy.
Blockers
-
Required CI
Testcheck is failing —packages/studio/src/components/editor/DomEditOverlay.test.ts:177"DomEditOverlay > renders selected bounds right after clicking a movable selection" assertscurrentSelection === selectionand getsnull. Root cause: the new gate atDomEditOverlay.tsx:215-216(if (!candidate.capabilities.canMove && !manualEditsEnabled) return;) short-circuits because the test fixture omitscanMove(undefined → falsy) and the harness doesn't passmanualEditsEnabled(defaults tofalseper the new prop default atDomEditOverlay.tsx:71). Fix: addcanMove: trueto the test capabilities fixture (the fixture already saysposition: "absolute", so this is the valueresolveDomEditCapabilitieswould compute in the real path). The PR shipped a regression that the suite is catching — please confirm green before merge. The same failure repeats onTests on windows-latest. -
Multi-select drag bypasses the toggle entirely —
DomEditOverlay.tsx:173-176derivesgroupCanMovefromevery((item) => item.selection.capabilities.canApplyManualOffset)with nocanMove || manualEditsEnabledfactor, andgestures.startGroupDrag(e)at line 314 fires unconditionally onceallowCanvasMovementis true. Failure mode: shift-click two non-absolute elements with the toggle off, drag the group — both move and write to the JSON sidecar that the user explicitly opted out of. This is the exact scenario the PR description claims is locked. -
Single-element resize and rotate handles still render and fire for non-absolute elements when the toggle is off —
DomEditOverlay.tsx:322(rotate handle) gates only onselection.capabilities.canApplyManualRotation;DomEditOverlay.tsx:388-396(resize handle) gates only onselection.capabilities.canApplyManualSize. Neither factorscanMove || manualEditsEnabled. The PR description states "Drag handles, resize, and rotation handles are hidden when disabled" — drag is, the other two aren't. Same data corruption surface as #2: user thinks they're opted out, dragging the resize/rotate handles writes JSON edits silently. (Note:PropertyPanel.tsx's R-field IS toggle-gated vianeedsToggleForElement— the canvas handle is the asymmetric one.) -
Defence-in-depth gap at the gesture boundary —
DomEditOverlay.tsx:719-721(startGesturefordrag/resize/rotate) checkscanApplyManual{Offset,Size,Rotation}but not the toggle. If any caller path slips through #2 or #3,startGesturehappily proceeds. Add thecanMove || manualEditsEnabledprecondition here so this is the single chokepoint instead of N call-sites each having to remember.
Important
-
Test coverage gap on the new behaviour — the toggle's effect is structural (drag/resize/rotate gated by
canMove || manualEditsEnabled) but no test in this PR pins it. After fixing #1, please add: (a) toggle-off + non-absolute element + drag attempt = blocked-move toast, (b) toggle-off + absolute element + drag = succeeds, (c) toggle-off + non-absolute + group drag = blocked (addresses #2), (d) toggle-on persists to.hyperframes/studio-manual-edits.jsonwithenabled: true. Without these, future refactors to the gate logic will silently regress. -
Toggle accessibility —
PropertyPanel.tsx:222-244usesrole="switch"+aria-checked(good) but noaria-label/ no associated label element. The visible text "Manual positioning" lives in a sibling<span>. Screen readers will read the switch state without context. Wrap oraria-labelledbyto the span. -
Cross-project preference vs per-project — toggle state writes to per-project
.hyperframes/studio-manual-edits.json(good, matches "people who want to share source can opt in their project"). But the default-off for newly-loaded existing projects without the field means anyone who relied on the prior implicit-on behaviour on an existing project that never wroteenabled: truewill silently lose drag-to-move on next load. The PR body says "Existing projects that already haveenabled: truein the manifest respect that value on load" — but pre-PR there was noenabledfield at all, so no existing project hasenabled: true. Worth a migration note or a one-time toast on first-load-after-update.
Nits
useManifestPersistence.ts:443—useState(false)for the in-memory default, thensetManualEditsEnabledState(parsed.enabled ?? false)on load. The twofalses drift if you ever want to flip the default — extract aMANUAL_EDITS_DEFAULT_ENABLED = falseconstant.- Commits 4/5 (enable-by-default → revert to opt-out) flip the same constants three times. Squash-merge will hide it; leaving as-is is fine, just calling out for reviewer flow.
Verdict: REQUEST CHANGES
Reasoning: Required CI Test is failing on a test that exercises this PR's own new gate (blocker #1), and the toggle is bypassable via three other interaction sites (multi-select drag, canvas resize, canvas rotate) that violate the PR's stated invariant.
— Vai
jrusso1020
left a comment
There was a problem hiding this comment.
Recalibrating after Vai's review: agreeing with their request-changes call.
My non-blocking path-enum framing was too soft. The PR body explicitly promises "Everything else (relative, static, flex children, …) is locked by default" — that's a global invariant, and per the path-enumeration discipline for new state-machine contracts, every path that can mutate position/size/rotation on a non-canMove element must honor the toggle. Vai enumerated three bypasses (multi-select drag, resize handle, rotate handle) that defeat the user-visible promise. Those should be blockers, not nits.
Also revising my CI note: Vai pinned the Test failure to DomEditOverlay.test.ts:177 short-circuiting on the new gate (test fixture omits canMove). That's the PR's own change breaking an existing test — substantive failure, not a flake.
Treating my prior approval as superseded by Vai's request-changes. Happy to re-stamp once the bypass paths are gated and the test fixture is updated.
— Recalibration by Rames Jusso (pr-review)
Adds bg-neutral-800 to the preview viewport so the area outside the canvas is visually distinct from the composition content — consistent with professional video editors (Premiere, DaVinci, Figma).
- NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard
color surrounding the canvas — distinct from the app chrome (#0a0a0a)
- Player wrapper: drop bg-black so the pasteboard shows around the canvas
(loading overlays still cover the area with bg-black during load)
- Player: set host background to transparent via inline style (overrides
:host { background: #000 } in shadow DOM), and inject a style rule into
the open shadow root so .hfp-container has overflow:visible and the
canvas iframe gets a thin white ring + soft drop-shadow — making the
canvas boundary legible against the pasteboard
Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting)
…ON-backed drag behind toggle
… and manual positioning toggle Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style persistence baked directly into the HTML source. Drag/resize/rotation values are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`, `--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via `persistDomEditOperations` — no re-apply step needed on load. Key changes: - `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the property/attribute from the HTML tag instead of setting it - `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live element state into `PatchOperation[]` for HTML source writes; add `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers) - `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure - `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs seek hooks via `reapplyPositionEditsAfterSeek` - `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh) - `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled` gates to just `canApplyManualOffset` — every draggable element is always draggable - `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props - `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`, `roundRotationAngle`, and snapshot/CSS-property types
…g seek assertions in test
d7d60d4 to
6cfd1b4
Compare
…e group-selection refresh - Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads via the refresh-key path instead of directly touching the iframe. - Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers; HTML is now the source of truth so no stale-ref refresh is needed. - Add `manualEditsRenderScript` helper; export via studio-api and apply it in `htmlCompiler` during HTML compilation.
…erlay drift on resize - Guard `getDomLayerPatchTarget` against elements with `data-composition-id` so the root composition div is never returned as a visual selection target. - Apply the same guard to the raw `elementFromPoint` fallback in `getPreviewTargetFromPointer`, which was the actual escape path. - Thread `iframeRef` into gesture handler opts; after applying draft dimensions during resize, re-read the element BCR via `toOverlayRect` and update the overlay box position to compensate for visual drift on elements with centered transform-origin (e.g. GSAP scale tweens).
…ble element selection - Resize: use BCR from `toOverlayRect` for both position and size after applying draft dimensions — GSAP scale makes visual size diverge from raw CSS size, BCR is the only accurate source during a gesture. - Click selection: add `isElementComputedVisible` guard to the `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements cannot be selected even though the browser hit-test returns them.
Share the app-level domEditSaveTimestampRef with useManifestPersistence so the SSE/HMR handler can suppress echoes from all studio saves (code tab, timeline, DOM edits), then call reloadPreview() for non-motion external changes that aren't echoes of our own saves.
Blocks ship as {id}.html + assets/ with no index.html. The preview
route hard-coded index.html so these projects returned 404 and their
assets (e.g. korea-map.png, map-nyc-paris.png) were never served.
Add resolveProjectMainHtml() that falls back to {id}.html, thread the
resolved compositionPath through transformPreviewHtml and
injectStudioPreviewAugmentations, and update listProjects() in the
vite adapter to surface block directories in the project list.
… video Three issues caused studio-edited positions to be lost during rendering: 1. The seek-reapply script used setInterval to wrap window.__hf.seek, but Puppeteer's page.evaluate() calls don't yield the event loop for macrotasks — the interval never fired, so reapplyAll() never ran after GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it. 2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img> during render) included "transform" but not "translate", "rotate", or "scale" — the CSS Transforms Level 2 individual properties used by studio drag/resize/rotation. The proxy was positioned at offsetLeft/ offsetTop without the translate offset. 3. getViewportMatrix (HDR compositor) only read cs.transform, missing individual transform properties entirely. Added composeIndividualTransforms to build the translate × rotate × scale matrix and compose it before the legacy transform matrix.
Compositions often set pointer-events: none on scenes, avatar wrappers,
and decorative layers. elementsFromPoint() skips these elements entirely,
making them unselectable in the Studio. Fix: temporarily inject a
* { pointer-events: auto !important } stylesheet during hit-testing, then
remove it immediately after.
Also adds a pointer_events_none lint rule (info severity, visible with
--verbose) so authors know which selectors may affect Studio selection.
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approving on 414320a after the architectural pivot.
The PR has changed shape from yesterday's "manual-positioning toggle + JSON manifest" design to "every element with drag/resize/rotate capability moves immediately; positions persist directly to HTML inline styles." This matches the design conclusion the Slack thread converged on yesterday ("code should be source of truth, JSON is just debounced cache") and moots every blocker from the prior round — there's no toggle anymore, so the multi-select-drag / resize-handle / rotate-handle bypass concerns (Vai's #2-4) and the DomEditOverlay.test.ts:177 short-circuit (Vai's #1) no longer apply. CI is fully green now, including the test that was failing yesterday.
Audited on the new commit
- State-machine removal (
useManifestPersistence.ts-136 net,manualEditsParsing.ts-238,manualEditsTypes.ts-39,manualEdits.ts-162) — the manifest persistence layer and the toggle state are gone.commitPositionPatchToHtmlreplacescommitStudioManualEditManifestOptimistically. Code shrinks substantially in the studio components; new logic centralizes inmanualEditsDom.ts(361 LoC, mostly the patch builders +stripGsapTranslateFromTransform). ✓ PatchOperation.value: string | null(sourcePatcher.ts:73-74) — null signals removal of the property/attribute. All four patch sites (patchInlineStyle,patchInlineStyleByTarget,patchAttribute,patchAttributeByTarget) handle null correctly:- For inline-style: when target tag has no existing
styleattribute, null is no-op; when it has one, the property is deleted from the parsed prop map and the style string is rebuilt. ✓ - For attribute: removes the attribute (with leading whitespace, via
\s+${escapeRegex(...)}=...) if present; no-op if absent. ✓ - Also fixes a pre-existing minor:
escapeRegex(fullAttr)is now applied inpatchAttributeandpatchAttributeByTarget, which it wasn't before — hardens against odd attribute names containing regex metachars. ✓
- For inline-style: when target tag has no existing
stripGsapTranslateFromTransform(manualEditsDom.ts:215-238) — GSAP's matrix baking is the real root cause of the "drag to 259px then reload → 518px" symptom the body calls out. The fix:- Reads
element.style.transform(the inline string, not computed) — GSAP writes resolved matrix here on each seek - Parses with
DOMMatrix(transform)defensively (try/catch, DOMMatrix may be undefined off-window) - Skips if m41/m42 are already 0 (no-op guard)
- Zeroes m41/m42, leaves m11/m12/m21/m22 (scale/rotate/skew) alone, leaves m43/m44 (3D) untouched
- If result is a 2D identity matrix, removes the
transformproperty entirely (clean reset); else writes back the matrix - Fires after every
applyStudioPathOffset/applyStudioPathOffsetDraftso it's in lockstep with the studio's translate write ✓
- Reads
installSeekTrapviaObject.defineProperty(manualEditsRenderScript.ts:99-218) — replaces thesetIntervalpolling that never fired during Puppeteer'spage.evaluate()because the event loop doesn't yield to macrotasks during synchronous bridge initialization. New approach:Object.definePropertysetter trap onwindow.__hf.seek/window.__player.renderSeek— captures the assignment the instant the bridge writes to the property- On set, wraps the function so post-seek
reapplyAll()re-composestranslate/rotatefrom--hf-studio-offset-x/yand--hf-studio-rotationCSS custom properties markWrappeduses non-enumerable, non-configurable defineProperty so wrapping is idempotent (avoids double-wrap)- Early-return if no studio-marked elements exist (skips the wrap install entirely on untouched HTML) ✓
MEDIA_VISUAL_STYLE_PROPERTIES(parityContract.ts:27-29) — adds the three CSS Transforms Level 2 individual properties (translate,rotate,scale). The video proxy now copies these too, so a studio-translated<video>doesn't drop its offset when the engine creates the proxy. ✓composeIndividualTransforms(videoFrameInjector.ts:556-585) — composes the three individual properties into a DOMMatrix, thenmat.translate(ox, oy).multiply(individualTransform).multiply(t).translate(-ox, -oy)— order is "origin shift → individual transforms → transform property → origin shift back." That matches the CSS spec order (individual transforms apply beforetransform). ✓getPreviewTargetFromPointerpointer-events:none hit-test (studioPreviewHelpers.ts:104-122) — temporarily injects* { pointer-events: auto !important }via<style>element, runs the hit-test, removes the style infinally. Synchronous insert/remove so other event handlers can't observe the override window. ✓
Body claims vs diff
Spot-checked:
- "GSAP baked the resolved CSS translate value into element.style.transform on every seek, causing double-translation" — verified, the fix is exactly the matrix m41/m42 zero ✓
- "setInterval polling never fired during Puppeteer's page.evaluate()" — Object.defineProperty setter trap is the right shape ✓
- "MEDIA_VISUAL_STYLE_PROPERTIES included transform but not translate, rotate, scale" — verified ✓
- "hyperframes lint --verbose reports pointer_events_none info findings" — new lint rule at
lint/rules/core.ts+52 ✓ - Test plan checks all marked done. CI matches.
Non-blocking notes
-
composeIndividualTransformsparsing assumes computed-style px/deg output —parseFloat(parts[0] ?? "0")ontranslateworks becausegetComputedStyle(el).getPropertyValue("translate")normalizes to absolute pixel values. Same forrotate(browsers normalize todeg). Hand-authoredtranslate: 1em 2emin source HTML would be wrong if it reached this path uncomputed, but it can't —getComputedStylealways resolves. Worth a one-line comment noting "values are guaranteed to be in px/deg bygetComputedStyle" so a future reader doesn't accidentally feed it raw source-side input. -
pointer-events: auto !importantinjection global override — during the synchronous hit-test window the entire document briefly haspointer-events: auto, overriding any composition that intentionally usespointer-events: noneto layer click-through behavior. Synchronous removal means no other event handler observes it, so no functional leak. But: ifforcePointerEventsAutosucceeds andresolveVisualDomEditSelectionTarget/getDomLayerPatchTargetthrows or returns early before thefinallyremoves it... the try/finally guarantees removal, so this is fine. Just noting it's a heavy hammer; alternatives (elementsFromPointalready returns elements regardless ofpointer-events, so the new fallback path may be the better-targeted fix without needing the global override). Cosmetic. -
Idempotency of
__hf_studio_pointer_events_override__style ID — if a prior hit-test leaked (e.g., due to a crashed render), the next call appends a second<style>with the same ID.style.remove()in finally would only remove the most recent. Practical leak is bounded since the try/finally is robust, butforcePointerEventsAutocoulddoc.getElementById(POINTER_EVENTS_OVERRIDE_ID)?.remove()first to defend. Cosmetic. -
Scope — PR grew from yesterday's 189/30 to 1066/1305 across 29 files. The breadth is justified given the design pivot, but worth flagging that #854 (the render-position fix) and the GSAP double-translation fix and the pointer-events fix and the post-resize-click fix are all riding along — any one of them could have been its own PR. Reviewable as-is, but the historical revert surface is wider than the original toggle scope.
-
Branch name still
feat/studio-preview-pasteboard-bg— yesterday's note still applies, though diluted by the larger scope. Cosmetic.
CI
All required checks green on 414320a: Test, Typecheck, Lint, Build, Format, CodeQL, Test: runtime contract, CLI smoke (required), Smoke: global install, Preview parity, preview-regression, Tests on windows-latest, Render on windows-latest, regression-shards (hdr / styles-g), player-perf. One regression-shards (styles-e) still in-progress. mergeable_state: blocked is reviewer-gate.
Withdrawing my prior REQUEST_CHANGES. Re-approving on the new design.
— Re-review by Rames Jusso (pr-review)
vanceingalls
left a comment
There was a problem hiding this comment.
Re-review. Architecture pivot: the JSON manifest + opt-out toggle were removed entirely. Positions persist directly to HTML inline styles + CSS custom properties. This is the right call — the prior design's correctness gaps (4 blockers from my last review) were all rooted in the toggle being an additional gate that had to be enforced at every capability site. Removing the gate removes the surface.
Audited blockers from prior review
- Required CI
Testred onDomEditOverlay.test.ts:113— ADDRESSED.canMove: trueadded to the test capabilities fixture in this PR (DomEditOverlay.test.ts:116). All required CI green on414320a:Test,Tests on windows-latest,Typecheck,Lint,Build,CLI smoke,Test: runtime contract,regression-shards (render-compat / styles-a..g / hdr),Render on windows-latest,preview-regression,Player perf (load/fps/scrub/drift/parity),CodeQL. Two optional shards still in-progress (regression-shards (fast),regression-shards (styles-e)), non-blocking. - Multi-select drag bypasses toggle — NO LONGER APPLICABLE (no toggle). The drag site still got a defensive tightening though:
DomEditOverlay.tsx:311now gates on!groupCanMovein addition to!allowCanvasMovement, which is the right shape — multi-select drag only fires when every selected element passes the capability check. - Single-element resize/rotate handles render and fire for non-absolute when toggle off — NO LONGER APPLICABLE. Per-capability gates (
canApplyManualOffset / canApplyManualSize / canApplyManualRotation) are now the single source of truth and they're tied to the element's actual structural properties. - Defence-in-depth gap at gesture boundary — NO LONGER APPLICABLE.
startGesturechecks the same capability flags; there's no second invariant to drift from.
Calibrated strengths
sourcePatcher.ts:158-186— thevalue: string | nullsemantics are cleanly threaded: existing-style branch deletes the prop, no-style branch is a no-op for null, and the rebuilt-style serialization is unchanged for the set branch. Emptystyle=""left harmless and noted.sourcePatcher.ts:228-238, 270-275— both attribute paths add\\s+${escapeRegex(fullAttr)}=removal patterns, and the previously-bareattrPatternis now wrapped inescapeRegex(latent issue withdata-attrs containing regex-meta chars also fixed in passing).manualEditsDom.ts:208-227—stripGsapTranslateFromTransformis exactly the right shape: extract translate from the GSAP-managed matrix viaDOMMatrix, write back without m41/m42, and drop the property entirely when the resulting matrix is identity. The DOMMatrix lookup is window-scoped so iframe contexts work.manualEditsRenderScript.ts:247-272— theObject.definePropertysetter trap on__hf.seek/__player.renderSeekis the right fix for the Puppeteer macrotask-starvation regression.WRAPPED_PROPguard prevents double-wrap, and there's still a 6s polling fallback for non-configurable cases.htmlCompiler.ts:996-1008— script injection is gated by attribute presence, so the runtime cost is zero for projects that don't use studio positioning.
Important (not blocking)
- No unit tests for the new
value: nullsemantics insourcePatcher.sourcePatcher.test.tsis untouched in this PR — none of its existing cases passvalue: null, so the removal branches inpatchInlineStyleInTag(lines 172-188),patchAttributeByTarget(lines 304-310),patchAttribute(lines 333-339), and thetext-contentnull no-op (lines 423, 442) are entirely uncovered. This is the new architectural primitive of the whole PR — a single happy-path round-trip test (buildPathOffsetPatches->applyPatchByTarget-> grep for--hf-studio-offset-xin result, thenbuildClearPathOffsetPatches->applyPatchByTarget-> verify it's gone) would lock the contract. composeRotationsilently drops authored axis-form rotates.manualEditsRenderScript.ts:152-157falls back toreturn rotationValuewhenisSimpleRotateAnglerejects the original (e.g.,45deg X Yaxis form, orvar(...)). The studio path usescalc(original + studio)for the simple case and juststudiofor the complex case — which means authored 3D rotations get overwritten by studio rotations. Probably fine in practice (studio doesn't ship 3D rotation today) but worth a TODO comment so a future user of authored axis-rotates doesn't get a silent regression.- Render-path injection idempotence.
htmlCompiler.ts:1000-1008appends<script>...</script>before the first</body>. IfcompileForRenderis ever called on already-compiled output (or the runtime cache double-fires), you'd get two copies of the script in the HTML. The runtime is self-guarded against double-wrapping viaWRAPPED_PROP, so this is benign at runtime — but a one-lineif (htmlWithAssets.includes("__hfStudioPositionSeekReapplyWrapped"))skip would make the HTML output deterministic across recompiles.
Nits
manualEditsDom.ts:215—(win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrixis more cast-juggling than needed; the iframe window does haveDOMMatrixtyped natively.win?.DOMMatrixworks.useDomEditCommits.ts:227-244—commitPositionPatchToHtmlswallows errors via.catch(showToast)but the innerqueueDomEditSavealready runs the body inside its own promise chain; ifqueueDomEditSaveitself rejects synchronously (it doesn't today, but the type doesn't preclude it), the catch may not fire. Defensivetry { void queueDomEditSave(...).catch(...) } catch { showToast(...) }would be more robust.- The closed group-drag write loop at
useDomEditCommits.ts:241-247callscommitPositionPatchToHtmlper-element with the same coalesce key — relies on the save queue to dedupe. Worth a comment.
Cited prior reviews
- @jrusso1020's initial APPROVE (pre-pivot) and follow-up COMMENT recalibrating after my prior REQUEST CHANGES are auto-invalidated by branch protection's "re-approval after push" rule; they're not in scope for this verdict.
Verdict: APPROVE
Reasoning: The pivot deletes the entire opt-out toggle surface that all 4 prior blockers attacked, and the new direct-to-HTML persistence is sound (clean null-removal contract in sourcePatcher, correct GSAP matrix strip, proper seek-trap for render). The test-coverage gap on the new value: null primitive is important but the production paths exercising it are integration-tested via the test plan and now green-on-CI; recommend a follow-up to lock the contract.
Review by Vai (re-review)
Summary
.hyperframes/studio-manual-edits.jsonsidecar filereapplyPositionEditsAfterSeekqueriesdata-hf-studio-*markers to re-composetranslate/rotate/width/heightafter GSAP seektranslatevalue intoelement.style.transformon every seek, causing double-translation when the studio's reapply hook also wrote the offset viatranslateindividual property. After a drag to 259px, reload would show 518px instead.stripGsapTranslateFromTransformnow zeros the translate component from GSAP's matrix (preserving scale/rotate) after every offset apply.scale: 0.85(centertransform-origin) drifted visually when CSSwidth/heightchanged because the scale shifts the visual left edge. Drift compensation measuresgetBoundingClientRectbefore/after the resize and adjustsleft/topto cancel the shift.setIntervalto wrapwindow.__hf.seek, but Puppeteer'spage.evaluate()never yields the event loop for macrotasks so the interval never fired. Now usesObject.definePropertyto trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it.MEDIA_VISUAL_STYLE_PROPERTIESincludedtransformbut nottranslate,rotate, orscale(CSS Transforms Level 2). The proxy<img>for<video>elements was positioned atoffsetLeft/offsetTopwithout the studio translate offset. Also fixedgetViewportMatrixfor HDR elements.useManifestPersistencehandled motion manifest paths but did nothing for other files (index.html, assets). Now callsreloadPreview()for non-motion external changes, with echo suppression via the shareddomEditSaveTimestampRef.pointer-events: noneunselectable in Studio —elementsFromPoint()skips these elements. Now temporarily injects* { pointer-events: auto !important }during hit-testing. Addedpointer_events_nonelint rule (info severity).clickevent bubbled tohandleBoxClickand re-selected whatever element was under the pointer. Now suppresses the click in both resize exit paths.Fixes #854
How it works
When you drag an element, the live DOM already gets
--hf-studio-offset-x/yandtranslate: var(...)as inline styles (unchanged). The new path then writes those same values into the HTML source file usingpersistDomEditOperationswithskipRefresh: true— one save per drag gesture, coalesced by key, no iframe reload.Reset edits clears the custom properties and restores original inline values, then reloads so CSS classes re-assert.
GSAP double-translation fix: GSAP 3.x reads the resolved
translateCSS individual property value at tween initialization and caches it into its internal transform matrix. On everyseek(), it writes this cached translate back intoelement.style.transform. When the studio's reapply then also setselement.style.translate = var(--hf-studio-offset-x, ...), both properties contribute — doubling the visual offset. The fix strips only the translate component (m41/m42) from the GSAP-managed matrix usingDOMMatrix, leaving scale, rotate, and skew untouched.Render position fix: The seek-reapply script (
studioPositionSeekReapplyRuntime) wrapswindow.__hf.seekso thatreapplyAll()runs after every GSAP seek to restore CSStranslateindividual properties. The wrapping usedsetIntervalpolling, but during Puppeteer renderingpage.evaluate()calls don't yield the event loop for macrotasks — the interval never fired. Now usesObject.definePropertyto install a setter trap onwindow.__hf.seek, wrapping the function synchronously the instant the bridge script assigns it.Changes
sourcePatcher:PatchOperation.value: string | null— null removes the property/attribute from the tagmanualEditsDom:build*Patches/buildClear*Patcheshelpers serialize live element state toPatchOperation[];reapplyPositionEditsAfterSeekreplaces manifest-based reapply;stripGsapTranslateFromTransformfixes double-translation; resize drift compensation inapplyStudioBoxSizeDimensionsmanualEdits.ts: removeapplyStudioManualEditManifest, all manifest target resolution; keep seek/play wrap infrastructureuseManifestPersistence: remove all JSON I/O, manifest state, toggle state; seek hook usesreapplyPositionEditsAfterSeek; SSE handler now callsreloadPreview()for non-motion external file changesuseDomEditCommits: newcommitPositionPatchToHtmlreplacescommitStudioManualEditManifestOptimisticallyDomEditOverlay,PropertyPanel, context/session/App wiring: removemanualEditsEnabledentirelymanualEditsParsing/manualEditsTypes: remove manifest types and upsert functionsmanualEditsRenderScript:installSeekTrapviaObject.definePropertyreplacessetIntervalpolling for wrapping seek functionsparityContract: addtranslate,rotate,scaletoMEDIA_VISUAL_STYLE_PROPERTIESvideoFrameInjector:getViewportMatrixcomposes CSS individual transform properties (translate,rotate,scale) into the viewport matrixstudioPreviewHelpers:getPreviewTargetFromPointerforcespointer-events: autoduring hit-testinguseDomEditOverlayGestures: suppress post-resize click in both resize exit pathslint/rules/core: addpointer_events_noneinfo ruleTest plan
--hf-studio-offset-x/y+translateinline; no.hyperframes/studio-manual-edits.jsoncreatedscaletween → position persists correctly after reloadpointer-events: nonein composition → element is selectable in Studiohyperframes lint --verbose→ reportspointer_events_noneinfo findingsbun testin core, engine → all tests pass