Skip to content

fix(core): warn on GSAP boundary exits without hard kill#474

Merged
miguel-heygen merged 2 commits intomainfrom
fix/scene-boundary-hard-kill
Apr 24, 2026
Merged

fix(core): warn on GSAP boundary exits without hard kill#474
miguel-heygen merged 2 commits intomainfrom
fix/scene-boundary-hard-kill

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented Apr 24, 2026

Fixes #473.

Problem

The HyperFrames skill now tells agents to add deterministic tl.set() hard-kills after elements fade out at beat / scene boundaries, but the linter did not enforce that rule outside the narrow caption-specific check.

That made the rule easy for sub-agents to ignore: an element could fade to opacity: 0 exactly as the next clip starts, with no explicit hidden-state set at the boundary. During non-linear seeking or frame capture, that leaves the final visibility state dependent on tween interpolation instead of an authored deterministic kill.

What this fixes

This PR adds a generalized GSAP lint warning for scene-boundary exits:

  • detects GSAP to / fromTo exit tweens that end at or near a clip data-start boundary
  • treats opacity: 0, autoAlpha: 0, visibility: "hidden", and display: "none" as hidden exit states
  • requires a matching same-selector tl.set(...) hidden state at the same boundary
  • scopes clip-boundary matching to the timeline's registered composition so sub-composition exits do not match unrelated root boundaries
  • reports gsap_exit_missing_hard_kill with the selector, boundary time, source snippet, and a fix hint that preserves the authored hidden property when possible
  • keeps valid compositions quiet when the boundary hard-kill already exists

Why

Clip boundaries are the exact points where rendered frames are most sensitive to stale DOM state. A fade-out tween describes a transition, but it does not give the linter or the authoring model an explicit deterministic state to land on when seeking around the boundary.

The existing caption rule proved the class of bug was worth catching, but it only applied to caption-loop patterns. The issue in #473 is broader: any element inside a timed composition can exit at a scene boundary and need the same deterministic cleanup.

Root cause

The GSAP lint rule parser already calculated tween windows and clip metadata existed in the lint context, but no rule connected those two facts:

  • clip data-start values were not used as scene-boundary checkpoints for GSAP exits
  • parsed GSAP windows tracked property names, but not enough property values to tell whether a tween ended in a hidden state
  • hard-kill detection only existed as a caption-specific regex, so normal scene elements were missed

This PR extends the existing GSAP window metadata with parsed property values, then checks hidden-state exits against same-composition clip start boundaries and same-selector tl.set calls.

Verification

Local checks

  • bun run --filter @hyperframes/core test src/lint/rules/gsap.test.ts
  • bunx oxlint packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts
  • bunx oxfmt --check packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts
  • bun run --filter @hyperframes/core typecheck
  • bun run --filter @hyperframes/core test
  • bun run --filter @hyperframes/core build

CLI verification

Verified against local fixtures where #headline exits at the next clip boundary without a hard kill:

  • opacity fixture reports gsap_exit_missing_hard_kill for #headline at 3.00s
  • autoAlpha fixture reports the same warning and suggests tl.set("#headline", { autoAlpha: 0 }, 3.00)
  • sub-composition regression test confirms a sub timeline exit no longer matches an unrelated root composition boundary

Browser verification

Verified the Studio lint flow with agent-browser against the autoAlpha fixture:

  • opened Studio at http://127.0.0.1:43174/#project/issue-473-autoalpha
  • clicked the real Lint button
  • confirmed the lint modal shows the new warning and the property-preserving { autoAlpha: 0 } fix hint
  • saved local proof artifacts under qa-artifacts/issue-473/

Notes

  • the tmp/issue-473-* fixtures and qa-artifacts/issue-473 browser proof are local-only and are not part of this PR
  • this intentionally stays heuristic-based: it warns near clip start boundaries instead of trying to build a full GSAP execution model
  • expression-valued GSAP props and deeper regex-parser limitations remain outside this PR's scope; those are parser-hardening work, not required for the bug in linter: flag missing tl.set() hard-kill on scene-boundary exits #473

@miguel-heygen miguel-heygen marked this pull request as ready for review April 24, 2026 15:36
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Verdict: approve — real bug, reproduced locally, fix is correctly scoped

Local verification

Built three fixtures from scratch against da4ce98d and ran the actual CLI (bun run --filter @hyperframes/cli dev lint <fixture> --json):

  1. Buggy — fade-out ending exactly at the 3.00s clip start boundary, no hard kill:

    {
      "code": "gsap_exit_missing_hard_kill",
      "severity": "warning",
      "selector": "#headline",
      "message": "GSAP exit on \"#headline\" ends at the 3.00s clip start boundary without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
      "fixHint": "Add `tl.set(\"#headline\", { opacity: 0 }, 3.00)` after the exit tween, or use autoAlpha/visibility if that is the authored hidden state."
    }
  2. Fixed — same fade-out but with tl.set("#headline", { opacity: 0, visibility: "hidden" }, 3) appended: warningCount: 0, rule correctly silences.

  3. Edge — buggy fixture WITHOUT class="clip" on the scene containers: gsap_exit_missing_hard_kill doesn't fire (because collectClipStartBoundaries requires class="clip"), BUT the existing timed_element_missing_clip_class warning fires on both scene divs — so authors are already funneled toward the canonical class="clip" shape by a separate rule. The two rules compose correctly; no coverage gap in practice.

Also ran:

  • bun run --filter @hyperframes/core test src/lint/rules/gsap.test.ts33/33 pass, including the two new tests.
  • bun run --filter @hyperframes/core test527/527 pass.
  • bun run --filter @hyperframes/core typecheck — clean.

Staff review — architecture + correctness

The PR does the minimum plumbing to extend the existing GsapWindow parser with a new piece of metadata (propertyValues) and then composes four small pure predicates (isHiddenGsapState, isSceneBoundaryExit, isHardKillSet, findMatchingSceneBoundary) into a single new rule. No new abstractions, no reshuffling of gsap.ts's structure. The rule shares the same gsapWindows pass as overlapping_gsap_tweens, so the detection cost is negligible.

The semantics I verified line up with the real failure mode described in #473:

  • Why the tween-position check is right: win.end = position + effectiveDuration for to/fromTo, so a tl.to("#headline", { opacity: 0, duration: 0.3 }, 2.7) has end = 3.0. SCENE_BOUNDARY_EPSILON_SECONDS = 0.05 means a boundary at exactly 3.0s matches, and a boundary at 2.97s would also match (within 1 frame at 30fps). That's the correct tolerance for authored times — tight enough not to catch mid-clip fades that intentionally end before a boundary, loose enough to absorb rounding noise.
  • Why excluding from / set in isSceneBoundaryExit is right: from interpolates FROM a state to the current one, so it ends at the target's original state rather than at the author's written hidden state — no determinism concern at the boundary. set has effectiveDuration = 0, which win.end <= win.position already rejects, but the explicit method check makes intent obvious.
  • Hard-kill same-selector match: isHardKillSet checks both targetSelector match AND isHiddenGsapState on the tl.set's propertyValues. That means a fade-out via opacity: 0 paired with a hard-kill via visibility: "hidden" correctly satisfies the rule — both are "hidden" states by the runtime's logic, so either side of the pair works. Right tolerance.

Non-blocking observations

These are all consistent with the PR's stated "heuristic-based" design goal — none are worth holding the merge on, but worth putting on the cleanup list for the next pass:

  1. Sub-composition scope vs. clip boundaries. clipStartBoundaries is collected once over ALL .clip tags in the document, then matched against tweens from every registered timeline. A tween on a sub-composition whose fade-out happens to end at a root-level clip's data-start (same numeric coincidence) could false-positive without an actual authoring bug at that scope. Rare in practice because the same-selector hard-kill check narrows it further, but scoping boundaries per-composition would tighten this.

  2. Expression-valued props bypass detection. parseLooseObjectLiteral handles literal scalars ("str" / 'str' / numbers / true / false) but can't evaluate opacity: hidden ? 0 : 1 or opacity: gsap.utils.random(0, 0.1). Those will silently skip the rule. Fine for heuristic-by-design; flag if you ever want to harden.

  3. Fix hint is always { opacity: 0 }. If the author's exit tween used autoAlpha: 0 or visibility: "hidden", the suggested kill in { opacity: 0 } loses their intent. The trailing "or use autoAlpha/visibility if that is the authored hidden state" in the hint covers it, but a targeted hint that echoes the detected property would be a small UX win later.

  4. The regex-based method pattern in extractGsapWindows. Unchanged by this PR, but worth a mental note: the method-match regex drives both parseGsapScript's animation index and the property-value extraction. The existing comment on line 63 (if (!/^\s*["']/.test(args)) continue;) warns that non-selector first-args drift the index — the same caveat applies to propertyValues being attributed to the wrong animation if the regex ever misaligns. No action needed now; just context for anyone touching the parser.

CI state

All green on da4ce98d:

  • Format / Typecheck / Lint / Test / Build / Test: runtime contract / Smoke: global install / Tests on windows-latest / Render on windows-latest / CodeQL / player-perf / Perf (fps/scrub/drift/parity/load) / regression-shards (all shards)

Ship it.

Review by Rames Jusso

@miguel-heygen miguel-heygen merged commit fbec7bb into main Apr 24, 2026
40 of 49 checks passed
Copy link
Copy Markdown
Collaborator Author

Merge activity

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.

linter: flag missing tl.set() hard-kill on scene-boundary exits

2 participants