feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting#1345
Conversation
44522e9 to
dbc7da2
Compare
c1b0b24 to
8024563
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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_PATTERNmatches (script registers a timeline onwindow.__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. stripJsCommentsapplied first so commented-outtl.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."fixHintis 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_RErequires#or.as the first char — sotl.to("[data-x]", {...})is a valid GSAP target that won't trigger this lint, even though Studio'sisElementGsapTargetedmay 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'sbun.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 establishedTIMELINE_REGISTRY_ASSIGN_PATTERN+stripJsCommentsutilities fromutils.ts. - All 5 unit tests test the rule's specific code path (no incidental coverage from upstream rules).
- The
// fallow-ignore-file code-duplicationmarkers added toadapters.test.tsandgsap.test.tsare housekeeping for cross-file test-fixture overlap — unrelated to the lint rule itself.
What I didn't verify
- Ran
bun test packages/corelocally — trust the unit test signal from the file. - The runtime-side
isElementGsapTargetedmatcher in #1346 — assumed to be selector-pattern-compatible with this rule's matcher.
— Review by Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
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.
…patches, mutate, apply-patches)
…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>
…Phase 3a complete
…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>
…SAP element targeting
8024563 to
bd9e1ae
Compare
6f3c02d to
a2cd542
Compare
The base branch was changed.

Summary
Adds a new
gsap_studio_edit_blockedlint 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
gsap.to,gsap.from,gsap.fromTo,gsap.set, orgsap.timeline()tweensinline-styleedits that overlap properties GSAP animates (position, transform, opacity, etc.)warn(noterror) — the edit may be intentional as a baselineTest plan
bun test packages/core— lint rule tests passgsap.to("#el", {x: 100})+ drag edit on#el→ lint warning appears🤖 Generated with Claude Code