Skip to content

fix(studio): keyframe bug bash — 25 fixes across drag, delete, recording, and property editing#1314

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/gesture-recording-offset-v2
Jun 10, 2026
Merged

fix(studio): keyframe bug bash — 25 fixes across drag, delete, recording, and property editing#1314
miguel-heygen merged 1 commit into
mainfrom
fix/gesture-recording-offset-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes 25 bugs across the keyframe editing, drag commit, animation deletion, gesture recording, and property panel systems. Spanning two sessions of investigation and fixes with a comprehensive audit between them.


Session 1: Core keyframe infrastructure (9 fixes)

Gesture recording

  1. Always read CSS var offsets regardless of translate state
  2. Clear translate after seek during preview
  3. Restore visibility + translate on recording stop
  4. Gate recording behind STUDIO_KEYFRAMES_ENABLED

Design panel
5. Subscribe to liveTime for real-time value refresh during playback
6. Use update-keyframe instead of add-keyframe when editing existing keyframe
7. Prefer single-element tween over group selector when multiple target same element

Path offset system
8. reapplyPathOffsets passes updateBase:false to prevent corrupting translate base
9. gsapAnimatesProperty handles array-form keyframes

Session 1: Server-side safety net (4 fixes)

  1. Server-side offset strippingstripStudioEditsFromTarget removes stale data-hf-studio-path-offset and CSS var offsets when an animation is deleted
  2. Opacity baking on deletebakeVisibilityOnDelete preserves element visibility when GSAP animation is removed and CSS opacity:0 would take over
  3. Baseline visual property capture — VISUAL_BASELINE block captures non-default opacity/scale/rotation from runtime during drag commits
  4. Scale-aware identity matrix — drag offset matrix uses editScaleX/Y instead of hardcoded 1:1

Session 1: File refactor + lint (3 fixes)

  1. Split oversized studio files to pass 600-line check
  2. Fix lint warnings in refactored modules
  3. Wire up keyframe nav arrows and indicators

Session 2: Regression fixes from audit (9 fixes)

  1. Gate server delete hooksstripStudioEdits flag on the delete mutation type so strip/bake only fires on user-initiated deletes, not internal delete-then-recreate drags in extendTweenAndAddKeyframe
  2. Element disappears after remove-all-keyframes — added bakeVisibilityOnDelete to the remove-all-keyframes handler so CSS opacity:0 elements stay visible after collapsing to a flat tween
  3. Integer rounding corrupts opacity/scale during drag — per-property precision: 3-decimal for visual properties (opacity, scale, rotation), integer for positional (x, y)
  4. Cross-tween value contamination — VISUAL_BASELINE queries __timelines to exclude properties animated by other tweens on the same element
  5. bakeVisibilityOnDelete misses opacity in earlier keyframes — reverse-scans all keyframes instead of only checking the literal last keyframe object
  6. bakeVisibilityOnDelete writes invalid CSS for relative opacity — guards against +=/-=/*= values and adds Number.isFinite check
  7. Falsy-zero doubles drag distance — replaced || gsapPos.x with Number.isFinite check so a GSAP base position of 0 is preserved correctly
  8. Gesture recording element jumps to wrong position — removed sign-inverted pointerElementOffset subtraction from dx/dy formula, applied once to basePosition so element center tracks the pointer
  9. TypeScript build broken — fixed 6 as Record<string, unknown>as unknown as Record<string, unknown> casts in gsapSoftReload.ts

All diagnostic logs stripped from production code.


Files changed

File Changes
packages/core/src/studio-api/routes/files.ts extractGsapScriptBlock exposes document; stripStudioEditsFromTarget; bakeVisibilityOnDelete with reverse-scan + relative value guard; stripStudioEdits flag gating; bake on remove-all-keyframes
packages/studio/src/hooks/gsapRuntimeReaders.ts POSITION_PROPS per-property rounding; VISUAL_BASELINE cross-tween guard via __timelines traversal
packages/studio/src/hooks/gsapDragCommit.ts Falsy-zero fix (Number.isFinite); extend-tween keyframe remapping
packages/studio/src/hooks/useGsapScriptCommits.ts stripStudioEdits: true on user-level delete
packages/studio/src/hooks/useGestureRecording.ts Sign inversion fix; pointer-to-element center offset applied to basePosition
packages/studio/src/utils/gsapSoftReload.ts TypeScript double-cast fixes; diagnostic log cleanup
packages/studio/src/components/editor/manualOffsetDrag.ts Scale-aware identity matrix; diagnostic log cleanup

Test plan

  • Delete animation via context menu → element stays visible, offsets stripped
  • Drag element past tween end → extend-tween does NOT strip/bake, preserves ease
  • Remove all keyframes → element stays visible
  • Drag mid-fade element → opacity preserved (not rounded to 0)
  • Multi-tween element drag → baseline excludes other tweens' properties
  • Drag element with GSAP base x=0 → no position doubling
  • Gesture recording → element center tracks pointer during recording
  • bunx tsc --noEmit passes for studio and core
  • bun test --filter manualOffsetDrag passes (11/11)

@mintlify

mintlify Bot commented Jun 10, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Jun 10, 2026, 12:52 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

Fallow audit report

Found 163 findings.

Duplication (98, showing 50)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/lint/rules/adapters.ts:7 Code clone group 1 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/lint/rules/adapters.ts:33 Code clone group 1 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/lint/rules/captions.ts:165 Code clone group 2 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:295 Code clone group 3 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:385 Code clone group 3 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:608 Code clone group 1 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:645 Code clone group 2 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:667 Code clone group 4 (35 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:845 Code clone group 4 (35 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.stress.test.ts:296 Code clone group 5 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.stress.test.ts:313 Code clone group 6 (13 lines, 5 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.stress.test.ts:499 Code clone group 6 (13 lines, 5 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:282 Code clone group 5 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:297 Code clone group 6 (13 lines, 5 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:313 Code clone group 6 (13 lines, 5 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:347 Code clone group 6 (13 lines, 5 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1099 Code clone group 7 (14 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1142 Code clone group 7 (14 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1200 Code clone group 8 (16 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1212 Code clone group 9 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1213 Code clone group 10 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1219 Code clone group 11 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1227 Code clone group 8 (16 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1242 Code clone group 9 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1243 Code clone group 10 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1251 Code clone group 11 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1260 Code clone group 8 (16 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1274 Code clone group 10 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1410 Code clone group 12 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1419 Code clone group 12 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1430 Code clone group 13 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1468 Code clone group 13 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1543 Code clone group 14 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1562 Code clone group 14 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1630 Code clone group 15 (22 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1681 Code clone group 15 (22 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:565 Code clone group 16 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:663 Code clone group 16 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1036 Code clone group 17 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1146 Code clone group 18 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1200 Code clone group 18 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1562 Code clone group 17 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1677 Code clone group 19 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1800 Code clone group 20 (18 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1883 Code clone group 20 (18 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1980 Code clone group 19 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/manualEditsRenderScript.ts:48 Code clone group 21 (16 lines, 3 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/manualEditsRenderScript.ts:51 Code clone group 22 (7 lines, 4 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/manualEditsRenderScript.ts:437 Code clone group 21 (16 lines, 3 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/manualEditsRenderScript.ts:440 Code clone group 22 (7 lines, 4 instances)

Showing 50 of 98 findings. Run fallow locally or inspect the CI output for the full report.

Health (65, showing 50)
Severity Rule Location Description
critical fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:50 'stripJsComments' has CRAP score 160.0 (threshold: 30.0, cyclomatic 25)
minor fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:164 'extractGsapWindows' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:337 'cssTransformToGsapProps' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:379 'gsapRules' has CRAP score 46.7 (threshold: 30.0, cyclomatic 41)
critical fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:508 '<arrow>' has CRAP score 349.9 (threshold: 30.0, cyclomatic 38)
minor fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:645 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
critical fallow/high-crap-score packages/core/src/lint/rules/gsap.ts:816 '<arrow>' has CRAP score 116.3 (threshold: 30.0, cyclomatic 21)
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:77 'resolveNode' has CRAP score 315.9 (threshold: 30.0, cyclomatic 36)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:151 'selectorFromQueryCall' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:229 'visitCallExpression' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:288 'resolveTargetSelector' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:317 'objectExpressionToRecord' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:408 'visitCallExpression' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:699 'parseMotionPathNode' has CRAP score 210.7 (threshold: 30.0, cyclomatic 29)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1084 'buildTweenStatementCode' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1351 'addKeyframeToScript' has CRAP score 31.8 (threshold: 30.0, cyclomatic 29)
major fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1533 'resolveConversionProps' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
major fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1707 'buildMotionPathObjectCode' has CRAP score 56.3 (threshold: 30.0, cyclomatic 14)
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1757 'setArcPathInScript' has CRAP score 315.9 (threshold: 30.0, cyclomatic 36)
major fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1865 'updateArcSegmentInScript' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1923 'unrollDynamicAnimations' has CRAP score 482.4 (threshold: 30.0, cyclomatic 45)
minor fallow/high-crap-score packages/core/src/studio-api/routes/files.ts:258 'bakeVisibilityOnDelete' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
critical fallow/high-crap-score packages/core/src/studio-api/routes/files.ts:415 'executeGsapMutation' has CRAP score 404.1 (threshold: 30.0, cyclomatic 41)
critical fallow/high-crap-score packages/core/src/studio-api/routes/files.ts:616 'processUploadedFiles' has CRAP score 106.4 (threshold: 30.0, cyclomatic 20)
minor fallow/high-crap-score packages/core/src/studio-api/routes/files.ts:979 '<arrow>' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
critical fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:46 'useKeyframeToggle' has CRAP score 210.0 (threshold: 30.0, cyclomatic 14)
critical fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:77 'TimelineToolbar' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
major fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:136 '<arrow>' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
major fallow/high-crap-score packages/studio/src/components/editor/AnimationCard.tsx:79 'PropertyRow' has CRAP score 56.3 (threshold: 30.0, cyclomatic 14)
minor fallow/high-crap-score packages/studio/src/components/editor/FileTree.tsx:176 'handleDrop' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.tsx:150 'seekToLayer' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.tsx:280 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/components/editor/PropertyPanel.tsx:162 'commitManualOffset' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/components/editor/PropertyPanel.tsx:264 '<arrow>' has CRAP score 97.0 (threshold: 30.0, cyclomatic 19)
minor fallow/high-crap-score packages/studio/src/components/editor/manualEditsDom.ts:226 'stripGsapTranslateFromTransform' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-cognitive-complexity packages/studio/src/components/editor/manualOffsetDrag.ts:238 'createManualOffsetDragMember' has cognitive complexity 17 (threshold: 15)
critical fallow/high-crap-score packages/studio/src/components/editor/propertyPanelStyleSections.tsx:38 'StyleSections' has CRAP score 62.5 (threshold: 30.0, cyclomatic 53)
minor fallow/high-crap-score packages/studio/src/components/editor/useLayerDrag.ts:156 'handleContainerPointerMove' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/components/sidebar/CompositionsTab.tsx:108 'CompCard' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/components/sidebar/LeftSidebar.tsx:66 'LeftSidebar' has CRAP score 184.5 (threshold: 30.0, cyclomatic 27)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:84 '<arrow>' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/gsapRuntimeBridge.ts:221 'tryGsapRotationIntercept' has cognitive complexity 18 (threshold: 15)
critical fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeReaders.ts:50 'readAllAnimatedProperties' has CRAP score 48.0 (threshold: 30.0, cyclomatic 42)
critical fallow/high-crap-score packages/studio/src/hooks/timelineEditingHelpers.ts:44 'patchIframeDomTiming' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/hooks/timelineEditingHelpers.ts:64 'resolveResizePlaybackStart' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/timelineEditingHelpers.ts:102 'persistTimelineEdit' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
minor fallow/high-crap-score packages/studio/src/hooks/timelineEditingHelpers.ts:130 'readFileContent' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
minor fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:39 'computePercentage' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/studio/src/hooks/useAnimatedPropertyCommit.ts:95 'commitAnimatedProperty' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/hooks/useClipboard.ts:34 'getElementOuterHtml' has CRAP score 156.0 (threshold: 30.0, cyclomatic 12)

Showing 50 of 65 findings. Run fallow locally or inspect the CI output for the full report.

Generated by fallow.

@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 4140770 to 87a81cb Compare June 10, 2026 00:56
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 87a81cb to e6ef7b0 Compare June 10, 2026 01:01
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from e6ef7b0 to 62fc8ae Compare June 10, 2026 01:06
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 62fc8ae to 54709cc Compare June 10, 2026 01:13
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 54709cc to 958f65a Compare June 10, 2026 01:21
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 958f65a to 9f6ebc7 Compare June 10, 2026 01:32
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 9f6ebc7 to fb3bfe4 Compare June 10, 2026 01:38
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from fb3bfe4 to 4b1a32c Compare June 10, 2026 01:45
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 4b1a32c to a6a4f7f Compare June 10, 2026 02:05
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from a6a4f7f to c4f4c1a Compare June 10, 2026 02:14
if (!projectId) return;
if (!opts?.background) setLinting(true);
try {
const res = await fetch(`/api/projects/${projectId}/lint`);
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from c4f4c1a to 2c3a053 Compare June 10, 2026 02:20
@miguel-heygen miguel-heygen changed the title fix(studio): keyframe bug bash — 7 fixes fix(studio): keyframe bug bash — 16 fixes + lint UX Jun 10, 2026
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from 2c3a053 to bd583d6 Compare June 10, 2026 02:34
@miguel-heygen miguel-heygen force-pushed the fix/gesture-recording-offset-v2 branch from bd583d6 to 5eebb7c Compare June 10, 2026 02:36

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hf#1314 — fix(studio): keyframe bug bash

Good density of targeted fixes here. A few things need attention before merge.


Blocker — Fallow CRITICAL: addKeyframeToScript complexity must come down

The Fallow CI check is failing with CRITICAL violations:

  • gsapParser.tsaddKeyframeToScript — cyclomatic complexity 25 (threshold 20), cognitive complexity 48 (threshold 15)
  • gsapDragCommit.ts — 3 unused exports: extendTweenAndAddKeyframe, commitKeyframedPosition, commitFlatViaKeyframes

These are blocking CI. The complexity violation in addKeyframeToScript in particular needs real decomposition — it's genuinely hard to reason about at 48 cognitive units. The unused exports need to either be consumed or removed.


Important — files.ts CDN URL hardcoded in a server-side path that mutates user files

// packages/core/src/studio-api/routes/files.ts
const GSAP_CDN = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js";
// ...injected via writeFileSync into user HTML on first "add" mutation

This is the same CDN concern flagged in #1302 — but this one is more serious. In gsapSoftReload.ts (client-side), the CDN URL only affects the preview iframe and can be re-fetched. Here in files.ts, the mutation path permanently writes the CDN <script> tag into the user's HTML file on disk when an add or add-with-keyframes mutation arrives on a non-GSAP file.

Two problems:

  1. Permanent mutation of user data with a pinned external CDN version. If cdn.jsdelivr.net is unavailable at render time (already happened in #1302 context), all videos using GSAP breaks at render.
  2. Version pinning in user files: gsap@3.12.5 is now embedded in user HTML permanently. When you upgrade GSAP, users who had mutations applied get a split version — some files have the old tag, some get the new one on next edit.

The right fix is to inject GSAP from a stable internal asset or the same self-hosted path already used elsewhere, not a CDN URL baked into user files.


Good fixes worth calling out

  • removeAllKeyframesFromScript from() collapse: Correctly collapses from() to first keyframe vs to()/set() to last. This was subtly wrong before and the fix is right.
  • _gsap cache clear in gsapSoftReload: Deleting element._gsap before tl.kill() prevents GSAP's per-element transform cache from baking stale positions into subsequent animations. Good catch.
  • htmlHasGsap template-aware: Stripping <template> content before GSAP detection prevents false positives from inert template markup. Correct.
  • LayersPanel stale element re-resolution: The isConnected check + fallback chain (getElementByIdquerySelector([data-hf-id])) is solid for handling re-mounted elements without losing selection state.
  • useLayerDrag deferred setPointerCapture: Moving capture to after drag threshold correctly unblocks click events on layer items. Simple and right.
  • manualOffsetDrag probe skip: Returning identity matrix early for elements without data-hf-studio-path-offset prevents GSAP cache corruption on non-targeted elements.
  • RAF pause before drag gesture: Setting rafPausedRef.current = true before creating drag members prevents GSAP timeline from advancing mid-drag. Order fix is correct.

Minor

The scoring approach in findGsapPositionAnimation (gsapRuntimeBridge.ts) and pickBestAnimation (useAnimatedPropertyCommit.ts) is a meaningful improvement over the previous single-match approach, but the magic numbers (+10, +5, +8, -5, +4, ±50ms) are tuned empirically with no documentation of why those weights were chosen. Not a blocker — just worth a comment explaining the reasoning so the next person to adjust knows what they're calibrating against.


Address the Fallow CI failure and the CDN injection path before merge.

— Vai

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at 32004fc. Triaged by Home's category split (state mgmt / interaction / lint UX / layers) — sampled one+ per bucket and chased a few targeted things. CI currently red, with one set of failures that look like real regressions in this PR — calling that out below before the substantive read.

Blockers

1. 5 unit tests fail in @hyperframes/studio — at least 3 are real regressions from this PR

From the Test job at run 27254474566:

  • src/components/editor/manualOffsetDrag.test.ts — 3 failures, all in measureManualOffsetDragScreenToOffsetMatrix:

    • measures the element center response and restores probe styles — expected matrix scale ~0.36 got 1.0 (identity)
    • measures movement in parent viewport pixels when the element is inside a scaled iframe — expected ~2 got 1.0 (identity)
    • rejects elements whose movement response cannot be measured — expected ok: false, got ok: true

    Root cause is this PR's new early-return at manualOffsetDrag.ts:148-155:

    if (
      !element.hasAttribute("data-hf-studio-path-offset") &&
      initialOffset.x === 0 &&
      initialOffset.y === 0
    ) {
      return { ok: true, matrix: { a: 1, b: 0, c: 0, d: 1 } };
    }

    The three failing tests construct elements without the data-hf-studio-path-offset attribute and pass initialOffset: {x:0,y:0} — so they hit the new fast path and get an identity matrix instead of the actual probe response. The third test explicitly expects rejection of an unmeasurable element; identity short-circuit returns ok regardless.

    Two paths to resolve:

    • (a) Tests need fixture updates: set data-hf-studio-path-offset on the test element so they exercise the probe path that's still being tested.
    • (b) The fast-path guard is too eager. The comment ("Skip probing for elements without existing path offsets — applying draft offsets adds data-hf-studio-path-offset and corrupts GSAP's transform cache") describes a real cache-corruption scenario, but the predicate should probably also gate on "element is a probe target with no GSAP animation" so legitimate measurement requests aren't skipped.

    My read: (a) is the lower-risk fix if the tests as-written represent stale scenarios that the new gesture-recording flow no longer hits. (b) is needed if the tests represent a use case that the production code still has to handle.

  • src/components/editor/DomEditOverlay.test.tsrenders selected bounds right after clicking a movable selectionTypeError: Cannot read properties of undefined (reading 'length') at DomEditOverlay.tsx:489. Not in this PR's diff, but if the click-stability changes upstream now hand DomEditOverlay a payload missing a previously-defined array field, the regression chain leads back here. Worth a one-line check that the length access has either guarded against undefined or got the field populated by the new gesture flow.

  • src/utils/studioUrlState.test.tshydrates seek first, preserves the initial url state, then restores selection — spy called 0 times, expected 1. Likely interaction with keyframeNav URL-state additions (the K/J/H/U arrow wiring from the prior stack). The seek-first hydration order might now require an explicit signal that the new state machine isn't sending.

    Recommend looking at all three before merge — even if the manualOffsetDrag ones are test-fixture issues, the DomEditOverlay TypeError reads like a real runtime crash for one selection shape.

2. CI infrastructure noise — regression-shards all cancelled, NOT this PR's fault

8 regression shards show as failing on gh pr checks, but shard-7 failed first with:

ERROR: Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled while waiting for connection

That's a Docker Hub registry timeout during image build, not a test failure. The other 7 shards were cascade-cancelled by the fail-fast policy. Not a blocker — re-running the workflow should clear them.

Main on 81416ab is green on the same regression suite (verified gh run list --workflow=regression --branch=main).

Concerns

3. resumeGsapTimelines doesn't actually resume playback

manualOffsetDrag.ts:374-393:

export function resumeGsapTimelines(element: HTMLElement): void {
  const ids = element.getAttribute("data-hf-drag-paused-timelines");
  element.removeAttribute("data-hf-drag-paused-timelines");
  if (!ids) return;
  const win = element.ownerDocument.defaultView as ...;
  // Re-seek to the current time to restore the paused timeline's render state.
  // play() would start playback; pause() already stops. Seek re-renders at the
  // current position without starting playback.
  const t = win.__player?.getTime?.() ?? 0;
  win.__player?.seek?.(t);
}

In createManualOffsetDragMember you call tl.pause() on individual __timelines[id] entries and stash the IDs in data-hf-drag-paused-timelines. To undo that pause you'd call tl.resume() (or tl.play()) on each ID. The current implementation only seeks the master player at the current time, which renders frames but leaves the individually-paused GSAP timelines in paused === true.

This matches a concern I raised on #1308 (round-2 of Miguel's prior stack) — flagged that the PR-body claim "GSAP timeline resume on no-movement cancel + pointercancel" doesn't match the implementation. Either:

  • The intent IS to leave timelines paused after a drag (so the user sees their committed position until they hit play again) — in which case the function should be restoreRenderState, not resumeGsapTimelines, and the PR body's "resume" claim is misleading.
  • Playback IS supposed to resume — in which case you need an explicit tl.resume() loop reading the stashed IDs.

Repro from the PR description's test plan: drag a clip in bugbash-combined, release with no movement, then hit play. If timelines stay frozen until a second seek, that's the bug; if they play through, the master-player seek is doing what you want and the function naming/comment need adjustment.

4. Fallow audit fails — 5 dead-code, 116 duplication findings

The audit report (<!-- fallow-id: fallow-results -->) is in the PR comments. The 5 dead-code findings worth fixing:

  • gsapDragCommit.ts:103, 152, 178extendTweenAndAddKeyframe, commitKeyframedPosition, commitFlatViaKeyframes are exported but only used by commitGsapPositionFromDrag in the same file. Drop the export keyword on all three.
  • FileTree.tsx:18FileTreeProps type export unused.
  • FileTreeNodes.tsx:22TreeNode re-export unused.

The 116 minor duplication findings are noisier — most are in lint/rules/gsap.ts and gsapParser.ts and look like patterns that would naturally repeat (per-rule visitor scaffolding). Not action items for this PR; flagging that Fallow ran red and someone should look.

5. extendTweenAndAddKeyframe is still a non-atomic delete + add-with-keyframes pair

gsapDragCommit.ts:143-169: two sequential commitMutation calls, the first deletes the animation, the second re-adds it with remapped percentages + new keyframe. If the second call fails between them, the animation is gone and the drag silently destroys the tween.

Carrying forward from my #1308 round-2 concern. New context that softens this: the call site at gsapDragCommit.ts:271-285 only fires when ct < ts - 0.01 || ct > ts + td + 0.01 — outside the tween's time range with a 10ms epsilon. That's a much narrower window than the prior "any drag" trigger, so the user-visible risk is lower.

Still worth either:

  • Bundling delete + add-with-keyframes into a server-side atomic extend-tween mutation
  • Or wrapping in a try/catch + restore-on-failure path

Not a blocker for this PR but flagging that the bound-by-condition reduction doesn't make the operation atomic.

6. from/fromTo drag silently converts to keyframes

gsapDragCommit.ts:280-285:

} else if (anim.method === "from" || anim.method === "fromTo") {
  await callbacks.commitMutation(
    selection,
    {
      type: "convert-to-keyframes",
      animationId: anim.id,
      resolvedFromValues: { x: newX, y: newY },
    },
    { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
  );
}

Before this PR, from() and fromTo() drags went through commitFromPosition / commitFromToPosition which preserved the tween shape and only updated x/y. Now they convert to keyframes outright.

Behavior change worth flagging in the PR body — users with from() tweens that they're dragging will see their tween reified into a keyframe array, which is harder to edit downstream (lose the from() semantic name in the source, gain a percentage array). PR scope is "30+ fixes" + "click stability" — is this an intentional simplification, or a regression?

The flip side: resolvedFromValues finally being consumed here closes my #1303#1309 thread on the unwired plumbing. Good to see that land.

Things that landed well

  • findGsapPositionAnimation scoring rewrite — the +10/+5/+8/-5/+4 scoring (gsapRuntimeBridge.ts:73-85) maps to the PR body's "selector specificity + time-range overlap (±50ms tolerance)" claim. Sensible disambiguation for elements with multiple animation candidates. Tie-breaker is array order (stable JS sort), which means parser-emitted ordering wins — consistent with how the rest of the bridge consumes animations.
  • computeCurrentPercentage now tween-aware — accepts optional animation param at gsapDragCommit.ts:31-46 and computes via absoluteToPercentage(currentTime, start, duration) when present, falls back to clip-relative when not. Addresses my #1303 concern about clip-relative percentages collapsing multi-tween information.
  • resolvedFromValues finally wired into the drag commit path — closes the cross-PR thread from #1303#1304#1305#1308#1309. The new from/fromTo branch in #6 above is where the value finally gets consumed at the convert-to-keyframes mutation site.
  • GSAP timeline pause() during dragcreateManualOffsetDragMember (line 245-280) pauses individual win.__timelines[] entries before the gesture starts, capturing IDs for later restoration. Right idea even if the restoration in #3 is incomplete.
  • useLintModal background-vs-foreground split — the new {background?: boolean} option (line 35) and backgroundFindings separate state lets the lint indicators (dots on composition tab, counts on code tab files) update silently without popping the modal. Good UX pattern.

Nits

  • New lint rule additions in packages/core/src/lint/rules/gsap.ts (+29 lines) have no accompanying test diff in this PR. The other lint rules in that file have test suites in packages/core/src/lint/rules/*.test.ts. Worth at least one test case for the new "group selectors with shared keyframes" rule (per PR body).
  • useGestureRecording.ts is now 429 lines (was dead-then-integrated in the prior stack). At this size it's within file-size limit but on the edge — useGestureRecording-helpers.ts would split off the 6 extracted helpers (readBasePosition, connectGsapRuntime, applyRuntimePreview, etc) cleanly if the file grows further.
  • gsapRuntimeBridge.ts re-exports readGsapProperty and readAllAnimatedProperties from gsapRuntimeReaders.ts — but gsapRuntimeReaders.ts is the new home for those functions, and they're imported through gsapRuntimeBridge.ts only by gsapDragCommit.ts. Could the re-export disappear and gsapDragCommit import from gsapRuntimeReaders directly? Minor.

Questions

  • #1: Are the 3 manualOffsetDrag.test.ts failures expected to be fixed by test-fixture updates (case a above), or is the new early-return guard too eager (case b)?
  • #3: Is resumeGsapTimelines named correctly, or should it be restoreRenderState? Does playback actually resume after a drag in the bugbash-combined project, or do users need to hit play?
  • #6: Was the silent from/fromTo → keyframe conversion intentional, or did the keyframe-path simplification accidentally subsume the from-shape preservation?

What I verified

  • All 8 failing CI checks: Test is real (5 test failures, see #1), Fallow audit has substantive findings (see #4), regression-shards are Docker Hub-cancel cascade noise (see #2).
  • findGsapPositionAnimation scoring formula against the PR body claim.
  • extendTweenAndAddKeyframe non-atomicity against my prior #1308 review.
  • resolvedFromValues wire-in at gsapDragCommit.ts:280-285 closes the open cross-PR thread.
  • Code change spans across 37 files reconciled with PR-body category groupings (state mgmt / click stability / lint UX / layers).

What I didn't verify

  • The actual bugbash-combined test plan in the browser — flagged the timeline-resume question for empirical validation by the author.
  • Deep-read on useGestureRecording.ts (only sampled readBasePosition and connectGsapRuntime). The big helpers (applyRuntimePreview, commitRecordedKeyframes) deserve a second pass post-blocker fixes.
  • Layers panel deferred-preventDefault + setPointerCapture-at-drag-threshold pattern (per PR body) — only verified that the layers tab files changed, didn't read the click-handler diff in detail.
  • Lint-rule "group selectors with shared keyframes" semantics — only verified the rule file size jumped 29 lines.

Solid bug-bash sweep — the scoring rewrite + tween-aware percentages + resolvedFromValues wire-up are all directionally correct moves on top of the prior stack. The 5 unit test failures are what's blocking, plus the resume-vs-render-state ambiguity if the timeline actually doesn't resume. Once those land, the rest is review-clean from my side.

Review by Rames D Jusso

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed at 7086ff0. Delta is exactly the blocker fixes + export drops + a couple of incidentals (+68/-8 across 7 files). All 5 unit test failures resolved, Test job now green.

Blockers cleared

manualOffsetDrag.test.ts (concern #1, option a) — went with the test-fixture-update path: added data-hf-studio-path-offset="true" to the two tests that exercise the probe response, kept the third test on the new fast-path semantics + added a fourth test (rejects path-offset elements whose movement response cannot be measured) to keep the rejection branch covered. Renaming the third test to returns identity matrix for non-path-offset elements with zero initial offset makes the new contract explicit. Clean.

DomEditOverlay.test.ts (concern #1) — root cause was happy-dom's getBoundingClientRect returning zeros for fresh elements, gating compRect.width > 0 (added in #1311). The new test stubs Element.prototype.getBoundingClientRect to return 800×450, flushes two RAF ticks after mount, and adds the missing childRects: [] mock field. The TypeError: Cannot read properties of undefined (reading 'length') was the missing childRects field — fix lives in the test mock, not runtime. Make sense.

studioUrlState.test.ts (concern #1) — explicit usePlayerStore.setState({ currentTime: 4.2 }) drives the hook's store subscription. Per Miguel's inline comment, the hook stopped taking currentTime as a prop in #1311 and now subscribes to the store directly; the harness prop was a no-op so the time-stability guard never let the seek-hydration effect through. Clean.

Fallow dead-code in gsapDragCommit.ts (concern #4)extendTweenAndAddKeyframe, commitKeyframedPosition, commitFlatViaKeyframes all dropped the export keyword. 3 of the 5 dead-code findings cleared.

GSAP CDN URL extracted to templates/constants.ts — small bonus cleanup in files.ts:709, replaces the inline cdn.jsdelivr.net/... literal with a shared GSAP_CDN constant. Nice.

One CI item still red

Fallow audit — still fails, but the remaining findings are NOT in this PR's surface:

  • FileTree.tsx:18FileTreeProps unused type export
  • FileTreeNodes.tsx:22TreeNode unused re-export

These look like leftovers from #1313's file-size split (FileTree.tsxFileTree.tsx + FileTreeNodes.tsx); both files are unchanged in this PR's diff. If Fallow is gating on "any audit failure" rather than "any new findings", you may need either (a) clean these up in this PR for it to land, or (b) bypass / waive in a separate cleanup PR. My read is the audit policy is the call — code-wise the dead types are obvious cleanups (just drop the export keyword or delete entirely if truly unused).

Duplication count is up (59 → 71) — likely from the new test-stub patterns. Minor severity, not worth chasing.

Concerns I left open in round-1 — still open after this push

Per your "all addressed" framing, treating these as "intentionally left as-is" unless you say otherwise. Posting for the record so they don't get lost:

  • #3 (resumeGsapTimelines doesn't resume) — no code change. If you've verified empirically that playback continues after a drag in bugbash-combined and the master-player seek does the right thing on individually-paused __timelines[] entries, that closes it for me. If not, the loop reading the stashed data-hf-drag-paused-timelines and calling tl.resume() per ID would still be needed. Repro: drag → release with no movement → press play. If the GSAP tween plays through, fine.
  • #5 (non-atomic extendTweenAndAddKeyframe) — left as-is. Bounded to outside-tween-range drags so the user-visible risk is small; flagged for the follow-up backlog rather than this PR.
  • #6 (from/fromTo silently converts to keyframes on drag) — left as-is. Reading the lack of code change as "intentional simplification"; worth a one-line note in the PR body / release notes so users dragging from() tweens aren't surprised by the conversion.

What I verified this round

  • git log 32004fc7..7086ff0 — 1 commit on this PR (fix(studio): keyframe bug bash — 7 fixes rebased onto refactor) + 1 incidental rebase pull-in from #1315 (README).
  • Unit test job now green at 7086ff0 (gh pr checks 1314 | grep ^Test).
  • 4 fallow findings cleared (gsapDragCommit.ts exports + the duplication count includes new test-stub patterns).
  • Fixture updates in manualOffsetDrag.test.ts and DomEditOverlay.test.ts exercise the new code paths from #1311+#1314, not just the old contract.

What I didn't verify

  • bugbash-combined test plan in the browser (concern #3 empirical).
  • Whether Fallow audit is gating on "any failure" vs "delta from base" — if the latter, the FileTree* leftovers might actually be pre-existing on main and you're being failed on a baseline mismatch.

Solid round-2. Test job green, dead-code exports cleared, fixture fixes are surgical. The remaining items are policy/follow-up calls, not engineering ones.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hf#1314 round-2

All blockers cleared:

  • 5 unit test failures fixed — Test is green
  • 3 unused exports in gsapDragCommit.ts dropped
  • CDN URL extracted to templates/constants.ts

On Fallow: the 2 remaining findings (FileTree.tsx:18, FileTreeNodes.tsx:22) are pre-existing dead-code from #1313's file split — not introduced here. If Fallow gates on any failure rather than delta, those need to be cleared on their own PR, not here.

The files.ts CDN injection path (permanently writing GSAP bootstrap into user HTML on disk) is still architecturally fragile — the constants extraction is an improvement but the mutation itself is unchanged. Flagging as a future-PR concern rather than holding this up.

— Vai

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved on behalf of James.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review

Round 3 — treated as fresh per scope shift. Read the full diff end-to-end.

CI: required checks (CLI smoke, Test, Build, Typecheck, Lint, Format, Studio: load smoke, regression) all green. Fallow audit is red but the two dead-code findings (FileTreeProps, TreeNode re-export) are pre-existing at base (8fcbb63a) — Fallow was skipped at base because the changed-file filter didn't match, so this PR surfaces them. Not introduced here. The duplication findings are likewise in files this PR doesn't touch.


Architecture / design

The keyframe fixes are well-targeted. Splitting useGestureRecording into pure helpers + a single RecordingRefs ref is a real improvement. The new save/restore of savedVisibility + savedTranslate on stop closes a real leak. The falsy-zero Number.isFinite fix, per-property rounding for POSITION_PROPS, and the __timelines cross-tween baseline guard are all solid.

stripStudioEditsFromTarget + bakeVisibilityOnDelete as a single hook gated by stripStudioEdits: true is the right call — keeps the internal delete-then-recreate dance from accidentally stripping CSS vars the next keyframe needs.


Findings

blocker — autoKeyframe=OFF on a GSAP-animated element silently corrupts keyframes

PropertyPanel.tsx commitManualOffset (lines ~162–180): with autoKf=false, when a GSAP animation exists, both early-return branches are skipped and control falls through to onSetManualOffset(...) — the CSS path-offset write. Per the explicit warning added in gsapRuntimeBridge.ts this same PR: "CSS path must NEVER touch GSAP-targeted elements because changing the CSS offset corrupts all existing keyframes (baked mismatch)". The AutoKeyframeToggle in TimelineToolbar.tsx ships in this same PR, so the UI to trigger the corruption arrives together with the corruption itself.

Fix options:

  • Refuse the write when autoKf=false && (gsapAnimId || gsapAnimations.length > 0), surface a toast
  • Or keep the GSAP path regardless of autoKf when an animation exists, restricting the toggle to CSS-path-only elements

blocker — extractGsapScriptBlock bootstrap write is non-atomic

files.ts mutation handler: when no GSAP script exists and the mutation is add/add-with-keyframes, writeFileSync writes a bootstrap block before the downstream validation/parser/script-computation runs. If any subsequent step throws, the user's HTML now permanently contains a bootstrap that wasn't there before, and the operation reports failure. Either defer the write until the new script is fully computed, or wrap in a try/catch that restores the original file on error.

The bootstrap also hardcodes gsap.timeline({ paused: true }) without a comment — worth a short justification if that's intentional.

important — bakeVisibilityOnDelete may bake wrong opacity for from() + keyframes

The if (anim.keyframes) branch reverse-scans for the last opacity regardless of anim.method. For from({ keyframes: [...] }), GSAP settles to the static state, not the last keyframe. Combined with the removeAllKeyframesFromScript change that collapses from() to the first keyframe, the bake and the collapse can disagree. Please confirm whether from()+keyframes exists in any shipped template; if so, skip the bake for from() animations (the destination is already correct static CSS).

important — useLintModal auto-lint ref never resets on projectId change

autoLintRanRef is set to true on first run and never cleared. Effect deps include projectId, but the early-return blocks re-entry forever. Switching projects in a session loses auto-lint for the new project. Fix: store the last linted projectId in the ref and re-run when it changes.

important — PropertyPanel force-renders every RAF frame during playback

Subscribing to liveTime and force-rendering the full PropertyPanel at 60fps during playback is a perf cliff when the panel is open. Either lift the subscription into small leaf components, or throttle to ~10–15fps (property panel values don't need 60fps fidelity).

important — rafPausedRef set only for drag gesture kind

domEditOverlayStartGesture.ts: only the drag branch sets rafPausedRef.current = true. Box-size and path-offset gestures on GSAP-animated elements still run the overlay RAF concurrently, which is exactly what the new ref was added to prevent.

important — missing test coverage for a 25-bug bash

The only new test churn is 18 lines in manualOffsetDrag.test.ts. Minimum ask before merge:

  • Falsy-zero fix in commitGsapPositionFromDrag — element with data-hf-drag-gsap-base-x="0" must not double the drag
  • bakeVisibilityOnDelete: keyframed-opacity scan, relative-value guard, from() case
  • readAllAnimatedProperties rounding: x rounds to integer, scale=0.123456 to 0.123
  • removeAllKeyframesFromScript: from() → first kf, to()/set() → last kf

All pure functions — no excuse for skipping them after a bash this deep.

nit — scope creep

The PR contains a full lint-modal rewrite, AutoKeyframeToggle UI, RenderQueueItem always-visible actions, useTimelinePlayhead auto-scroll change, pointer-capture timing fix, propertyPanelStyleSections fill-mode removal, and a new gsap_group_selector_keyframes lint rule — none of which are in the "25 keyframe bug fixes" framing. Fine individually; bundled here they bloat review and make bisection harder.

nit — gsap_group_selector_keyframes regex is fragile

[^}]* inside the vars lookahead will miss any object containing a nested } (inline functions, style objects). False negatives only — add a comment noting best-effort heuristic.

nit — extractGsapScriptBlock exposes Document to callers that don't need it

Small API surface expansion. Consider a thin handle ({ scriptText, replaceScript }) that keeps Document internal.

nit — useLayerDrag.ts setPointerCapture try/catch {}

Swallows errors silently. At minimum console.warn so failures aren't invisible.

— Vai

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed at ce84aba4 (delta from round-2 7086ff0a — 7 files, +155/-35). Round-2 reviewers approved at 7086ff0a so my last LGTM is implicit on everything below that line; this review covers only what landed in round-3.

The 9 fixes from the "Session 2 audit" are well-targeted and mostly clean. A few of them deserve a second look before the next stamp.

Concerns

  1. bakeVisibilityOnDelete silently skips gsap.from(...) (files.ts:258-288). The branches handle keyframes (reverse-scan), to/set, and fromTo, but not from. A gsap.from(el, { opacity: 1 }) ends with the element at its CSS opacity — which might be 0 — and deleting that animation gives CSS its way again. So baking from the animation's properties.opacity would actually be wrong for from (the animation's 1 is the START opacity, not the final). This may be intentional, but the implication is: deleting a from-method opacity animation can still leave a visibility hole. Worth either: (a) a comment in bakeVisibilityOnDelete documenting that from is intentionally not baked, or (b) baking the element's current runtime opacity (via DOM read) when method is from.

  2. VISUAL_BASELINE cross-tween guard misses keyframes-array tweens (gsapRuntimeReaders.ts:71-99). The walk over __timelines.getChildren(true) reads each tween's vars and collects top-level keys (excluding duration, ease, etc.) as "animated properties." But for tweens of the form gsap.to(el, { keyframes: [{ opacity: 0 }, { opacity: 1 }], duration: 1 }), the top-level vars key is keyframes — not opacity — so opacity never lands in otherTweenProps. Result: a sibling tween animating opacity via the keyframes-array form won't suppress the VISUAL_BASELINE write, and you can still bake stale opacity. The fix is a one-level descent into vars.keyframes: when present, union the keys of each keyframe object into otherTweenProps.

  3. The mutation handler is now CRAP 636 / cyclomatic 52 (files.ts:750, per the Fallow report). Round-3 added the stripStudioEdits gating in the "delete" case and the bake call in "remove-all-keyframes". Both are correct and small, but the giant switch is now the highest-CRAP single function in the codebase (above even unrollDynamicAnimations). Not your bug to fix today — but Vai called out addKeyframeToScript decomposition in round-1, and the same pattern logic applies here. Worth a follow-up ticket to extract per-mutation-type handlers; otherwise the next "small fix" will tip it past whatever the next CI threshold is.

Nits

  • options.scaleX || 1 falsy-fallback at zero (manualOffsetDrag.ts:153-154). 0 || 1 === 1 is harmless for input correctness (zero-scale-element doesn't have a sensible identity matrix anyway), but the same Number.isFinite pattern from fix #23 in gsapDragCommit.ts would be more consistent and would catch the NaN case explicitly.
  • GSAP vars allowlist is incomplete but currently harmless (gsapRuntimeReaders.ts:80-87). The exclude list covers duration, ease, delay, stagger, id, onComplete, onUpdate — but GSAP has more utility vars (paused, repeat, repeatDelay, yoyo, motionPath, runBackwards, data, inherit, startAt, immediateRender, lazy, overwrite, callbackScope, defaults, onStart, onRepeat, onReverseComplete). All of them would currently be added to otherTweenProps and then ignored (none are in VISUAL_BASELINE), so no real-world impact today. But if VISUAL_BASELINE ever grows to include something like motionPath, the bug surfaces. Worth flipping the allowlist into a "treat all CSS-style props as animated" or maintaining a full GSAP-vars block list.
  • readAllAnimatedProperties walks __timelines × children × keys on every call. Acceptable for typical scripts, but if this gets called per-frame during preview (haven't traced the caller), the constant factor adds up. Not a blocker — flagging for the perf backlog.

Questions

  • Was the from-method case considered for bakeVisibilityOnDelete? (See concern #1.) If you decided from is rare enough or always converts to to/keyframes via the studio's commit path, a one-line comment in the function would close the loop for next reader.
  • Why use Math.round(val * 1000) !== Math.round(defaultVal * 1000) for the VISUAL_BASELINE "different from default" check (gsapRuntimeReaders.ts:96)? The 3-decimal-precision equality is fine in practice, but I wanted to make sure the intent isn't "skip values within 0.5 of default" — it's "skip values that round to default at 3 decimals." Just confirming I'm reading it right.
  • gsapSoftReload.ts allTargets collector — the diff shows the new array being populated but I couldn't see where it's consumed (cut off in my view). Assuming it feeds a downstream _gsap cache clear or similar; if it's collected and never read, it's dead code.

Fallow audit

Fail-on-issues with 128 findings. The new-vs-round-2 critical ones in this PR's code:

  • bakeVisibilityOnDelete (files.ts:258) CRAP 71.3, cyclomatic 16 — new function.
  • <arrow> (files.ts:750) CRAP 636.1, cyclomatic 52 — the mutation-handler switch grown by this PR (covered above).
  • readAllAnimatedProperties (gsapRuntimeReaders.ts:30) CRAP 44.1, cyclomatic 39 — bumped by the VISUAL_BASELINE walk.

The FileTree.tsx:18 / FileTreeNodes.tsx:22 dead-type findings from round-2 are still there — same answer as before, pre-existing from #1313's split. If Fallow's policy is delta-from-base, the three above are what's holding the audit red on round-3.

What I verified this round

  • Diff 7086ff0a..ce84aba4 — 1 commit, 7 files, +155/-35.
  • Algebra of bakeVisibilityOnDelete branches against GsapAnimation shape (keyframes reverse-scan, to/set direct read, fromToproperties.opacity).
  • Cross-tween guard walks against the __timelines structure assumed from gsapRuntimeBridge / gsapSoftReload — same getChildren(true) + targets().includes(el) pattern as the existing code.
  • Number.isFinite fix on data-hf-drag-gsap-base-x/y matches the symmetric falsy-zero pattern from round-2's getBoundingClientRect work.
  • Sign-inversion fix in useGestureRecording.ts — algebra: old dx = (Δpointer - clickOffset)/scale, new dx = Δpointer/scale with basePosition += clickOffset/iframeScale. Semantically: instead of subtracting click-offset every frame, apply it once to basePosition at pointer-down. Correct, assuming iframeScale is captured before captureStart fires (didn't trace).
  • Test job + all 8 regression shards + Windows green at ce84aba4. Only Fallow audit red.

What I didn't verify

  • Empirical bugbash-combined exercise of fix #24's pointer-tracks-center behavior.
  • Whether from-method animations actually reach the delete path in practice (concern #1) — depends on the studio's commit pipeline I haven't traced.
  • Per-frame call frequency of readAllAnimatedProperties (nit on the O(N²) walk).
  • Whether gsapSoftReload.ts's new allTargets array has a downstream consumer (concern in Questions).

Solid round overall — the cross-tween baseline and the strip/bake gating both close real holes I'd have flagged. The from-method gap and the keyframes-array gap in the cross-tween guard are subtle; worth at least an ack so they don't get lost.

Review by Rames D Jusso

@vanceingalls

Copy link
Copy Markdown
Collaborator

Addendum on Fallow (from Rames D Jusso's note): The new Fallow CRITICAL findings are introduced by this PR, not pre-existing. bakeVisibilityOnDelete at CRAP 71 and the mutation-handler switch at cyclomatic 52 / CRAP 636 are both well above threshold — the switch is worse than addKeyframeToScript in round 1.

Consistent with my round-1 stance, these are blockers. The mutation handler complexity also makes the non-atomic write (blocker 2) harder to reason about and fix — they're related.

Adding: decompose bakeVisibilityOnDelete and the files.ts mutation switch before merge. Existing blockers (autoKf corruption + non-atomic write) still stand.

— Vai

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed at 7d36b0e0 (delta from ce84aba4 — 6 files, +140/-22). This round addresses Vai's two blockers, three of their importants, and my round-3 concern #1. Plus a new bug fix with serious test coverage. Most items cleared cleanly; one observation on Vai's #6, plus a couple of trade-off questions on the new code.

Cleared from round-3

Vai blocker #1autoKeyframe=OFF corruption (PropertyPanel.tsx:162-179). Went with Vai's option 2: dropped the autoKf gate so the GSAP-commit / add-keyframe paths fire whenever a GSAP animation exists, and added if (hasGsap) return; as the final guard before the CSS-path fallback. The fall-through to onSetManualOffset on a GSAP element is now structurally impossible. Clean.

Vai blocker #2 — non-atomic writeFileSync bootstrap (files.ts:780). The premature writeFileSync(res.absPath, html, "utf-8") is gone — the bootstrap is now only added to the in-memory string and parsed via extractGsapScriptBlock(html). Downstream validation/parse can throw without leaving a half-baked bootstrap on disk.

Vai important + my round-3 #1from() bake (files.ts:259-261). if (anim.method === "from") return; at the top of bakeVisibilityOnDelete skips the from-method entirely. Since from() ends at static CSS opacity (not the keyframe's value), this is the correct call. Closes my concern #1 too.

Vai important #4useLintModal projectId reset (useLintModal.ts:60-64). prevProjectIdRef tracks the last project; on switch, autoLintRanRef.current = false. Project-switch now re-arms auto-lint. ✓

Vai important #5PropertyPanel 60fps re-render. Switched from requestAnimationFrame to setTimeout(100). Solves the perf cliff (see trade-off below).

NEW — auto-update endpoint adjacency (gsapParser.ts:1405-1430). Real bug: previously adding a keyframe at 74% would clobber the _auto 100% even with a 75% sitting between them. Rewrite walks allPcts.sort(), finds true leftNeighbor + rightNeighbor, only updates _auto 0%/100% when they're the actual immediate neighbors. Symmetric handling of both endpoints (previously only 100% had auto-update). +89 lines of gsapParser.test.ts covering: adjacent-to-0/100 with 2 kfs, non-adjacent skip with 5 kfs, edge-case mid-range adds, non-auto 100% untouched. Solid.

NEW — CSS offset reset on gesture recording (useGestureRecording.ts:279-282, 406-412). Resets --hf-studio-offset-x/y to 0 at recording start, restores on stop. Prevents pre-existing path-offsets from leaking into the recorded values.

Observation on Vai important #6 (rafPausedRef)

I think this one was already covered at ce84aba4, so no action needed here. domEditOverlayStartGesture.ts (not in this round's diff) sets opts.rafPausedRef.current = true at three sites: line 82 (group-gesture start), line 124 (drag-specific branch), and line 158 — the common path after the if (kind === "drag") {} else {} block, which fires for resize, rotate, and any gesture kind that survives the early returns. So box-size and rotation gestures already pause the overlay RAF via the common path. Worth confirming with Vai that they read it the same way; if so, this can drop off the round-4 list.

Concerns

  1. PropertyPanel 100ms throttle is a step too far (PropertyPanel.tsx:99-105). 60fps via RAF → 10Hz via setTimeout(100) is a 6× reduction. For property values changing fast during preview (a sliding x from 0→500 over 0.5s), the panel display will visibly stutter at ~10Hz. 33ms (30fps) or 50ms (20fps) buys most of the perf back while keeping the UI fluid. The bug Vai called out was "perf cliff on 60fps re-render of the full panel" — solving that doesn't require dropping all the way to 10Hz. Worth tuning unless you have data showing 10Hz is fine.

  2. PropertyPanel.commitManualOffset final if (hasGsap) return; is a silent no-op (PropertyPanel.tsx:175). When the user types a value into the property panel on a GSAP element and none of the prior branches fire (e.g. onCommitAnimatedProperty=undefined && !gsapKeyframes), the input is silently dropped. Vai's option 1 suggested "surface a toast" — that didn't land. Edge case but worth a toast or at least a console.warn so the user sees something happened.

  3. useGestureRecording.ts CSS offset restore can leak (useGestureRecording.ts:406-412). The restore in stopRecording runs unconditionally if cssVarOffset.x || .y is set. But if recording is interrupted (window blur, unmount, error in tick), the restore branch may not fire — the element ends up with --hf-studio-offset-x: 0px permanently. Worth wrapping the restore in try/finally or a useEffect cleanup tied to the recording state.

  4. Cross-tween guard still misses keyframes-array form (gsapRuntimeReaders.ts:80-87, carryover from my round-3). Not addressed this round. Tweens of the form gsap.to(el, { keyframes: [{ opacity: 0 }, { opacity: 1 }] }) register keyframes as a top-level var key, not opacity — so VISUAL_BASELINE's "skip props animated by other tweens" doesn't apply. Same fix as before: one-level descent into vars.keyframes keys. Flagging again so it doesn't get lost; not raising to blocker.

Nits

  • useGestureRecording.ts:279-282if (base.cssOffX || base.cssOffY) uses the same falsy-zero pattern Number.isFinite was meant to replace elsewhere this PR. CSS offsets of exactly 0 px would skip the reset (which is benign since they're already 0), but the inconsistency stands out.
  • Auto-update endpoint adjacency tests — strong coverage of the new logic, but no test covers the zero-other-keyframes case (adding 50% to a script with only 0% + 100%). Math says: both endpoints update; would be nice to lock that down.
  • Vai's nits #9 (regex), #10 (Document exposure), #11 (useLayerDrag try/catch) — not addressed. Independently small; fine to land outside this PR if you want.

Fallow audit

Still failing — running new CI now (shard-2/4/5/6/7/8 still pending at review time, but Fallow audit already failed at 39s). The new findings vs round-3 should mostly cancel — bakeVisibilityOnDelete got smaller (the from early-return drops a branch), and the mutation handler got smaller (writeFileSync removed). The FileTree*.tsx dead-types and the carry-over health findings (addKeyframeToScript, gigantic mutation switch) remain. Same waiver argument as last round: pre-existing on this PR's base.

What I verified this round

  • Diff ce84aba4..7d36b0e0 — 1 commit (force-pushed, same subject), 6 files, +140/-22.
  • Auto-endpoint adjacency logic walked by hand for: 0-other-kfs (both endpoints update), 1-non-endpoint-between (skip), boundary percentage==0 / percentage==100 (skipped by outer guard), non-auto endpoint (skipped by hasAuto check).
  • PropertyPanel.commitManualOffset control flow against Vai's option 2 — fall-through to CSS-path is now structurally blocked.
  • bakeVisibilityOnDelete from-method early-return matches the removeAllKeyframesFromScript collapse-to-first-kf behavior — consistent.
  • useLintModal projectId tracking — old projectId stored on first run, compared on subsequent runs, reset works for both forward and back navigation between projects.
  • rafPausedRef on domEditOverlayStartGesture.ts lines 82/124/158 — already covers all gesture kinds via line 158 (not in this round's diff).
  • Test job + Lint + Format + Typecheck green at 7d36b0e0. Fallow still red.

What I didn't verify

  • The 100ms throttle empirically — whether it actually feels jittery in PropertyPanel during playback (concern #1).
  • Whether commitManualOffset's silent return (concern #2) is reachable in practice or guarded upstream.
  • useGestureRecording.ts recording-interruption paths (window blur, unmount) for the CSS-offset-restore leak (concern #3).
  • Keyframes-array form coverage in __timelines traversal (concern #4 — round-3 carryover).

Substantive round — Vai's biggest items closed, the auto-endpoint fix is a quality bug catch with proper test coverage, and the from-method bake handling closes the loop on my concern. The 100ms throttle and the silent return are the two things worth a second pass before stamp. Pinging Vai for their take.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 4

Round-3 blocker status:

✓ Blocker 1 resolved — autoKf=OFF no longer corrupts GSAP keyframes. commitManualOffset now returns early when hasGsap before the CSS path-offset write. The silent no-op without a toast is a UX nit, not a correctness issue.

✓ Blocker 2 resolved — bootstrap write is now atomic. Bootstrap applied to in-memory html string; writeFileSync only runs after all parser/mutation steps succeed. File on disk is untouched if anything throws.

✗ Blocker 3 still open — Fallow CRITICALs not decomposed. The Fallow report on this head shows files.ts:753 at CRAP 636.1 / cyclomatic 52 (the mutation switch) and files.ts:532 at CRAP 160 / cyclomatic 25 — both in files this PR modified, both well above threshold. My addendum asked for real decomposition. Adding fallow-ignore-next-line instead of extracting each case body into a helper kicks the problem to the next person reading this code.


New blocker — commitManualSize has no hasGsap guard

PropertyPanel.tsx lines 184–200: width/height edits unconditionally call onSetManualSize(...). On a GSAP-animated element where width/height are in the tween's propKeys, this writes to the CSS-side --hf-studio-* vars while GSAP keyframes are in play — the same baked-mismatch corruption blocker 1 just fixed for x/y. The AutoKeyframeToggle UI ships in this same PR so the path to this corruption is visible on release. Same fix: if (hasGsap && !onCommitAnimatedProperty) return; and route through onCommitAnimatedProperty when present.


Round-3 importants resolved:

  • bakeVisibilityOnDelete from() early-return added (line 260–262)
  • useLintModal autoLintRanRef resets on projectId change via prevProjectIdRef
  • PropertyPanel RAF → 100ms setTimeout throttle. 10Hz is fine for a property panel display.
  • rafPausedRef set for all gesture kinds via line 158 (Rames D Jusso correct — I had the wrong read)

Important — readAllAnimatedProperties swallows __timelines failures silently

The try { ... } catch {} wrapping the __timelines.getChildren() walk (lines 71–105 of the patch) means if the iframe __timelines access fails (cross-origin, detached frame, GSAP not booted yet), otherTweenProps stays empty and the visual-baseline loop reads every animated property regardless of whether another timeline owns it. That's the cross-tween clobber the guard exists to prevent. At minimum: console.warn in the catch so the regression isn't invisible.


Fallow: bakeVisibilityOnDelete dropped from CRITICAL to major (CRAP 79.4) — the from() fix pushed it higher but it's no longer the top concern. files.ts:753 CRAP 636 and :532 CRAP 160 remain open from files this PR touched.

— Vai

continue;
}

writeFileSync(finalPath, buffer);

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-reviewed at 17296f23 (delta from 7d36b0e0 — 4 files, +541/-442). All three of my round-4 trade-off items addressed plus the big-ticket mutation-handler decomposition I asked about in round-3. Two concerns on the new COMPUTED_BASELINE tier worth a second look.

Cleared from round-4

Throttle re-tuned (PropertyPanel.tsx:104). 100ms → 33ms (~30Hz). My recommendation was 33-50ms; you picked the upper-rate end. Fluid display + ~2× re-render reduction from 60fps. Good.

Silent no-op gets a toast (PropertyPanel.tsx:175, 194). showToast?.("Cannot edit position — animation callbacks not available") on both commitManualOffset AND commitManualSize. Also factored hasGsapAnimation to a single derived variable. Vai's option 1 ("surface a toast") landed.

Mutation handler decomposition — round-3 concern #3 (CRAP 636/cyclomatic 52 on the route's switch). Extracted to executeGsapMutation (files.ts:415-614, ~200 lines) + typed GsapMutationRequest discriminated union + requireAnimation / requireFromToAnimation helpers. The route handler is now ~50 lines instead of ~400. Same logic, dramatically lower complexity. Cleanest decomposition I'd hoped for.

Cross-tween guard log on failure (gsapRuntimeReaders.ts:117-122). catch (e) { console.warn(...) } instead of silent. Vai's overall ask for "no swallowed errors" propagates here too.

GSAP_CONFIG_KEYS exhaustive allowlist (gsapRuntimeReaders.ts:27-46). Expanded from 7 to 18 keys, now covering onStart/onRepeat/repeat/yoyo/repeatDelay/paused/immediateRender/lazy/overwrite/keyframes/parent. Closes the round-3 nit about list completeness.

bakeVisibilityOnDelete simplification (files.ts:268-272). Collapsed the to/set/fromTo branches into a single else if ("opacity" in anim.properties) since from is short-circuited above. Same behavior, less branching.

useLintModal.ts cleanupfindingsByElement + findingsByFile deduped into shared groupFindings(keyFn) callback. No behavior change, structural cleanup only.

New in round-5

UNIVERSAL_BASELINE expanded (gsapRuntimeReaders.ts:128-143). 5 props → 13. Added scaleZ, rotationX, rotationY, skewX, skewY, z, xPercent, yPercent, transformPerspective. All have well-defined CSS-independent defaults (1 or 0). Safe to compare against hardcoded values.

COMPUTED_BASELINE tier (gsapRuntimeReaders.ts:149-201). 24 properties whose "default" depends on the stylesheet (borderRadius, boxShadow, filter family, letterSpacing, fontSize, outline, stroke, backgroundPosition). For these, compare GSAP's runtime value against getComputedStyle(el) and only capture if they differ. Sensible architectural split. (See concerns below — couple of subtleties on the filter-family props.)

Concerns on the new COMPUTED_BASELINE

  1. Filter-family props (blur/brightness/contrast/etc.) have no direct CSS computed equivalent. They're values inside filter: blur(5px) brightness(0.8) etc., not standalone CSS properties. The current code does:

    const raw = computedStyle.getPropertyValue(prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`));
    cssVal = parseFloat(raw);  // → NaN for unknown CSS properties
    if (Number.isFinite(cssVal) && ...) continue;
    result[prop] = Math.round(gsapVal * 1000) / 1000;

    For filter props, cssVal is always NaNNumber.isFinite(cssVal) === false → no continue → result writes gsapVal unconditionally. So every element with a finite gsap.getProperty(el, "blur") value will get blur: 0 (or whatever GSAP returns) written to the baseline, regardless of whether it's been touched. That's noise in the captured baseline for any element with no filter set.

    Fix options: (a) drop the filter family from COMPUTED_BASELINE and accept they're under-captured, (b) parse the actual computedStyle.filter string for the relevant function call (filter.match(/blur\((\d+)/) etc.), or (c) check if Math.round(gsapVal * 1000) differs from the prop's well-known default (0 for blur/contrast/saturate/etc., 1 for brightness/opacity, 100 for sepia/grayscale/invert — these aren't all 0 or 1).

  2. GSAP's brightness default is 1, not 0. Adjacent issue: if you do decide to keep filter props in COMPUTED_BASELINE, the "is it changed" check needs to know each filter's default. blur: 0, brightness: 1, contrast: 1, saturate: 1, hueRotate: 0, grayscale: 0, sepia: 0, invert: 0 — not a uniform default. A FILTER_DEFAULTS map would handle this cleanly; alternatively, filter props belong in their own tier with explicit defaults rather than computed comparison.

  3. getComputedStyle call on every readAllAnimatedProperties invocation (gsapRuntimeReaders.ts:181-184). It's outside the inner loop and called once — that's the right factoring. But it does trigger layout if there's pending DOM mutation. If readAllAnimatedProperties is called per-frame during drag preview, the layout cost compounds. Probably fine in practice; flagging because the previous version had no getComputedStyle call at all.

Open from earlier rounds — for the record

  • Keyframes-array cross-tween form (round-3 #2, round-4 #4). gsap.to(el, { keyframes: [{opacity:0}, {opacity:1}] }) still escapes the otherTweenProps capture — now via the keyframes config-key skip rather than the prior arbitrary-key skip, but same outcome. Worth a one-level descent into vars.keyframes to union the inner keys; not gating from my side.
  • CSS offset restore leak in useGestureRecording (round-4 #3) — interrupted-recording paths still don't restore. Wrap in try/finally or unmount cleanup.

Property list completeness — open question

  • Is the COMPUTED_BASELINE set complete for HF's use case? Notable absences: width/height/padding/margin / top/left/right/bottom. GSAP can animate these too. If HF intentionally restricts to filter/typography/border/stroke/background, a one-line // Excluded: width/height/etc — captured via [other path] would document the choice.

What I verified this round

  • Diff 7d36b0e0..17296f23 — 1 commit, 4 files, +541/-442.
  • executeGsapMutation decomposition is a pure extract — every case maps 1:1 to the prior route-body switch, all requireAnimation calls + early-error returns preserved. respond callback shim handles status codes.
  • bakeVisibilityOnDelete collapse-3-into-1: the to/set/fromTo branches all read anim.properties.opacity the same way; collapsing is correct after the from early-return shields it.
  • UNIVERSAL_BASELINE additions: every prop has a well-defined GSAP/CSS default that doesn't depend on stylesheet. ✓
  • GSAP_CONFIG_KEYS against GSAP docs — covers all the common Tween/Timeline vars I checked. parent is unusual but probably the internal _parent ref leaking into vars; safe to skip.
  • PropertyPanel commitManualSize mirrors commitManualOffset structure exactly. Consistent.
  • useLintModal refactor — groupFindings(keyFn) is a pure abstraction over the two prior loops. No behavior change.

What I didn't verify

  • Empirical filter-family baseline output (concern #1) — would need to query readAllAnimatedProperties on an element with no filter set and confirm blur/brightness/etc. appear with bogus values.
  • getComputedStyle per-frame perf impact (concern #3) — depends on call frequency from the drag-preview path.
  • Whether parent actually appears in GSAP vars in practice or is just defensive.

Strong round. The mutation-handler decomposition closes my biggest structural ask from round-3 and is genuinely cleaner than what was there. PropertyPanel UX trade-offs resolved to my satisfaction. The COMPUTED_BASELINE tier is a good idea, but the filter-family handling needs another pass — either drop them from this set or give them explicit defaults. Not gating from my side; flagging for Vai to weigh in if the filter-property capture path matters for them.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 5

Round-4 blocker status:

✓ Blocker 1 materially improved — accepting wrapper extraction, noting remainder

executeGsapMutation() is now a proper module-level function, the route handler dropped from ~400 lines to ~50, processUploadedFiles() split out the upload path, and the fallow-ignore-next-line is gone. Real improvement. The 22-case switch inside executeGsapMutation still reports CRAP 404.1 / cyclomatic 41 (CRITICAL threshold is 30), which is what I asked to decompose. After 5 rounds with all safety issues resolved, I'm not holding further on this — but I'd recommend opening a follow-up issue to finish the per-case-body extraction to a dispatch map before this module grows again.

✓ Blocker 2 resolved — commitManualSize now guarded

Routes through onCommitAnimatedProperty when hasGsapAnimation and falls back to a toast before reaching onSetManualSize. The CSS-side write on a GSAP-animated element is structurally unreachable.

✓ Important resolved — try/catch {} now has console.warn

__timelines.getChildren() catch logs a warning so baseline-capture failures aren't invisible.


Important — COMPUTED_BASELINE filter capture produces unconditional noise

Rames D Jusso's note is correct and worth calling out explicitly: blur/brightness/contrast/saturate/etc. have no direct CSS computed equivalent, so cssVal resolves to NaN for every element. The isFinite(cssVal) guard returns false → unconditional capture path fires for every element with a GSAP filter reading. That's baseline noise on every animated filter, not just edge cases. Fix options per Rames's note: drop filter properties from COMPUTED_BASELINE, or add an explicit per-property defaults map (brightness/contrast/saturate → 1, blur/hueRotate/grayscale/sepia/invert → 0).

Nit — second silent try/catch {} in gsapRuntimeReaders.ts

The getComputedStyle call (line ~130) also has an empty catch. Contained failure mode (cssVal stays NaN, fallback handles it) but inconsistent with the console.warn just added above. Drive-by console.warn would keep it coherent.


bakeVisibilityOnDelete down to CRAP 49.5 (minor) — no longer a concern. CI regression shards still running at review time.

— Vai

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at HEAD 17296f2 on James's behalf — both reviewers cleared after 5 rounds.

  • Vai APPROVED: both R4 safety blockers (autoKf fall-through, commitManualSize GSAP guard) cleared, executeGsapMutation extraction landed cleanly (route handler 400→50 lines, fallow-ignore gone).
  • Rames D Jusso LGTM: all R4 trade-offs addressed (throttle 100→33ms, toast on silent no-op, mutation-handler decomp), R3 from() concern closed.

One non-gating item both reviewers flagged that should land before merge or as an immediate follow-up: COMPUTED_BASELINE filter captureblur/brightness/contrast/etc. have no CSS computed equivalent so cssVal=NaN fires unconditional baseline capture on every element with a GSAP filter reading. Either drop filters from that set or add a per-property defaults map (brightness/contrast/saturate→1, others→0). Not gating, but worth a quick fix or a tracked follow-up before this module grows again.

Vai also recommended a follow-up issue for per-case mutation-dispatch extraction (inner switch still CRAP 404 / cyclomatic 41).

@miguel Angel Simon Sierra (U09D5NBJA0Y) all yours.

…tion, gesture recording

- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
  stripStudioEdits flag on the delete mutation type so they only fire on
  user-initiated deletes, not on internal delete-then-recreate drags.

- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
  elements with CSS opacity:0 stay visible after collapsing keyframes.

- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
  precision for visual properties (opacity, scale, rotation) instead of
  Math.round which corrupted mid-fade values to 0.

- Guard VISUAL_BASELINE against cross-tween contamination by querying
  __timelines for properties animated by other tweens on the same element.

- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
  containing opacity, guard against relative values (+=/-=/*=), and add
  Number.isFinite check.

- Fix falsy-zero doubling in drag commit: replace || fallback with
  Number.isFinite so a base GSAP position of 0 is correctly preserved.

- Fix gesture recording sign inversion: remove pointerElementOffset
  subtraction from dx/dy formula and instead apply it once to basePosition
  so the element center tracks the pointer.

- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).

- Strip all diagnostic logs from production code.
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.

5 participants