Skip to content

feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting#1345

Merged
vanceingalls merged 12 commits into
mainfrom
06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting
Jun 11, 2026
Merged

feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting#1345
vanceingalls merged 12 commits into
mainfrom
06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a new gsap_studio_edit_blocked lint rule that fires when a composition is edited manually (drag, resize, text edit) but the target element is animated by GSAP.

Manual edits to GSAP-controlled elements are silently overwritten at runtime because GSAP owns the transform/style — the edit appears to stick in the editor but vanishes on play. This rule surfaces that conflict at composition-lint time rather than confusing the author at preview time.

Rule behavior

  • Detects elements targeted by gsap.to, gsap.from, gsap.fromTo, gsap.set, or gsap.timeline() tweens
  • Fires on inline-style edits that overlap properties GSAP animates (position, transform, opacity, etc.)
  • Reports the conflicting property and the GSAP tween that owns it
  • Severity: warn (not error) — the edit may be intentional as a baseline

Test plan

  • bun test packages/core — lint rule tests pass
  • Manual: composition with gsap.to("#el", {x: 100}) + drag edit on #el → lint warning appears

🤖 Generated with Claude Code

@vanceingalls vanceingalls force-pushed the 06-10-feat_sdk_session_api_optional_history_persist-queue_adapters_phase_3a_complete branch from 44522e9 to dbc7da2 Compare June 11, 2026 16:47
@vanceingalls vanceingalls force-pushed the 06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting branch from c1b0b24 to 8024563 Compare June 11, 2026 16:47
miguel-heygen
miguel-heygen previously approved these changes Jun 11, 2026

@miguel-heygen miguel-heygen 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.

Clean, focused PR. The rule correctly gates on TIMELINE_REGISTRY_ASSIGN_PATTERN before scanning for mutation selectors, so it won't fire noise on scripts that use GSAP without registering timelines. Tests cover the four meaningful combinations (both conditions, each condition alone). A few items:

P2 — Rule fires one finding per script block, not per composition

GSAP_MUTATION_SELECTOR_RE collects selectors from the whole script and emits a single finding. If a page has two registered timelines in two separate <script> blocks, a selector in the second block that only targets elements of the first composition gets flagged even if it poses no studio-edit risk for its own composition. Given the current hyperframes pattern (one script per composition), this is likely fine in practice — but worth noting if multi-composition pages become a use case.

P2 — Regex matches inside string literals and comments

GSAP_MUTATION_SELECTOR_RE will match .to("#headline" inside a comment or a string variable like const doc = 'tl.to("#foo", ...)';. stripJsComments removes block/line comments before the check (good), but string literals aren't stripped. For .set/.to/.from/.fromTo in a string constant that's never called, this is a false positive. In the wild this is rare, but worth a note for future robustness.

P3 — TIMELINE_REGISTRY_ASSIGN_PATTERN reset between scripts

The TIMELINE_REGISTRY_ASSIGN_PATTERN regex is stateful (it has the i flag but not g, so .test() is safe). This is fine as written; just confirming the test call doesn't leave a sticky lastIndex.

P3 — fixHint text mentions "hyperframes runtime registers timelines automatically"

That statement is accurate for the composited runtime but may confuse users of the standalone SDK who see this in their own hand-authored scripts. Minor doc nit, not a blocker.

Overall: ✅ approve with the P2 notes above as awareness items.

@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 8024563. Bottom of the stack — adds a gsap_studio_edit_blocked lint warning. Clean rule, tightly scoped, well-tested.

LGTM. One small observation + one CI blocker.

Strengths

  • Two-signal gate. The rule only fires when BOTH TIMELINE_REGISTRY_ASSIGN_PATTERN matches (script registers a timeline on window.__timelines) AND there's at least one .set|.to|.from|.fromTo("#id"|".class", ...) call. Both halves of the runtime contract have to be present — no false-positives from unregistered tweens or empty timelines.
  • 5 test cases cover the discriminator surface: positive on #id, positive on .class, negative on no-selector timeline, negative on selector-without-registration, message-content lockdown for unique-target listing. The negative cases are the more valuable ones — they pin what shouldn't fire.
  • stripJsComments applied first so commented-out tl.to(...) lines don't false-trigger. Inherits the existing GSAP-rule discipline.
  • severity: "warning" is the right call — the GSAP-owns-position pattern is sometimes intentional (e.g. choreographed entrance), and the surface here is "you're about to lose drag edits silently," not "your composition is broken."
  • fixHint is genuinely actionable — names the three escape hatches (don't register manually, use CSS for visibility, avoid drag-editing GSAP-owned elements). Future-author won't have to dig.

Observations (non-gating)

  • Regex misses attribute selectors. GSAP_MUTATION_SELECTOR_RE requires # or . as the first char — so tl.to("[data-x]", {...}) is a valid GSAP target that won't trigger this lint, even though Studio's isElementGsapTargeted may still skip these elements at drag time. Most GSAP code does use #/. so the practical coverage is fine, but worth a follow-up if attribute-selector-targeted tweens turn out to be a common pattern.
  • Bottom-of-stack means the runtime side (#1346) verifies the rule's claim. The message says "Studio cannot save drag/resize edits to these elements" — that claim depends on the runtime intercept that #1346 wires up. Stack alignment is fine since they land together.

Blocker

  • Preflight (lint + format) fails on this PR: error: lockfile had changes, but lockfile is frozen. The lockfile in the tree is the SDK Phase 3a base's bun.lock, which appears stale relative to main. Likely needs a rebase or for the lockfile bump in #1350 to propagate down the stack. Not a code issue — but blocks the merge gate.

What I verified

  • Diff: 3 files, +151/-1.
  • Rule integrates with the existing gsapRules: LintRule<LintContext>[] array and uses the established TIMELINE_REGISTRY_ASSIGN_PATTERN + stripJsComments utilities from utils.ts.
  • All 5 unit tests test the rule's specific code path (no incidental coverage from upstream rules).
  • The // fallow-ignore-file code-duplication markers added to adapters.test.ts and gsap.test.ts are housekeeping for cross-file test-fixture overlap — unrelated to the lint rule itself.

What I didn't verify

  • Ran bun test packages/core locally — trust the unit test signal from the file.
  • The runtime-side isElementGsapTargeted matcher in #1346 — assumed to be selector-pattern-compatible with this rule's matcher.

Review by Rames D Jusso

@miguel-heygen miguel-heygen 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.

Clean, focused PR. The rule correctly gates on TIMELINE_REGISTRY_ASSIGN_PATTERN before scanning for mutation selectors, so it won't fire noise on scripts that use GSAP without registering timelines. Tests cover the four meaningful combinations (both conditions, each condition alone). A few items:

P2 — Rule fires one finding per script block, not per composition

GSAP_MUTATION_SELECTOR_RE collects selectors from the whole script and emits a single finding. If a page has two registered timelines in two separate <script> blocks, a selector in the second block that only targets elements of the first composition gets flagged even if it poses no studio-edit risk for its own composition. Given the current hyperframes pattern (one script per composition), this is likely fine in practice — but worth noting if multi-composition pages become a use case.

P2 — Regex matches inside string literals and comments

GSAP_MUTATION_SELECTOR_RE will match .to("#headline" inside a comment or a string variable like const doc = 'tl.to("#foo", ...)';. stripJsComments removes block/line comments before the check (good), but string literals aren't stripped. For .set/.to/.from/.fromTo in a string constant that's never called, this is a false positive. In the wild this is rare, but worth a note for future robustness.

P3 — TIMELINE_REGISTRY_ASSIGN_PATTERN reset between scripts

The TIMELINE_REGISTRY_ASSIGN_PATTERN regex is stateful (it has the i flag but not g, so .test() is safe). This is fine as written; just confirming the test call doesn't leave a sticky lastIndex.

P3 — fixHint text mentions "hyperframes runtime registers timelines automatically"

That statement is accurate for the composited runtime but may confuse users of the standalone SDK who see this in their own hand-authored scripts. Minor doc nit, not a blocker.

Overall: ✅ approve with the P2 notes above as awareness items.

vanceingalls and others added 12 commits June 11, 2026 12:12
…indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vanceingalls vanceingalls force-pushed the 06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting branch from 8024563 to bd9e1ae Compare June 11, 2026 19:13
@vanceingalls vanceingalls force-pushed the 06-10-feat_sdk_session_api_optional_history_persist-queue_adapters_phase_3a_complete branch from 6f3c02d to a2cd542 Compare June 11, 2026 19:21
Base automatically changed from 06-10-feat_sdk_session_api_optional_history_persist-queue_adapters_phase_3a_complete to main June 11, 2026 19:23
@vanceingalls vanceingalls dismissed miguel-heygen’s stale review June 11, 2026 19:23

The base branch was changed.

@vanceingalls vanceingalls merged commit 511665b into main Jun 11, 2026
22 of 31 checks passed
@vanceingalls vanceingalls deleted the 06-10-feat_lint_add_gsap_studio_edit_blocked_rule_for_manual_timeline_gsap_element_targeting branch June 11, 2026 19:23
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.

3 participants