Full Code of janpaepke/ScrollMagic for AI

main 7ce4e3ea6078 cached
76 files
257.9 KB
67.4k tokens
167 symbols
1 requests
Download .txt
Showing preview only (277K chars total). Download the full file or copy to clipboard to get everything.
Repository: janpaepke/ScrollMagic
Branch: main
Commit: 7ce4e3ea6078
Files: 76
Total size: 257.9 KB

Directory structure:
gitextract_3ruugf_l/

├── .gitignore
├── .prettierignore
├── CHANGELOG.md
├── LICENSE.md
├── MAINTAINING.md
├── PLUGINS.md
├── README.md
├── ROADMAP.md
├── config/
│   └── banner.txt
├── docs/
│   └── diagrams/
│       ├── contain.html
│       ├── intersect.html
│       └── shared.css
├── eslint.config.mjs
├── package.json
├── prettier.config.mjs
├── rollup.config.mjs
├── scripts/
│   └── export-diagrams.mjs
├── src/
│   ├── Container.ts
│   ├── ContainerProxy.ts
│   ├── EventDispatcher.ts
│   ├── ExecutionQueue.ts
│   ├── Options.processors.ts
│   ├── Options.ts
│   ├── ScrollMagic.ts
│   ├── ScrollMagicError.ts
│   ├── ScrollMagicEvent.ts
│   ├── ViewportObserver.ts
│   ├── env.d.ts
│   ├── index.ts
│   ├── util/
│   │   ├── agnosticValues.ts
│   │   ├── getScrollContainerDimensions.ts
│   │   ├── getScrollPos.ts
│   │   ├── pickDifferencesFlat.ts
│   │   ├── processProperties.ts
│   │   ├── rafQueue.ts
│   │   ├── registerEvent.ts
│   │   ├── sanitizeProperties.ts
│   │   ├── sharedResizeObserver.ts
│   │   ├── throttleRaf.ts
│   │   ├── transformObject.ts
│   │   ├── transformers.ts
│   │   └── typeguards.ts
│   └── util.ts
├── tests/
│   ├── e2e/
│   │   ├── UNTESTED-KNOWN-BUGS.md
│   │   ├── caching.test.ts
│   │   ├── containers.test.ts
│   │   ├── destroy.test.ts
│   │   ├── dev-warnings.test.ts
│   │   ├── element-tracking.test.ts
│   │   ├── enable-disable.test.ts
│   │   ├── helpers.ts
│   │   ├── refresh.test.ts
│   │   ├── scroll-progress.test.ts
│   │   └── scroll-velocity.test.ts
│   └── unit/
│       ├── ContainerProxy.test.ts
│       ├── EventDispatcher.test.ts
│       ├── ExecutionQueue.test.ts
│       ├── Options.processors.test.ts
│       ├── ScrollMagicError.test.ts
│       ├── ScrollMagicEvent.test.ts
│       ├── agnosticValues.test.ts
│       ├── getScrollContainerDimensions.test.ts
│       ├── getScrollPos.test.ts
│       ├── pickDifferencesFlat.test.ts
│       ├── processProperties.test.ts
│       ├── rafQueue.test.ts
│       ├── registerEvent.test.ts
│       ├── sanitizeProperties.test.ts
│       ├── sharedResizeObserver.test.ts
│       ├── throttleRaf.test.ts
│       ├── transformObject.test.ts
│       ├── transformers.test.ts
│       └── typeguards.test.ts
├── tsconfig.json
├── typedoc.json
└── vitest.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
/dist
/docs/tsdoc
__screenshots__

================================================
FILE: .prettierignore
================================================
dist
.claude


================================================
FILE: CHANGELOG.md
================================================
# Changelog

## Unreleased

#### New Features

- **`{ signal: AbortSignal }` event listener option** — follows the DOM `addEventListener` pattern. Pass an `AbortController`'s signal to `on()` or `subscribe()` to remove one or many listeners with a single `abort()` call.

### 3.0.0-beta.4

#### Internal

- Codebase terminology cleanup (remove legacy "scene" naming, rename `scrollOffset` → `activeRange`).
- ContainerProxy: separate `size`/`position` getters replace combined `rect`.
- Unit test coverage expanded from 84 to 164 tests.

### 3.0.0-beta.3

#### Breaking Changes

- **Options renamed** — `scrollParent` → `container`, `triggerStart` → `containerStart`, `triggerEnd` → `containerEnd`. This groups container-related options under a shared prefix and avoids confusion with GSAP's `trigger` (which refers to the element, not the container). The `resolvedBounds` getter now returns `{ element, container }` instead of `{ element, scrollParent }`.

#### New Features

- **Multi-match selector warning** (dev mode) — when a CSS selector passed to `element` or `container` matches more than one DOM element, a warning advises creating one instance per element instead.

#### Build

- **Original sources embedded in source maps** — published `.map` files now contain the actual TypeScript source via `inlineSources`, making them useful for debugging without the `src/` directory.

### 3.0.0-beta.2

#### Breaking Changes

- **`destroy()` calls `onDestroy` instead of `onRemove` on plugins** — `onRemove` now only fires via `removePlugin()`. Plugins that used `onRemove` for destroy cleanup should add `onDestroy` (or assign the same function to both).
- **`computedOptions` removed** — replaced by `resolvedBounds`, which returns `{ element: ElementBounds, container: ContainerBounds }` (cached layout bounds only, no longer leaks the full internal options structure).
- **Getter return types narrowed** — `element`, `container`, and `vertical` getters now return resolved types (`Element`, `Window | Element`, `boolean`) instead of the raw public input union. Setters still accept the full public types.

#### New Features

- **Plugin lifecycle hooks: `onEnable`, `onDisable`, `onDestroy`** — plugins can react to enable/disable transitions and distinguish manual removal from instance teardown. `destroy()` on an enabled instance fires `onDisable` → `onDestroy` in sequence.
- **`scrollmagic/util` subpath export** — exposes `agnosticValues` and `agnosticProps` via `import { ... } from 'scrollmagic/util'` for plugin authors working with direction-agnostic bounds.
- **`ElementBounds`, `ContainerBounds`, `ResolvedBounds` types exported** — available from the main entry point for plugin and integration authors.

## 3.0.0-beta.1

### New Features

- **`scrollVelocity` getter** — per-container scroll velocity in px/s, shared across all instances on the same container via ContainerProxy. Returns 0 when disabled, destroyed, or idle (100ms staleness decay).
- **`enable()` / `disable()`** — temporarily disconnect all observers without destroying the instance. Progress freezes at its current value; `modify()`, `on()`/`off()`, plugins, and most getters remain functional. Re-enabling reconnects everything and schedules a full recalculation.
- **`{ once: true }` event listener option** — follows the DOM `addEventListener` options bag pattern. Works with both `.on()` and `.subscribe()`.
- **`refresh()` / `refreshAll()` / `destroyAll()`** — force bounds recalculation after layout changes invisible to ResizeObserver (position shifts, class toggles, sibling DOM mutations, font loading, etc.).
- **Post-destroy and non-browser guards** — all public methods now warn in dev mode and bail cleanly instead of producing undefined behavior when called after `destroy()` or outside a browser environment.
- **Element–container ancestry validation** (dev mode) — `console.error` when the tracked element isn't a descendant of its container, catching silent IntersectionObserver misconfiguration.

### Bug Fixes

- **Container position not initialized synchronously** — non-window containers defaulted to `{top:0,left:0}` until the first scroll/resize event, producing wrong initial progress for containers offset from the viewport top.
- **Zero-size container guard** — when a scroll container collapses to 0px, `updateProgress()` no longer produces incorrect values (division by near-zero) and `updateViewportObserver()` no longer passes broken margins to the IntersectionObserver.
- **Direction change not invalidating elementBoundsCache** — changing `vertical` via `modify()` left stale axis-dependent bounds in the cache.
- **containerBounds not rescheduled on option changes** — `containerStart`, `containerEnd`, and `vertical` changes via `modify()` didn't trigger a container bounds recalculation, causing wrong progress and viewport margins.
- **Stale closure in `onElementResize`** — `updateElementBoundsCache()` replaced the entire bounds object, but the resize handler's destructured reference pointed to the old one, so size comparisons always returned false and progress never recalculated after element resize.
- **`destroy()` skipping plugin `onRemove` callbacks** — plugin cleanup was routed through `removePlugin()`, which hit the `guardInert()` check (destroyed was already true) and silently skipped all `onRemove` callbacks.

### Performance

- **Replace debounce with `throttleRaf` for container resize** — removes the arbitrary 100ms debounce delay. Both window and element resize paths now use rAF-batched throttling for consistent, responsive behavior.
- **Cache PixelConverter results** — `elementStart`/`elementEnd` converters are skipped when element size is unchanged (common during scroll). Bounds caches mutated in-place via `Object.assign` instead of allocating new objects each frame.

### Internal

- Explicit `type` keyword on type-only imports for better tree-shaking.
- New `Vector` type for `{x, y}` pairs, replacing the old `ScrollDelta` shape.
- E2e tests reorganized from origin-based to feature-based structure. 13 regression tests covering v2-reported edge cases added.
- Added MAINTAINING.md and ROADMAP.md.


================================================
FILE: LICENSE.md
================================================
MIT License

Copyright (c) 2014-present Jan Paepke

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: MAINTAINING.md
================================================
# Maintaining ScrollMagic

Reference for triaging issues and PRs against **v3**.

---

## Philosophy

- **Actually read the issue** — every response should show it. No copy-paste walls.
- **Close fast over leaving open** — an open issue implies intent to act. If you won't act on it, close it clearly.
- **Specific > comprehensive** — 2–4 sentences that address the actual scenario beats a thorough generic answer.

---

## Issue Triage

### Categories

**Bug Reports** — broken behavior, browser-specific failures, wrong values, etc.

- Investigate. If confirmed, fix or document in `tests/e2e/UNTESTED-KNOWN-BUGS.md`.
- Label: `bug`

**Support / How-To** — "How do I...", "Is it possible to...", "Not working with X"

- Check comments first — if already answered, reference the solution: _"Looks like [username] provided a working solution above."_
- If you can answer confidently, do so briefly.
- Label: `support`

**Feature Requests** — new capabilities, API additions

- Evaluate fit with v3's scope and architecture.
- If in scope and a PR would be welcome, say so and label `help wanted`.
- Label: `enhancement`

**Build / Module Issues** — webpack, npm, TypeScript types, bundler problems

- v3 is a native ES module with TypeScript built-in — many v2-era build problems don't apply.
- Label: `support` or `bug` depending on whether something is broken vs. misunderstood.

**Meta / Admin** — license questions, dead links, repo housekeeping

- Handle case-by-case.
- Label: `invalid` or close without comment if clearly stale.

---

### Labels

| Label                | Meaning                                             |
| -------------------- | --------------------------------------------------- |
| `bug`                | Confirmed broken behavior                           |
| `enhancement`        | New capability request                              |
| `support`            | Usage question, how-to                              |
| `needs-info`         | Waiting on reporter to provide more context         |
| `needs-reproduction` | Cannot reproduce; a minimal repro is required       |
| `help wanted`        | Community contribution welcome                      |
| `good first issue`   | Low barrier, good entry point for new contributors  |
| `duplicate`          | Already tracked elsewhere — close and link          |
| `wontfix`            | Deliberate decision not to address                  |
| `invalid`            | Off-topic, spam, malformed                          |
| `mobile`             | Mobile-specific concern (IO, viewport, touch, etc.) |

---

### Response Guidelines

**Format:** No emojis in the body. 2–4 sentences specific to the issue, then close.

**Close reason:**

- Won't fix / out of scope: `not planned`
- Duplicate: `duplicate`
- Resolved or confirmed working: `completed`
- Spam / off-topic: `not planned`

### Response Templates

**Cannot reproduce:**

> Unable to reproduce this with the information provided. If you can share a minimal reproduction (CodePen, StackBlitz, or a small repo), that would help narrow it down significantly.

**Already answered in comments:**

> Looks like [username] provided a working solution above — hopefully that helps. Feel free to reopen with a reproduction if you're still running into this.

**Feature request, not accepting:**

> Thanks for the suggestion. This falls outside v3's current scope — [brief reason]. Feel free to open a discussion if you want to explore it further.

---

## Pull Requests

### Merge Criteria

- CI passes (types, lint, tests)
- New behavior has test coverage
- No unrelated changes bundled in
- Commit messages follow conventional commits

### Review Checklist

- Does the change match the stated intent?
- Is there a simpler approach?
- Are edge cases handled?
- Does it follow existing code style?

### Abandoned PRs

Comment asking for an update after 30 days of inactivity. Close after 60 days total — invite the author to reopen or for someone else to pick it up.

---

## Stale Issue Policy

1. Apply `needs-info` or `needs-reproduction` when waiting on the reporter.
2. If no response after 30 days, leave one comment asking for an update.
3. Close after 60 days total: _"Closing for inactivity — feel free to reopen or link a reproduction if you revisit this."_

<!-- GitHub Actions stale bot (https://github.com/actions/stale) can automate steps 2–3 if the volume warrants it -->

---

## Release Process

- Semver: patch for bug fixes, minor for new features, major for breaking changes
- Changelog maintained in `CHANGELOG.md`
- Tagged GitHub releases with release notes
- Published to npm as `scrollmagic`

---

## Handling v2 Issues

v2 is in maintenance-only mode — 2.0.9 is the final release. Issues filed against v2 should be acknowledged and redirected to v3.

### Approach by Category

**Bug:** Acknowledge the specific bug. Mention if v3's architecture approaches it differently (but don't promise a fix). Close as `not planned`.

**Support / How-To:** Answer briefly if confident — it helps people who find the issue via search. Then redirect to v3.

**Feature Request:** Note if v3 already covers it (see [v3 feature reference](#v3-feature-reference) below), or invite them to try v3 and reopen if still needed. Close as `not planned`.

**Build / Module:** Note that v3 is a native ES module with TypeScript built-in — most v2 bundler issues don't apply. Close as `not planned`.

When redirecting to v3, link to the [README on main](https://github.com/janpaepke/ScrollMagic/blob/main/README.md).

### v3 Feature Reference

Quick reference for assessing whether a v2 request or bug is addressed in v3:

- **No Controller** — each `new ScrollMagic({ element })` is self-contained
- **No pinning** — no pin system; CSS `position: sticky` covers most use cases
- **No built-in animation** — pair with GSAP, Motion, anime.js, etc.
- **Horizontal scroll** — `vertical: false` option
- **Any scroll container** — `scrollParent` accepts `window` or any element
- **Plugin system** — `addPlugin()` with `onAdd`, `onRemove`, `onModify` lifecycle hooks
- **Named position shorthands** — `'here'` (0%), `'center'` (50%), `'opposite'` (100%)
- **Inset functions** — `(size) => number` for dynamic computation
- **Native TypeScript** with full type exports
- **ES module** with UMD fallback, zero dependencies
- **SSR safe**
- **MIT license only** (v2 was dual MIT/GPL-3.0+)
- **Events:** `enter`, `leave`, `progress` — each with `direction`, `location`, `event.target`
- **Getters/setters** for all options; `modify()` for batch updates
- **`enable()` / `disable()`** — pause/resume tracking without destroying; `modify()` works while disabled


================================================
FILE: PLUGINS.md
================================================
# ScrollMagic Plugins

Plugins extend ScrollMagic instances with custom behaviour — class toggles, debug overlays, animation bindings, or anything else. Each plugin is a plain object with a `name` and optional lifecycle hooks.

## Basic Example

```ts
import ScrollMagic, { type ScrollMagicPlugin } from 'scrollmagic';

const myPlugin: ScrollMagicPlugin = {
	name: 'my-plugin',
	onAdd() {
		// `this` is the ScrollMagic instance
		this.on('enter', () => {
			/* ... */
		});
	},
	onRemove() {
		this.off('enter' /* ... */);
	},
};

const sm = new ScrollMagic({ element: '#target' });
sm.addPlugin(myPlugin);
sm.removePlugin(myPlugin); // or let destroy() handle cleanup
```

## Lifecycle Hooks

All hooks are optional. In every hook, `this` is bound to the ScrollMagic instance the plugin is attached to.

| Hook | When it fires |
| --- | --- |
| `onAdd()` | Immediately when `addPlugin()` is called. |
| `onRemove()` | When `removePlugin()` is called. The instance is still alive. |
| `onEnable()` | When `enable()` resumes tracking. |
| `onDisable()` | When `disable()` pauses tracking. Also fires during `destroy()` (before `onDestroy`) if the instance was enabled. |
| `onDestroy()` | When `destroy()` tears down the instance. The instance is already disabled at this point. |
| `onModify(changes)` | When options change via `modify()` or a setter. `changes` contains only the options that actually changed. |

### Hook Sequence

**`removePlugin(plugin)`** fires `onRemove` only.

**`destroy()`** on an enabled instance fires in order:

1. `onDisable` — tracking paused (observers disconnected)
2. `onDestroy` — instance dying (final cleanup)

If the instance was already disabled before `destroy()`, only `onDestroy` fires.

Note: `onRemove` does **not** fire during `destroy()`. Use `onDestroy` for teardown cleanup. If a plugin needs the same cleanup logic for both removal and destruction, assign the same function to both hooks:

```ts
const cleanup = function (this: ScrollMagic) {
	/* ... */
};
const plugin: ScrollMagicPlugin = {
	name: 'shared-cleanup',
	onRemove: cleanup,
	onDestroy: cleanup,
};
```

## Using Utilities

ScrollMagic exposes direction-agnostic helpers via a separate entry point. These are useful for plugins that need to work with both vertical and horizontal scrolling:

```ts
import { agnosticValues, agnosticProps } from 'scrollmagic/util';
```

- **`agnosticProps(vertical)`** — returns a map of direction-neutral prop names (`start`, `end`, `size`, etc.) to their CSS/DOM equivalents (`top`/`left`, `bottom`/`right`, `height`/`width`, etc.).
- **`agnosticValues(vertical, obj)`** — extracts the relevant values from a rect or similar object based on scroll direction. For example, `agnosticValues(true, element.getBoundingClientRect())` returns `{ start: rect.top, end: rect.bottom, size: rect.height, ... }`.

## Plugin Instance Methods

These methods are available on every ScrollMagic instance — plugins use them via `this` in hooks:

```ts
// Event listeners
this.on(type, callback);
this.off(type, callback);
this.subscribe(type, callback); // returns unsubscribe function

// Read state
this.progress; // 0–1
this.disabled; // true when disabled or destroyed
this.scrollVelocity; // px/s along tracked axis
this.activeRange; // { start, end } container scroll positions where tracking is active
this.resolvedBounds; // { element, container } cached layout bounds

// Read/write options
this.element;
this.elementStart;
this.elementEnd;
this.container;
this.containerStart;
this.containerEnd;
this.vertical;

// Actions
this.modify(options); // update multiple options at once
this.refresh(); // force bounds recalculation
this.enable();
this.disable();
this.destroy();

// Plugin management
this.addPlugin(plugin);
this.removePlugin(plugin);
this.pluginList; // snapshot of registered plugins
```


================================================
FILE: README.md
================================================
# ScrollMagic 3

<!--
TODO: Replace static shields (license, bundle, dependencies) once published
![license](https://img.shields.io/npm/l/scrollmagic)
-->

[![npm version](https://img.shields.io/npm/v/scrollmagic/next)](https://www.npmjs.com/package/scrollmagic/v/next)
[![license](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE.md)
[![bundle size](https://img.shields.io/badge/gzip-~6kb-brightgreen)](https://bundlephobia.com/package/scrollmagic)
[![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://npmgraph.js.org/?q=scrollmagic)
[![TypeScript](https://img.shields.io/badge/TypeScript-native-blue)](https://www.typescriptlang.org/)

### The lightweight library for magical scroll interactions

> **Looking for ScrollMagic v2?** The legacy version is on the [`v2-stable`](https://github.com/janpaepke/ScrollMagic/tree/v2-stable) branch.

ScrollMagic tells you where an element is relative to the viewport as the user scrolls — and fires events when that changes.

It's a convenience wrapper around [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) and [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) that handles the performance pitfalls and counter-intuitive edge cases for you.

[![Donate](https://scrollmagic.io/assets/img/btn_donate.svg 'Shut up and take my money!')](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=8BJC8B58XHKLL 'Shut up and take my money!')

### Not an animation library – unless you want it to be

By itself, ScrollMagic doesn't animate anything. It provides precise scroll-position data and events — what you do with them is up to you. If you're looking for a ready-made scroll animation solution, check out [GSAP ScrollTrigger](https://gsap.com/docs/v3/Plugins/ScrollTrigger/), [Motion](https://motion.dev/docs/scroll), or [anime.js](https://animejs.com/).

For pure CSS-driven scroll animations, see native [scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations) (not yet supported in all browsers). ScrollMagic complements them by providing cross-browser support, event callbacks, progress values, and state management that the native API doesn't cover.

ScrollMagic is a general-purpose, framework-agnostic, zero-dependency foundation for scroll-driven UX — what you do with it is entirely up to you: class toggles, animations, lazy loading, parallax, scroll-linked video, behavioural tracking, or anything else.

### Why ScrollMagic?

- Tiny footprint, zero dependencies
- Free to use ([open source](LICENSE.md))
- Optimized for performance (shared observers, batched rAF, single-frame updates)
- Built for modern browsers, mobile compatible
- Native TypeScript support
- SSR safe
- Works with any scroll container (window or custom element)
- Horizontal and vertical scrolling
- Plugin system for extensibility
- Framework agnostic — works with React, Vue, vanilla JS, anything

## Installation

```sh
npm install scrollmagic@next
```

## Quick Start

```js
import ScrollMagic from 'scrollmagic';

new ScrollMagic({ element: '#my-element' })
	.on('enter', () => console.log('visible!'))
	.on('leave', () => console.log('gone!'))
	.on('progress', e => console.log(`${(e.target.progress * 100).toFixed(0)}%`));
```

## How It Works

ScrollMagic uses two sets of bounds to define the active range:

- **Container bounds** — a zone on the scroll container, defined by `containerStart` and `containerEnd`
- **Element bounds** — a zone on the tracked element, defined by `elementStart` and `elementEnd`

Progress goes from `0` to `1` as the element bounds pass through the container bounds. Events fire on enter, leave, and progress change.

### Contain and Intersect

The two most common configurations are **contain** and **intersect**. They differ in where the container bounds are positioned:

#### Contain (default when `element` is `null`)

<img align="right" src="docs/dist/gfx/contain.gif" alt="Contain mode animation: tall element scrolls through viewport, progress tracks from 0% to 100%" width="260" />

The container bounds match the viewport edges — `containerStart` and `containerEnd` are both at `'here'` (`0%`). Progress goes from 0 to 1 while one fully **contains** the other: either the element is fully visible inside the viewport, or the element fully covers the viewport.

Typical uses: scroll progress bars, parallax, scroll-linked video, scroll-driven storytelling.

<br clear="both" />

#### Intersect (default when `element` is set)

<img align="right" src="docs/dist/gfx/intersect.gif" alt="Intersect mode animation: element scrolls through the viewport, progress tracks from 0% to 100%" width="260" />

The container bounds span the full viewport — `containerStart` and `containerEnd` are at `'opposite'` edges (`100%`). Progress goes from 0 to 1 while the element **intersects** with the viewport: starting when its leading edge enters and ending when its trailing edge leaves.

Typical uses: enter/leave animations, lazy loading, class toggles, visibility tracking.

<br clear="both" />

#### Not just defaults

While _contain_ and _intersect_ are the inferred defaults, you can also configure them explicitly — for example setting `containerStart: 0, containerEnd: 0` on an instance that has an element to get contain behaviour, or mixing container and element insets for custom tracking zones. The two configurations are **useful mental models, not rigid modes**.

#### Native scroll-driven animation ranges

If you're familiar with [CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations), here's how the native `view()` timeline ranges map to ScrollMagic configurations:

| Native range | ScrollMagic equivalent |
| ------------ | ---------------------- |
| `cover`      | _intersect_ default — `containerStart: 'opposite', containerEnd: 'opposite'` |
| `contain`    | _contain_ default — `containerStart: 0, containerEnd: 0` |
| `entry`      | `containerStart: 'opposite', containerEnd: 0` — container zone collapses to the trailing edge |
| `exit`       | `containerStart: 0, containerEnd: 'opposite'` — container zone collapses to the leading edge |

The native `entry-crossing` and `exit-crossing` ranges are equivalent to `entry` and `exit` above — the distinction only applies when subdividing a single native timeline, not when defining standalone tracking ranges.

## Options

All options are optional. They can be passed to the constructor and updated at any time via setters or `.modify()`.

| Option           | Type                                   | Default                    | Description                                           |
| ---------------- | -------------------------------------- | -------------------------- | ----------------------------------------------------- |
| `element`        | `Element \| string \| null`            | first child of `container` | The tracked element (or CSS selector). Selectors match only the first element — create one instance per element to track multiple. |
| `elementStart`   | `number \| string \| function`         | `0`                        | Start **inset** on the element.                       |
| `elementEnd`     | `number \| string \| function`         | `0`                        | End **inset** on the element.                         |
| `container`      | `Window \| Element \| string \| null`  | `window`                   | The scroll container (or CSS selector). Selectors use the first match. |
| `containerStart` | `number \| string \| function \| null` | inferred (see below)       | Start **inset** on the scroll container.              |
| `containerEnd`   | `number \| string \| function \| null` | inferred (see below)       | End **inset** on the scroll container.                |
| `vertical`       | `boolean`                              | `true`                     | Scroll axis. `true` = vertical, `false` = horizontal. |

**Inset values** work like CSS `top`/`bottom`: positive values offset inward from the respective edge in the tracked direction. Accepted value types:

- **Numbers** — pixel values (e.g. `50`)
- **Strings** — percentage or pixel strings (e.g. `'50%'`, `'20px'`), relative to the parent size (scroll container for container options, element for element options)
- **Named positions** — `'here'` (0%), `'center'` (50%), `'opposite'` (100%)
- **Functions** — `(size) => number` for dynamic computation

**`null` means infer:** For `element`, `container`, `containerStart`, or `containerEnd`, setting it to `null` resets them to their inferred default.

For `containerStart`/`containerEnd` the inferred values depend on `element`:

- **`element` is `null`** → defaults to [**contain**](#contain-default-when-element-is-null): the element is inferred as the first child of the container (for `window` this is `document.body`), container offsets are `'here'` (0%), mapping progress to overall scroll position.
- **`element` is not `null`** → defaults to [**intersect**](#intersect-default-when-element-is-set): container offsets are `'opposite'` (100%), tracking the element as it scrolls through the full viewport.

## Events

Subscribe with `.on()`, `.off()`, or `.subscribe()` (returns an unsubscribe function). Pass `{ once: true }` to auto-remove the listener after its first invocation. Calling `.off()` or the unsubscribe function after the listener has already been removed (e.g. after a `once` listener fires) is a safe no-op.

| Event      | When                                                     |
| ---------- | -------------------------------------------------------- |
| `enter`    | Element enters the active zone (progress leaves 0 or 1)  |
| `leave`    | Element leaves the active zone (progress reaches 0 or 1) |
| `progress` | Progress value changes while in the active zone          |

Every event provides:

```ts
event.target; // the ScrollMagic instance (access all properties, e.g. event.target.progress, event.target.element)
event.type; // 'enter' | 'leave' | 'progress'
event.direction; // 'forward' | 'reverse'
event.location; // 'start' | 'inside' | 'end'
```

## Examples

```js
// Intersect (default): active while any part of the element
// is visible in the viewport
new ScrollMagic({
	element: '#a',
});

// Intersect with narrowed container zone:
// active while the element passes through the center line
new ScrollMagic({
	element: '#b',
	containerStart: 'center',
	containerEnd: 'center',
});

// Same as above, but with element offsets:
// starts 50px before the element, ends 100px after it
new ScrollMagic({
	element: '#c',
	containerStart: 'center',
	containerEnd: 'center',
	elementStart: -50,
	elementEnd: -100,
});

// Fixed scroll distance of 150px, regardless of element height.
// elementEnd receives the element's size and offsets from
// the bottom — (size - 150) leaves only 150px of track.
new ScrollMagic({
	element: '#d',
	containerStart: 'center',
	containerEnd: 'center',
	elementEnd: size => size - 150,
});

// Contain: active only while the element is fully visible
// (element insets pushed to opposite edges = full element height)
new ScrollMagic({
	element: '#e',
	elementStart: 'opposite', // same as '100%'
	elementEnd: 'opposite', // same as '100%'
});

// Contain (default when no element): track overall scroll progress
new ScrollMagic();
```

## API

```ts
const sm = new ScrollMagic(options);

// Event listeners
sm.on(type, callback); // add listener, returns instance (chainable)
sm.on(type, callback, { once: true }); // listener auto-removes after first invocation
sm.off(type, callback); // remove listener, returns instance (chainable)
sm.subscribe(type, callback); // add listener, returns unsubscribe function
sm.subscribe(type, callback, { once: true }); // both auto-removes and returns unsubscribe

// Modify options after creation
sm.modify({ containerStart: 'center' });

// All options can also be directly read and written
const elem = sm.element; // get the tracked element
sm.containerStart = 'center'; // set individual options

// Read-only getters
sm.progress; // 0–1, how far through the active zone
sm.activeRange; // { start, end } container scroll positions where tracking is active
sm.scrollVelocity; // px/s along tracked axis, 0 when idle
sm.resolvedBounds; // { element, container } cached layout bounds

// Refresh — recalculate bounds after external layout changes
sm.refresh();

// Pause / resume tracking without destroying
sm.disable(); // disconnects all observers, freezes progress
sm.enable(); // reconnects observers, recalculates from current state
sm.disabled; // read-only, true when disabled or destroyed

// Lifecycle
sm.destroy();

// Static
ScrollMagic.defaultOptions({ vertical: false }); // get/set defaults for new instances
ScrollMagic.refreshAll(); // refresh every active instance
ScrollMagic.destroyAll(); // destroy every active instance
```

## When to use `refresh()`

ScrollMagic automatically tracks element size changes (via `ResizeObserver`) and scroll position changes. But some layout changes are invisible to these observers — they change an element's **position** without changing its **size** or triggering a scroll event.

Call `refresh()` (or `ScrollMagic.refreshAll()`) after:

- **CSS position/margin/padding changes** — `element.style.marginTop = '20px'`
- **CSS class toggles that affect layout** — `element.classList.add('expanded')`
- **DOM structure changes** — siblings added/removed above the element, shifting its position
- **Images loading without explicit dimensions** — an `<img>` above the tracked element loads and expands, pushing it down
- **Font loading** — `document.fonts.ready.then(() => ScrollMagic.refreshAll())`
- **Route changes in SPAs** — content swap changes scroll height
- **Dynamic content loading** — CMS-injected content, third-party widgets

```js
// After changing a style that affects position
element.style.marginTop = '100px';
sm.refresh();

// After fonts finish loading (affects text reflow)
document.fonts.ready.then(() => ScrollMagic.refreshAll());

// After a framework re-render that changes layout
onRouteChange(() => ScrollMagic.refreshAll());
```

Note that `refresh()` is only needed if you want bounds to update **before the next scroll event**. If the user keeps scrolling, element positions are re-read on every scroll frame anyway. `refresh()` matters when layout changes while tracking is active and the scroll position stays the same — e.g. toggling a class or injecting content without any scrolling.

`refresh()` is asynchronous — it schedules recalculation for the next animation frame and returns immediately. Multiple `refresh()` calls within the same frame are batched automatically.

## Plugins

ScrollMagic has a plugin system for extending instance behaviour.

```ts
sm.addPlugin(myPlugin);
sm.removePlugin(myPlugin);
```

See [PLUGINS.md](PLUGINS.md) for the full plugin authoring guide.

## Browser Support

Chrome 73+, Firefox 69+, Safari 13.1+, Edge 79+ (aligned to `ResizeObserver` support).

## License

MIT — [Jan Paepke](https://janpaepke.de)

<!-- TODO: link to extended documentation, demos, migration guide -->


================================================
FILE: ROADMAP.md
================================================
# Roadmap

Ideas and future directions for ScrollMagic v3. Nothing here is committed, just a notepad for enhancements.

## Documentation & Demo Pages

API docs and interactive demos for v3. (Highest priority)

## API Gaps

### Plugin candidates

- **toggleClass** — auto add/remove CSS class on enter/leave. The most common scroll use case — nearly every library has it. Usage-specific, so better as a bundled plugin than core API.
- **CSS variable output** — expose `--progress`, `--visible` etc. as CSS custom properties on elements. Enables pure-CSS scroll effects with zero JS callbacks.
- **Batch coordination** — when N elements enter the viewport in the same frame, fire one coordinated callback with stagger support. Essential for grid/list reveals.
- **Web Animation API bridge** — drive `element.animate()` keyframes from ScrollMagic progress. Use native `ScrollTimeline` when available (compositor-thread performance for `transform`/`opacity`/`filter`) and fall back to SM-driven progress updates when not. Gives SM users native animation performance without leaving SM's event/callback model.

## Plugin Ideas

### Auto-refresh (MutationObserver + PositionObserver)

An opt-in plugin that automatically calls `refresh()` when layout-affecting changes are detected on the tracked element. Two complementary approaches:

- **MutationObserver** — watches `style` and `class` attribute changes on the element. Catches inline style modifications (`element.style.margin = '...'`) and class toggles. Limitation: high false-positive rate (fires on non-layout changes like `color`), can't detect stylesheet rule changes or media query transitions.
- **Userland PositionObserver** (e.g. [Shopify/position-observer](https://github.com/Shopify/position-observer)) — IntersectionObserver-based, detects actual position shifts without polling. Limitation: ~1px precision, only works while the element intersects the root.

Neither covers all cases, but together they could reduce the need for manual `refresh()` in common scenarios (framework re-renders, third-party widgets, CMS-injected content). Should be off by default due to overhead.

### Debug indicators

Visual debugging overlay similar to ScrollMagic v2's `addIndicators` plugin. Shows trigger positions, element start/end markers, and current progress. Helps developers see what ScrollMagic is calculating without console logging.

### Pin

Element pinning during scroll progress — the v2 `setPin` equivalent. CSS `position: sticky` covers the basic "pin while in viewport" case well, but doesn't handle unpin-and-repin scenarios (pin an element, release it at a specific scroll position, then re-pin it later). A plugin could fill that gap while recommending `sticky` for simple cases.

## Framework Integrations

### React

React wrapper/hooks for ScrollMagic. Reference implementation: [sm-test-react](https://github.com/janpaepke/sm-test-react). Should handle lifecycle cleanup (destroy on unmount), re-render-safe refs, and ideally provide a `useScrollMagic` hook.


================================================
FILE: config/banner.txt
================================================
<%= pkg.title %> v<%= pkg.version %>
Author: <%= pkg.author.name %> (<%= pkg.author.url %>)
<%= pkg.contributors && pkg.contributors.some(function(c) { return c.name }) ? 'Contributors: ' + pkg.contributors.filter(function(c) { return c.name }).map(function(c) { return c.name }).join(', ') + '\n' : '' %>Generated: <%= moment().format('YYYY-MM-DD') %>
License: <%= pkg.license %>
Docs & Demos: <%= pkg.homepage %>

================================================
FILE: docs/diagrams/contain.html
================================================
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>ScrollMagic – Contain Diagram</title>
		<link rel="stylesheet" href="shared.css" />
		<style>
			/*
			 * Contain mode: element taller than container.
			 * containerStart at top edge, containerEnd at bottom edge.
			 * No approach/exit — starts at progress 0%, ends at 100%.
			 *
			 * Layout geometry (all relative to stage top):
			 *   stage-padding-top = 120px
			 *   viewport-height = 174px
			 *   element-height = 260px
			 *
			 * Progress 0% (element top at viewport top): top = 120px
			 * Progress 100% (element bottom at viewport bottom): top = 294 - 260 = 34px
			 *
			 * Total travel: 86px. 5% pause at each end.
			 */

			:root {
				--element-height: 260px;
				--element-width: calc(var(--viewport-width) - 6px);
				--anim-duration: 4s;
			}

			.element {
				height: var(--element-height);
				width: var(--element-width);
				animation: scroll-element var(--anim-duration) linear infinite;
			}

			@keyframes scroll-element {
				0%,
				5% {
					top: 120px;
				}
				95%,
				100% {
					top: 34px;
				}
			}

			/* markers at viewport edges */
			.marker-start {
				top: var(--stage-padding-top);
			}
			.marker-end {
				top: calc(
					var(--stage-padding-top) + var(--viewport-height) - 1px
				);
			}

			/* progress track spans viewport height, fill grows bottom→up */
			.progress-track {
				top: var(--stage-padding-top);
				height: var(--viewport-height);
			}
			.progress-fill {
				height: 0%;
				animation: fill-progress var(--anim-duration) linear infinite;
			}

			@keyframes fill-progress {
				0%,
				5% {
					height: 0%;
				}
				95%,
				100% {
					height: 100%;
				}
			}

			/* progress label tracks the fill top, bottom→up — stays visible at 100% */
			.progress-label {
				animation: label-pos var(--anim-duration) linear infinite;
			}

			@keyframes label-pos {
				0%,
				4% {
					top: calc(var(--stage-padding-top) + var(--viewport-height));
					opacity: 0;
				}
				5% {
					top: calc(var(--stage-padding-top) + var(--viewport-height));
					opacity: 1;
				}
				95%,
				100% {
					top: var(--stage-padding-top);
					opacity: 1;
				}
			}
		</style>
	</head>
	<body>
		<div class="stage">
			<div class="mask-top"></div>
			<div class="mask-bottom"></div>

			<div class="viewport">
				<span class="viewport-label">Container</span>
			</div>

			<div class="element">Element</div>

			<div class="marker marker-start">
				<div class="marker-label-group">
					<span class="marker-name">containerStart</span>
					<span class="marker-value">'here' (0%)</span>
				</div>
				<div class="marker-line"></div>
			</div>
			<div class="marker marker-end">
				<div class="marker-label-group">
					<span class="marker-name">containerEnd</span>
					<span class="marker-value">'here' (0%)</span>
				</div>
				<div class="marker-line"></div>
			</div>

			<div class="progress-track">
				<div class="progress-fill"></div>
			</div>
			<div class="progress-label">
				<span class="progress-counter">0%</span>
			</div>
		</div>

		<script>
			// Progress calculation exposed as global for both live preview and export.
			// Motion and progress both: 5%→95%
			window.getProgress = function (frac) {
				if (frac < 0.05) return 0;
				if (frac >= 0.95) return 100;
				return Math.round(((frac - 0.05) / 0.9) * 100);
			};

			const label = document.querySelector('.progress-counter');
			const duration = 4000;
			const start = performance.now();
			(function update(now) {
				const frac = ((now - start) % duration) / duration;
				label.textContent = window.getProgress(frac) + '%';
				requestAnimationFrame(update);
			})(start);
		</script>
	</body>
</html>


================================================
FILE: docs/diagrams/intersect.html
================================================
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>ScrollMagic – Intersect Diagram</title>
		<link rel="stylesheet" href="shared.css" />
		<style>
			/*
			 * Intersect mode: element shorter than container.
			 * Element enters from below, exits above.
			 *
			 * Layout geometry (all relative to stage top):
			 *   stage-padding-top = 120px
			 *   viewport-height = 174px
			 *   element-height = 100px
			 *   stage-padding-bottom = 120px
			 *
			 * containerStart = viewport bottom edge = 294px
			 * containerEnd = viewport top edge = 120px
			 *
			 * Element-top positions:
			 *   start (below): 354px
			 *   progress 0% (elem top at viewport bottom): 294px
			 *   progress 100% (elem bottom at viewport top): 20px
			 *   end (above): -40px
			 *
			 * Total travel: 394px. Constant speed.
			 * With 5% pause at each end, motion: 5% → 95%
			 *   progress 0% at: 5% + (60/394)*90% ≈ 18.7%
			 *   progress 100% at: 5% + (334/394)*90% ≈ 81.3%
			 */

			:root {
				--element-height: 100px;
				--anim-duration: 4s;
			}

			.element {
				height: var(--element-height);
				animation: scroll-element var(--anim-duration) linear infinite;
			}

			@keyframes scroll-element {
				0%,
				5% {
					top: 354px;
				}
				95%,
				100% {
					top: -40px;
				}
			}

			/* markers at viewport edges */
			.marker-start {
				top: calc(
					var(--stage-padding-top) + var(--viewport-height) - 1px
				);
			}
			.marker-end {
				top: var(--stage-padding-top);
			}

			/* progress track spans viewport height, fill grows bottom→up */
			.progress-track {
				top: var(--stage-padding-top);
				height: var(--viewport-height);
			}
			.progress-fill {
				height: 0%;
				animation: fill-progress var(--anim-duration) linear infinite;
			}

			@keyframes fill-progress {
				0%,
				18.7% {
					height: 0%;
				}
				81.3%,
				100% {
					height: 100%;
				}
			}

			/* progress label tracks the fill top, bottom→up — stays visible at 100% */
			.progress-label {
				animation: label-pos var(--anim-duration) linear infinite;
			}

			@keyframes label-pos {
				0%,
				17.7% {
					top: calc(var(--stage-padding-top) + var(--viewport-height));
					opacity: 0;
				}
				18.7% {
					top: calc(var(--stage-padding-top) + var(--viewport-height));
					opacity: 1;
				}
				81.3%,
				95%,
				100% {
					top: var(--stage-padding-top);
					opacity: 1;
				}
			}

			/* event badges — flash at enter/leave moments */
			.event-badge {
				position: absolute;
				z-index: 5;
				left: calc(
					var(--viewport-left) + var(--viewport-width) / 2
				);
				transform: translateX(-50%);
				padding: 3px 12px;
				border-radius: 10px;
				font-size: 12px;
				font-weight: 600;
				white-space: nowrap;
				color: #fff;
				opacity: 0;
			}
			.event-enter {
				top: calc(
					var(--stage-padding-top) + var(--viewport-height) - 32px
				);
				background: #27ae60;
				animation: flash-enter var(--anim-duration) linear infinite;
			}
			.event-leave {
				top: calc(var(--stage-padding-top) + 12px);
				background: #e67e22;
				animation: flash-leave var(--anim-duration) linear infinite;
			}

			@keyframes flash-enter {
				0%,
				17% {
					opacity: 0;
					transform: translateX(-50%) scale(0.8);
				}
				18.7% {
					opacity: 1;
					transform: translateX(-50%) scale(1);
				}
				27% {
					opacity: 1;
					transform: translateX(-50%) scale(1);
				}
				30% {
					opacity: 0;
					transform: translateX(-50%) scale(0.8);
				}
				100% {
					opacity: 0;
					transform: translateX(-50%) scale(0.8);
				}
			}

			@keyframes flash-leave {
				0%,
				79.5% {
					opacity: 0;
					transform: translateX(-50%) scale(0.8);
				}
				81.3% {
					opacity: 1;
					transform: translateX(-50%) scale(1);
				}
				89% {
					opacity: 1;
					transform: translateX(-50%) scale(1);
				}
				92% {
					opacity: 0;
					transform: translateX(-50%) scale(0.8);
				}
				100% {
					opacity: 0;
					transform: translateX(-50%) scale(0.8);
				}
			}
		</style>
	</head>
	<body>
		<div class="stage">
			<div class="mask-top"></div>
			<div class="mask-bottom"></div>

			<div class="viewport">
				<span class="viewport-label">Container</span>
			</div>

			<div class="element">Element</div>

			<div class="event-badge event-enter">enter</div>
			<div class="event-badge event-leave">leave</div>

			<div class="marker marker-start">
				<div class="marker-label-group">
					<span class="marker-name">containerStart</span>
					<span class="marker-value">'opposite' (100%)</span>
				</div>
				<div class="marker-line"></div>
			</div>
			<div class="marker marker-end">
				<div class="marker-label-group">
					<span class="marker-name">containerEnd</span>
					<span class="marker-value">'opposite' (100%)</span>
				</div>
				<div class="marker-line"></div>
			</div>

			<div class="progress-track">
				<div class="progress-fill"></div>
			</div>
			<div class="progress-label">
				<span class="progress-counter">0%</span>
			</div>
		</div>

		<script>
			// Progress calculation exposed as global for both live preview and export.
			// Motion: 5%→95%, progress active: 18.7%→81.3%
			window.getProgress = function (frac) {
				if (frac < 0.187) return 0;
				if (frac >= 0.813) return 100;
				return Math.round(
					((frac - 0.187) / (0.813 - 0.187)) * 100
				);
			};

			const label = document.querySelector('.progress-counter');
			const duration = 4000;
			const start = performance.now();
			(function update(now) {
				const frac = ((now - start) % duration) / duration;
				label.textContent = window.getProgress(frac) + '%';
				requestAnimationFrame(update);
			})(start);
		</script>
	</body>
</html>


================================================
FILE: docs/diagrams/shared.css
================================================
/* shared styles for ScrollMagic animated diagrams */
:root {
	--viewport-width: 260px;
	--viewport-height: 174px;
	--viewport-left: 170px;
	--element-width: 100px;
	--stage-padding-top: 120px;
	--stage-padding-bottom: 120px;

	--color-viewport: #e74c3c;
	--color-element: #3498db;
	--color-marker: #888;
	--color-progress: #2ecc71;
	--color-bg: #fff;
	--color-label: #555;
}

* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}

body {
	background: var(--color-bg);
	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
	display: flex;
	justify-content: center;
	align-items: center;
	min-height: 100vh;
	overflow: hidden;
}

.stage {
	position: relative;
	/* viewport-left + viewport + gap + progress track + label */
	width: calc(var(--viewport-left) + var(--viewport-width) + 80px);
	height: calc(var(--viewport-height) + var(--stage-padding-top) + var(--stage-padding-bottom));
}

/* mask overlays — white semi-transparent, dims the element outside the container */
.mask-top,
.mask-bottom {
	position: absolute;
	left: var(--viewport-left);
	width: var(--viewport-width);
	background: rgba(255, 255, 255, 0.7);
	z-index: 2;
}
.mask-top {
	top: 0;
	height: var(--stage-padding-top);
}
.mask-bottom {
	bottom: 0;
	height: var(--stage-padding-bottom);
}

/* container box */
.viewport {
	position: absolute;
	left: var(--viewport-left);
	top: var(--stage-padding-top);
	width: var(--viewport-width);
	height: var(--viewport-height);
	border: 3px solid var(--color-viewport);
	border-radius: 6px;
	background: transparent;
	z-index: 3;
}
.viewport-label {
	position: absolute;
	top: -22px;
	left: 50%;
	transform: translateX(-50%);
	font-size: 13px;
	font-weight: 600;
	color: var(--color-viewport);
	white-space: nowrap;
}

/* element (moves via animation) */
.element {
	position: absolute;
	left: calc(var(--viewport-left) + var(--viewport-width) / 2);
	transform: translateX(-50%);
	width: var(--element-width);
	background: var(--color-element);
	border-radius: 5px;
	z-index: 1;
	display: flex;
	align-items: center;
	justify-content: center;
	color: #fff;
	font-size: 13px;
	font-weight: 600;
}

/* container markers — left side, with arrow pointing to container edge */
.marker {
	position: absolute;
	z-index: 4;
	display: flex;
	align-items: center;
	right: calc(100% - var(--viewport-left) + 10px);
	flex-direction: row;
	gap: 6px;
	transform: translateY(-50%);
}
.marker-label-group {
	display: flex;
	flex-direction: column;
	align-items: flex-end;
	gap: 1px;
}
.marker-name {
	font-size: 11px;
	color: var(--color-label);
	white-space: nowrap;
	font-weight: 500;
}
.marker-value {
	font-size: 10px;
	font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
	color: #999;
	white-space: nowrap;
}
.marker-line {
	position: relative;
	height: 0;
	width: 40px;
	border-top: 2px dashed var(--color-marker);
	flex-shrink: 0;
}
/* arrowhead pointing right toward the container edge */
.marker-line::after {
	content: '';
	position: absolute;
	right: -7px;
	top: -5px;
	width: 0;
	height: 0;
	border-left: 7px solid var(--color-marker);
	border-top: 5px solid transparent;
	border-bottom: 5px solid transparent;
}

/* progress indicator — right side of container */
.progress-track {
	position: absolute;
	z-index: 4;
	left: calc(var(--viewport-left) + var(--viewport-width) + 20px);
	width: 4px;
	background: #e0e0e0;
	border-radius: 2px;
}
.progress-fill {
	position: absolute;
	top: auto;
	bottom: 0;
	left: 0;
	width: 100%;
	background: var(--color-progress);
	border-radius: 2px;
}
.progress-label {
	position: absolute;
	z-index: 4;
	left: calc(var(--viewport-left) + var(--viewport-width) + 32px);
	font-size: 14px;
	font-weight: 700;
	font-variant-numeric: tabular-nums;
	color: var(--color-progress);
	white-space: nowrap;
}


================================================
FILE: eslint.config.mjs
================================================
//@ts-check

import eslint from '@eslint/js';
import compat from 'eslint-plugin-compat';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';

export default defineConfig(
	{ ignores: ['dist/', 'docs/tsdoc'] },
	eslint.configs.recommended,
	tseslint.configs.recommendedTypeChecked,
	compat.configs['flat/recommended'],
	{
		languageOptions: {
			globals: {
				...globals.browser,
			},
			parserOptions: {
				projectService: {
					allowDefaultProject: ['vitest.config.ts'],
				},
				tsconfigRootDir: import.meta.dirname,
			},
		},
		rules: {
			'no-useless-rename': 'warn',
			'@typescript-eslint/no-explicit-any': 'off',
		},
	},
	{
		files: ['**/*.mjs'],
		extends: [tseslint.configs.disableTypeChecked],
		languageOptions: {
			globals: {
				...globals.node,
			},
		},
	},
	{
		files: ['scripts/*.mjs'],
		rules: {
			'compat/compat': 'off',
		},
	}
);


================================================
FILE: package.json
================================================
{
	"name": "scrollmagic",
	"title": "ScrollMagic",
	"version": "3.0.0-beta.4",
	"description": "The lightweight library for magical scroll interactions.",
	"type": "module",
	"main": "./dist/scrollmagic.umd.js",
	"module": "./dist/scrollmagic.esm.js",
	"types": "./dist/types/index.d.ts",
	"exports": {
		".": {
			"types": "./dist/types/index.d.ts",
			"import": "./dist/scrollmagic.esm.js",
			"require": "./dist/scrollmagic.umd.js"
		},
		"./util": {
			"types": "./dist/types/util.d.ts",
			"import": "./dist/scrollmagic.util.esm.js",
			"require": "./dist/scrollmagic.util.umd.js"
		}
	},
	"sideEffects": false,
	"files": [
		"dist"
	],
	"devDependencies": {
		"@eslint/js": "^10.0.1",
		"@rollup/plugin-terser": "^0.4.4",
		"@rollup/plugin-typescript": "^12.3.0",
		"@vitest/browser-playwright": "^4.0.18",
		"eslint": "^10.0.0",
		"eslint-plugin-compat": "^6.2.0",
		"gifenc": "^1.0.3",
		"globals": "^17.3.0",
		"jsdom": "^28.1.0",
		"pngjs": "^7.0.0",
		"prettier": "^3.8.1",
		"rollup": "^4.57.1",
		"rollup-plugin-bundle-size": "^1.0.3",
		"rollup-plugin-delete": "^3.0.2",
		"rollup-plugin-license": "^3.7.0",
		"tslib": "^2.8.1",
		"typedoc": "^0.28.17",
		"typescript": "^5.9.3",
		"typescript-eslint": "^8.56.0",
		"vitest": "^4.0.18"
	},
	"engines": {
		"node": ">=18"
	},
	"scripts": {
		"build": "rollup -c",
		"dev": "npm run build -- --watch",
		"test": "npm run typecheck && vitest run",
		"test:unit": "vitest run --project unit",
		"test:e2e": "vitest run --project e2e",
		"test:watch": "vitest",
		"typecheck": "tsc --noEmit",
		"lint": "eslint .",
		"lint:fix": "npm run lint -- --fix",
		"prettier": "prettier --check .",
		"prettier:fix": "prettier --write .",
		"preversion": "npm run build && npm run test",
		"prepublishOnly": "npm run build",
		"docs:api": "typedoc",
		"doces:export-diagrams": "node scripts/export-diagrams.mjs"
	},
	"repository": {
		"type": "git",
		"url": "git+https://github.com/janpaepke/ScrollMagic.git"
	},
	"author": {
		"name": "Jan Paepke",
		"url": "https://janpaepke.de",
		"email": "e-mail@janpaepke.de"
	},
	"contributors": [
		{}
	],
	"license": "MIT",
	"bugs": {
		"url": "https://github.com/janpaepke/ScrollMagic/issues"
	},
	"homepage": "https://scrollmagic.io",
	"browserslist": [
		"Chrome >= 73",
		"Firefox >= 69",
		"Safari >= 13.1",
		"Edge >= 79",
		"iOS >= 13.4",
		"Samsung >= 9.2",
		"not dead"
	]
}


================================================
FILE: prettier.config.mjs
================================================
/**
 * @see https://prettier.io/docs/configuration
 * @type {import("prettier").Config}
 */
const config = {
	semi: true,
	useTabs: true,
	printWidth: 120,
	bracketSpacing: true,
	arrowParens: 'avoid',
	htmlWhitespaceSensitivity: 'css',
	endOfLine: 'lf',
	singleQuote: true,
	trailingComma: 'es5',
	experimentalTernaries: true,
};

export default config;


================================================
FILE: rollup.config.mjs
================================================
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import bundleSize from 'rollup-plugin-bundle-size';
import clean from 'rollup-plugin-delete';
import license from 'rollup-plugin-license';

import pkg from './package.json' with { type: 'json' };
import cfg from './tsconfig.json' with { type: 'json' };

const createCommonPlugins = () => [
	bundleSize(),
	typescript({
		declarationDir: './dist/types',
		exclude: ['tests/**/*'],
	}),
	terser(),
	license({
		banner: {
			commentStyle: 'ignored',
			content: {
				file: './config/banner.txt',
				encoding: 'utf-8',
			},
		},
	}),
];

const main = {
	input: './src/index.ts',
	output: [
		{
			format: 'umd',
			file: pkg.main,
			name: pkg.title,
			sourcemap: true,
		},
		{
			format: 'esm',
			file: pkg.module,
			sourcemap: true,
		},
	],
	plugins: [
		clean({
			targets: `${cfg.compilerOptions.outDir}/*`,
		}),
		...createCommonPlugins(),
	],
};

const util = {
	input: './src/util.ts',
	output: [
		{
			format: 'umd',
			file: pkg.exports['./util'].require,
			name: `${pkg.title}Utils`,
			sourcemap: true,
		},
		{
			format: 'esm',
			file: pkg.exports['./util'].import,
			sourcemap: true,
		},
	],
	plugins: createCommonPlugins(),
};

export default [main, util];


================================================
FILE: scripts/export-diagrams.mjs
================================================
/**
 * Converts HTML animation files to GIF using Playwright + gifenc + pngjs.
 *
 * Usage:
 *   npm run export-diagrams                        # export all diagrams
 *   node scripts/export-diagrams.mjs intersect    # export only intersect.gif
 *   node scripts/export-diagrams.mjs contain      # export only contain.gif
 *
 * Options (env vars):
 *   FPS=15        frames per second (default: 15)
 */

import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import pkg from 'pngjs';
const { PNG } = pkg;
import gifenc from 'gifenc';
const { GIFEncoder, quantize, applyPalette } = gifenc;

const __dirname = dirname(fileURLToPath(import.meta.url));
const srcDir = resolve(__dirname, '..', 'docs', 'diagrams');
const outDir = resolve(__dirname, '..', 'docs', 'dist', 'gfx');

const diagrams = {
	intersect: resolve(srcDir, 'intersect.html'),
	contain: resolve(srcDir, 'contain.html'),
};

const fps = parseInt(process.env.FPS || '15', 10);

async function exportDiagram(name, htmlPath) {
	console.log(`\n--- Exporting ${name} ---`);
	console.log(`  Source: ${htmlPath}`);

	const browser = await chromium.launch();
	const page = await browser.newPage();

	// Set viewport large enough to contain the stage (170+260+80=510 wide, 120+174+120=414 tall)
	await page.setViewportSize({ width: 540, height: 450 });

	await page.goto(`file://${htmlPath}`, { waitUntil: 'domcontentloaded' });

	// Wait for animations to start
	await page.waitForFunction(() => document.getAnimations().length > 0);

	// Get animation duration and detect all animations
	const animInfo = await page.evaluate(() => {
		const animations = document.getAnimations();
		const durations = animations.map(a => {
			const timing = a.effect.getComputedTiming();
			return timing.duration + timing.delay;
		});
		return {
			count: animations.length,
			duration: Math.max(...durations),
		};
	});

	console.log(`  Animations: ${animInfo.count}, duration: ${animInfo.duration}ms`);
	console.log(`  FPS: ${fps}`);

	const duration = animInfo.duration;
	const frameCount = Math.ceil((duration / 1000) * fps);
	const frameDelay = Math.round(1000 / fps); // ms per frame for GIF

	console.log(`  Frames: ${frameCount}, delay: ${frameDelay}ms`);

	// Pause all animations at time 0 and kill the rAF-based text loop
	await page.evaluate(() => {
		document.getAnimations().forEach(a => {
			a.pause();
			a.currentTime = 0;
		});
		// Override rAF so the HTML's live-preview loop can't overwrite text
		window.requestAnimationFrame = () => 0;
	});

	// Determine tight bounding box of the .stage element
	const stageBox = await page.evaluate(() => {
		const stage = document.querySelector('.stage');
		const rect = stage.getBoundingClientRect();
		return {
			x: Math.round(rect.x),
			y: Math.round(rect.y),
			width: Math.round(rect.width),
			height: Math.round(rect.height),
		};
	});

	console.log(`  Stage bounds: ${stageBox.width}x${stageBox.height} at (${stageBox.x}, ${stageBox.y})`);

	const gif = GIFEncoder();
	let width, height;

	for (let i = 0; i < frameCount; i++) {
		const time = (i / frameCount) * duration;

		// Seek all animations to this time
		await page.evaluate(t => {
			document.getAnimations().forEach(a => {
				a.currentTime = t;
			});
		}, time);

		// Update the JS-driven progress counter via the page's own getProgress()
		await page.evaluate(
			({ t, dur }) => {
				const el = document.querySelector('.progress-counter');
				if (el && typeof window.getProgress === 'function') {
					el.textContent = window.getProgress(t / dur) + '%';
				}
			},
			{ t: time, dur: duration }
		);

		// Take screenshot of just the stage area
		const pngBuffer = await page.screenshot({
			clip: stageBox,
			type: 'png',
		});

		const png = PNG.sync.read(pngBuffer);
		if (i === 0) {
			width = png.width;
			height = png.height;
		}

		const palette = quantize(png.data, 256);
		const index = applyPalette(png.data, palette);
		gif.writeFrame(index, width, height, {
			palette,
			delay: frameDelay,
			repeat: 0,
		});

		if ((i + 1) % 10 === 0 || i === frameCount - 1) {
			process.stdout.write(`\r  Frame ${i + 1}/${frameCount} (${Math.round(time)}ms)`);
		}
	}

	gif.finish();
	const gifBytes = gif.bytes();

	// Ensure output directory
	if (!existsSync(outDir)) {
		mkdirSync(outDir, { recursive: true });
	}

	const outPath = resolve(outDir, `${name}.gif`);
	writeFileSync(outPath, gifBytes);

	const sizeKB = (gifBytes.length / 1024).toFixed(1);
	console.log(`\n  Output: ${outPath} (${sizeKB} KB)`);

	await browser.close();
}

// Main
const requested = process.argv[2];
const names = requested ? [requested] : Object.keys(diagrams);

for (const name of names) {
	if (!diagrams[name]) {
		console.error(`Unknown diagram: ${name}`);
		console.error(`Available: ${Object.keys(diagrams).join(', ')}`);
		process.exit(1);
	}
}

console.log(`Exporting diagrams: ${names.join(', ')}`);

for (const name of names) {
	await exportDiagram(name, diagrams[name]);
}

console.log('\nDone!');


================================================
FILE: src/Container.ts
================================================
import { type DispatchableEvent, EventDispatcher } from './EventDispatcher';
import { getScrollContainerDimensions } from './util/getScrollContainerDimensions';
import { getScrollPos } from './util/getScrollPos';
import { registerEvent } from './util/registerEvent';
import { rafQueue } from './util/rafQueue';
import { observeResize } from './util/sharedResizeObserver';
import { throttleRaf } from './util/throttleRaf';
import { isWindow } from './util/typeguards';

export type ScrollContainer = HTMLElement | Window;

type CleanUpFunction = () => void;
type Vector = {
	x: number;
	y: number;
};
const ZERO_VECTOR: Readonly<Vector> = Object.freeze({ x: 0, y: 0 });

// type EventType = 'scroll' | 'resize';
enum EventType {
	Scroll = 'scroll',
	Resize = 'resize',
}
export class ContainerEvent implements DispatchableEvent {
	constructor(
		public readonly target: Container,
		public readonly type: `${EventType}`,
		public readonly scrollDelta: Readonly<Vector> = ZERO_VECTOR // I could make an additional EventType only for Scroll Events, but we'll just ignore these for resize events...
	) {}
}

export class Container {
	/** Time in milliseconds after which the scroll velocity is considered stale. */
	private static readonly VELOCITY_STALE_MS = 100;
	private dimensions = {
		// inner size excluding scrollbars
		clientWidth: 0,
		clientHeight: 0,
		// size of scrollable content
		scrollWidth: 0,
		scrollHeight: 0,
	};
	private scrollPos = {
		top: 0,
		left: 0,
	};
	private positionCache = {
		// position of scroll parent (if not window) relative to window
		top: 0,
		left: 0,
	};
	private destroyed = false;
	private lastScrollTime: number | undefined;
	private readonly scrollVelocityCache: Vector = { x: 0, y: 0 };
	private readonly dispatcher = new EventDispatcher<ContainerEvent>();
	private readonly cleanups: CleanUpFunction[] = [];
	/**
	 * TODO: Currently we have no way of detecting, when physical scrollbars appear or disappear, which should technically trigger a resize event.
	 * One potential way of getting around this would be to add an additional resize observer to the documentElement and detect when it crosses 100% of the container's client size (either in or out)
	 * But this seems quite hacky and code intense for this edge case scenario. It would also work for document scrolls, not for Element scrolls.
	 */
	constructor(public readonly containerElement: ScrollContainer) {
		const throttledScroll = throttleRaf(() => {
			this.updateScrollPos();
			rafQueue.flush();
		});
		const throttledResize = throttleRaf(() => {
			this.updateDimensions();
			rafQueue.flush();
		});
		if (!isWindow(containerElement)) {
			const throttledMove = throttleRaf(this.updatePosition.bind(this));
			this.cleanups.push(throttledMove.cancel, this.subscribeMove(throttledMove));
			this.updatePosition(); // initialize synchronously; subsequent updates are throttled via subscribeMove
		}
		this.cleanups.push(
			throttledScroll.cancel,
			throttledResize.cancel,
			this.subscribeScroll(throttledScroll),
			this.subscribeResize(throttledResize)
		);
		this.updateScrollPos();
		this.updateDimensions();
	}

	private updateScrollPos() {
		const prevScrollPos = this.scrollPos;
		this.scrollPos = getScrollPos(this.containerElement);
		const deltaY = this.scrollPos.top - prevScrollPos.top;
		const deltaX = this.scrollPos.left - prevScrollPos.left;
		const now = performance.now();
		if (undefined !== this.lastScrollTime) {
			const dt = now - this.lastScrollTime;
			if (dt > 0) {
				this.scrollVelocityCache.x = (deltaX / dt) * 1000;
				this.scrollVelocityCache.y = (deltaY / dt) * 1000;
			}
		}
		this.lastScrollTime = now;
		this.dispatcher.dispatchEvent(new ContainerEvent(this, EventType.Scroll, { x: deltaX, y: deltaY }));
	}

	private updateDimensions() {
		this.dimensions = getScrollContainerDimensions(this.containerElement);
		this.dispatcher.dispatchEvent(new ContainerEvent(this, EventType.Resize));
	}

	private updatePosition() {
		// this should only be executed, when containerElement is NOT window
		const { top, left } = (this.containerElement as HTMLElement).getBoundingClientRect();
		this.positionCache = { top, left };
	}

	// subscribes to resize events of containerElement and returns a function to reverse the effect
	private subscribeResize(onResize: () => void) {
		const { containerElement } = this;
		if (isWindow(containerElement)) {
			return registerEvent(containerElement, EventType.Resize, onResize);
		}
		return observeResize(containerElement, onResize);
	}

	// subscribes to scroll events of containerElement and returns a function to reverse the effect
	private subscribeScroll(onScroll: () => void) {
		return registerEvent(this.containerElement, EventType.Scroll, onScroll, { passive: true });
	}

	private subscribeMove(onMove: () => void) {
		const listeners = [
			registerEvent(window, EventType.Scroll, onMove, { passive: true }),
			registerEvent(window, EventType.Resize, onMove),
		];
		return () => listeners.forEach(cleanup => cleanup());
	}

	// subscribes Container and returns a function to reverse the effect
	public subscribe(type: `${EventType}`, cb: (e: ContainerEvent) => void): () => void {
		return this.dispatcher.addEventListener(type, cb);
	}

	public get size(): Readonly<Container['dimensions']> {
		return this.dimensions;
	}

	public get position(): Readonly<Container['positionCache']> {
		return this.positionCache;
	}

	public get scrollVelocity(): Readonly<Vector> {
		if (undefined === this.lastScrollTime || performance.now() - this.lastScrollTime > Container.VELOCITY_STALE_MS) {
			return ZERO_VECTOR;
		}
		return this.scrollVelocityCache;
	}

	public destroy(): void {
		if (this.destroyed) {
			return;
		}
		this.destroyed = true;
		this.cleanups.forEach(cleanup => cleanup());
		this.cleanups.length = 0;
	}
}


================================================
FILE: src/ContainerProxy.ts
================================================
import { Container, type ContainerEvent, type ScrollContainer } from './Container';
import { ScrollMagic } from './ScrollMagic';
import { ScrollMagicInternalError } from './ScrollMagicError';
type EventCallback = (e: ContainerEvent) => void;
type CleanUpFunction = () => void;
type Velocity = {
	x: number;
	y: number;
};

export class ContainerProxy {
	private static cache = new WeakMap<ScrollContainer, [Container, Set<ScrollMagic>]>();

	private container?: Container;
	constructor(private readonly sm: ScrollMagic) {}
	private unsubscribers: CleanUpFunction[] = [];

	public attach(containerElement: ScrollContainer, onUpdate: EventCallback): void {
		if (undefined !== this.container) {
			this.detach();
		}
		let cache = ContainerProxy.cache.get(containerElement);
		if (undefined === cache) {
			cache = [new Container(containerElement), new Set()];
			ContainerProxy.cache.set(containerElement, cache);
		}
		const [container, instances] = cache;
		instances.add(this.sm);
		this.container = container;
		this.unsubscribers = [container.subscribe('resize', onUpdate), container.subscribe('scroll', onUpdate)];
	}

	public detach(): void {
		if (undefined === this.container) {
			return;
		}
		const { containerElement } = this.container;
		const cache = ContainerProxy.cache.get(containerElement);
		if (undefined === cache) {
			throw new ScrollMagicInternalError('No cache info for container');
		}
		const [container, instances] = cache;
		instances.delete(this.sm);
		this.unsubscribers.forEach(unsubscribe => unsubscribe());
		this.unsubscribers = [];
		if (instances.size === 0) {
			// no more attached instances
			container.destroy();
			ContainerProxy.cache.delete(containerElement);
		}
		this.container = undefined;
	}

	public get size(): Container['size'] {
		if (undefined === this.container) {
			throw new ScrollMagicInternalError(`Can't get size when not attached to a container`);
		}
		return this.container.size;
	}

	public get position(): Container['position'] {
		if (undefined === this.container) {
			throw new ScrollMagicInternalError(`Can't get position when not attached to a container`);
		}
		return this.container.position;
	}

	public get scrollVelocity(): Velocity {
		if (undefined === this.container) {
			return { x: 0, y: 0 };
		}
		return this.container.scrollVelocity;
	}
}


================================================
FILE: src/EventDispatcher.ts
================================================
const noop = () => {};
type EventType = string;
export interface DispatchableEvent {
	readonly target: unknown;
	readonly type: EventType;
}

/** Options for event listener registration. */
export type ListenerOptions = {
	/** If `true`, the listener is automatically removed after its first invocation. */
	once?: boolean;
	/** An {@link AbortSignal} — when aborted, the listener is automatically removed. Matches the DOM `addEventListener` pattern. */
	signal?: AbortSignal;
};
type Callback<E extends DispatchableEvent> = (event: E) => void;
type ListenerEntry<E extends DispatchableEvent> = { cb: Callback<E>; options: ListenerOptions };

export class EventDispatcher<E extends DispatchableEvent = DispatchableEvent> {
	private listeners = new Map<string, Set<ListenerEntry<E>>>();

	// adds a listener to the dispatcher. returns a function to reverse the effect.
	public addEventListener(type: E['type'], cb: Callback<E>, options: ListenerOptions = {}): () => void {
		// Match DOM spec: if signal already aborted, don't register
		if (options.signal?.aborted) {
			return noop;
		}
		let set = this.listeners.get(type);
		if (!set) {
			set = new Set();
			this.listeners.set(type, set);
		}
		// fresh object per registration — Set identity keeps duplicate registrations of the same callback distinct
		const entry: ListenerEntry<E> = { cb, options };
		set.add(entry);
		const remove = () => this.removeEntry(type, entry);
		if (options.signal) {
			options.signal.addEventListener('abort', remove, { once: true });
		}
		return remove;
	}

	// removes a listener from the dispatcher
	public removeEventListener(type: E['type'], cb: Callback<E>): void {
		const set = this.listeners.get(type);
		if (!set) {
			return;
		}
		for (const entry of set) {
			if (entry.cb === cb) {
				this.removeEntry(type, entry);
				break;
			}
		}
	}

	// dispatches an event
	public dispatchEvent(event: E): void {
		const set = this.listeners.get(event.type);
		if (!set) {
			return;
		}
		// iterate a copy so listeners added during dispatch don't fire in the same cycle
		for (const entry of [...set]) {
			if (entry.options.once) {
				this.removeEntry(event.type, entry);
			}
			entry.cb(event);
		}
	}

	private removeEntry(type: string, entry: ListenerEntry<E>): void {
		const set = this.listeners.get(type);
		if (!set) {
			return;
		}
		set.delete(entry);
		if (0 === set.size) {
			this.listeners.delete(type);
		}
	}
}


================================================
FILE: src/ExecutionQueue.ts
================================================
import { rafQueue } from './util/rafQueue';
import { transformObject } from './util/transformObject';
type Callback = () => void;
type ExecutionCondition = () => boolean;
const always: ExecutionCondition = () => true;
type CommandList<T extends string> = Record<T, Command>;

/**
 * This class holds a list of callbacks allows them to be scheduled for execution on next animationFrame.
 * Every callback will only be executed once per animationFrame, even if scheduled multiple times.
 * The order of the queue superceeds the order of scheduling, this means that if the queue consists of callbacks a, b,
 * a will always execute first, even if b is scheduled first.
 *
 * usage example:
 * ```
 * const queue = new ExecutionQueue({
 * 		a: () => console.log('a');
 * 		b: () => console.log('b');
 * })
 * queue.commands.b.schedule();
 * queue.commands.a.schedule();
 * <expected output on animationFrame>
 * 'a'
 * 'b'
 * ```
 *
 * For details about conditional execution see Command class below.
 *
 * To invoke execution now (and purge scheduled), call queue.execute.
 * To cancel scheduled execution, call queue.cancel
 */
export class ExecutionQueue<C extends string> {
	public readonly commands: CommandList<C>;

	constructor(queueItems: Record<C, Callback>) {
		this.commands = transformObject(queueItems, ([key, command]) => [key, new Command(command, () => rafQueue.schedule(this))]);
	}

	// executes all commands in the list in order, depending on whether or not their conditions are met
	public execute(): void {
		Object.values<Command>(this.commands).forEach(item => {
			if (item.conditionsMet) {
				item.execute();
			}
			item.resetConditions();
		});
	}
	public cancel(): void {
		rafQueue.unschedule(this);
	}
}

/**
 * Each command in the ExecutionQueue above can be scheduled for execution using command.schedule()
 * .schedule() also accepts an optional parameter, a condition callback
 * This is called when execution is due, to determine if the callback should still be called.
 *
 * usage example:
 * ```
 * let x = 1;
 * const queue = new ExecutionQueue({
 * 		a: () => {
 * 			x = 2;
 * 			console.log('a');
 * 		};
 * 		b: () => console.log('b');
 * })
 * // result of execution condition remains true
 * queue.commands.b.schedule(() => x === 1);
 * <expected output on animationFrame>
 * 'b'
 * // x is 1 now, but will be 2, once a has been called.
 * queue.commands.a.schedule();
 * queue.commands.b.schedule(() => x === 1);
 * <expected output on animationFrame>
 * 'a'
 * ```
 */
class Command {
	protected conditions: ExecutionCondition[] = [];
	constructor(
		public readonly execute: Callback,
		protected readonly onSchedule: () => void
	) {}
	public schedule(condition?: ExecutionCondition) {
		if (undefined === condition) {
			// if no condition is provided, conditions are considered always met. Any conditions added after this won't even be run
			this.conditions = [];
			condition = always;
		}
		this.conditions.push(condition);
		this.onSchedule();
	}
	public resetConditions() {
		this.conditions = [];
	}
	public get conditionsMet() {
		return this.conditions.some(condition => condition());
	}
}


================================================
FILE: src/Options.processors.ts
================================================
import {
	PixelConverter,
	Private,
	PrivateUninferred,
	Public,
	inferredContainerDefaults,
	defaults as optionDefaults,
} from './Options';
import { ScrollMagicError } from './ScrollMagicError';
import { agnosticValues } from './util/agnosticValues';
import { getScrollContainerDimensions } from './util/getScrollContainerDimensions';
import { PropertyProcessors, processProperties } from './util/processProperties';
import { sanitizeProperties } from './util/sanitizeProperties';
import { skipNull, toPixelConverter, toSvgOrHtmlElement, toValidContainer } from './util/transformers';
import { isHTMLElement, isSVGElement, isWindow } from './util/typeguards';

const transformers: PropertyProcessors<Required<Public>, PrivateUninferred> = {
	element: skipNull(toSvgOrHtmlElement),
	elementStart: toPixelConverter,
	elementEnd: toPixelConverter,
	container: skipNull(toValidContainer),
	containerStart: skipNull(toPixelConverter),
	containerEnd: skipNull(toPixelConverter),
	vertical: Boolean,
};

// removes unknown properties from supplied options
export const sanitizeOptions = <T extends Public>(options: T): T => sanitizeProperties(options, optionDefaults);

// converts all public values to their corresponding private value, leaving null values untouched
const transform = (options: Public): Partial<PrivateUninferred> => processProperties(options, transformers);

// processes remaining null values
const infer = (options: PrivateUninferred): Private => {
	const inferContainer = (container: Window | HTMLElement | null): Window | HTMLElement => container ?? window;

	const inferElement = (elem: Element | null): HTMLElement | SVGElement => {
		if (null !== elem) {
			return elem as HTMLElement | SVGElement;
		}
		const resolved = inferContainer(options.container);
		const child = isWindow(resolved) ? document.body : resolved.firstElementChild;
		if (null === child || !(isHTMLElement(child) || isSVGElement(child))) {
			throw new ScrollMagicError(`Could not autodetect element, as container has no valid children.`);
		}
		return child;
	};

	const inferContainerOffset = (val: PixelConverter | null): PixelConverter =>
		val ?? (null === options.element ? inferredContainerDefaults.fallback : inferredContainerDefaults.default);

	return processProperties(options, {
		container: inferContainer,
		element: inferElement,
		containerStart: inferContainerOffset,
		containerEnd: inferContainerOffset,
	});
};

// checks if the options the user entered actually make sense
const sanityCheck = (options: Private): void => {
	const { containerStart, containerEnd, elementStart, elementEnd, vertical, container, element } = options;

	if (!isWindow(container) && !container.contains(element)) {
		console?.error(
			'ScrollMagic: element is not a descendant of container. The IntersectionObserver requires an ancestor relationship to function correctly.',
			{ element, container }
		);
	}

	const { size: elementSize } = getElementSize(options);
	const { clientSize: containerSize } = agnosticValues(vertical, getScrollContainerDimensions(container));

	const elementDistance = elementSize - elementStart(elementSize) - elementEnd(elementSize);
	const trackDistance = -(containerSize - containerStart(containerSize) - containerEnd(containerSize));

	const total = elementDistance + trackDistance;
	if (total < 0) {
		console?.warn(
			'ScrollMagic Warning: Detected no overlap with the configured track options. This means ScrollMagic will not trigger unless this changes later on (i.e. due to resizes).',
			{
				...options,
				containerStart: containerStart(containerSize),
				containerEnd: containerEnd(containerSize),
				elementStart: elementStart(elementSize),
				elementEnd: elementEnd(elementSize),
			}
		);
	}
};

export const processOptions = <T extends Public>(
	newOptions: T,
	oldOptions?: Private
): { sanitized: T; processed: Private } => {
	const sanitized = sanitizeOptions(newOptions);
	const normalized = transform(sanitized);
	const processed = infer({ ...oldOptions, ...normalized } as PrivateUninferred);
	if (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {
		sanityCheck(processed);
	}
	return { sanitized, processed };
};

// helpers
const getElementSize = ({ vertical, element }: Pick<Private, 'vertical' | 'element'>) =>
	agnosticValues(vertical, element.getBoundingClientRect());


================================================
FILE: src/Options.ts
================================================
type NullableProperties<T extends object, K extends keyof T> = Omit<T, K> & {
	[X in K]: T[X] | null;
};
type UnitString = `${number}px` | `${number}%`;
type PositionShorthand = keyof typeof positionShorthands;
type CssSelector = string;

/** Converts an element's or container's current size (in pixels) to a pixel offset used for position calculations. */
export type PixelConverter = (size: number) => number;

/** Public configuration options accepted by the ScrollMagic constructor and `modify()`. */
export type Public = {
	/** The tracked element (or CSS selector). Defaults to the first child of `container`. Set to `null` to reset. */
	element?: Element | CssSelector | null;
	/** Start **inset** on the element. Positive values shrink the tracked region from the leading edge. @default 0 */
	elementStart?: number | UnitString | PositionShorthand | PixelConverter;
	/** End **inset** on the element. Positive values shrink the tracked region from the trailing edge. @default 0 */
	elementEnd?: number | UnitString | PositionShorthand | PixelConverter;
	/** The scroll container (or CSS selector). Defaults to `window`. Set to `null` to reset. */
	container?: Window | Element | CssSelector | null;
	/** Start **inset** on the scroll container. Set to `null` to infer based on `element`. @default null (inferred) */
	containerStart?: number | UnitString | PositionShorthand | PixelConverter | null;
	/** End **inset** on the scroll container. Set to `null` to infer based on `element`. @default null (inferred) */
	containerEnd?: number | UnitString | PositionShorthand | PixelConverter | null;
	/** Scroll axis. `true` = vertical, `false` = horizontal. @default true */
	vertical?: boolean;
};

// basically a normalized version of the options
export type Private = {
	element: Element;
	elementStart: PixelConverter;
	elementEnd: PixelConverter;
	container: Window | HTMLElement;
	containerStart: PixelConverter;
	containerEnd: PixelConverter;
	vertical: boolean;
};

// values that can be null after processing and need to be inferred, if still null
export type PrivateUninferred = NullableProperties<
	Private,
	'element' | 'container' | 'containerStart' | 'containerEnd'
>;

/** Named position shorthands that resolve to percentage strings for element and container offsets. */
export const positionShorthands = {
	here: '0%',
	center: '50%',
	opposite: '100%',
} as const satisfies Record<string, UnitString>;

/** Default values for all public options. Returned (and optionally overridden) by `ScrollMagic.defaultOptions()`. */
export const defaults: Required<Public> = {
	element: null,
	elementStart: 0,
	elementEnd: 0,
	container: null,
	containerStart: null,
	containerEnd: null,
	vertical: true,
};

// applied during fallback inference. if containerStart or containerEnd is null this will apply default if element is present and fallback otherwise
export const inferredContainerDefaults: Record<string, PixelConverter> = {
	default: (containerSize: number) => containerSize, // default 100%, starts at bottom, ends at top
	fallback: () => 0, // if no element is supplied, it will fall back to the first child of the container (usually the body), so it starts at the top and ends at the bottom
};


================================================
FILE: src/ScrollMagic.ts
================================================
import type { ContainerEvent } from './Container';
import { ContainerProxy } from './ContainerProxy';
import { EventDispatcher, type ListenerOptions } from './EventDispatcher';
import { ExecutionQueue } from './ExecutionQueue';
import * as Options from './Options';
import { processOptions, sanitizeOptions } from './Options.processors';
import { EventLocation, EventType, ScrollDirection, ScrollMagicEvent } from './ScrollMagicEvent';
import { agnosticProps } from './util/agnosticValues';
import { getScrollPos } from './util/getScrollPos';
import { pickDifferencesFlat } from './util/pickDifferencesFlat';
import { observeResize } from './util/sharedResizeObserver';
import { numberToPercString } from './util/transformers';
import { isWindow } from './util/typeguards';
import { ViewportObserver } from './ViewportObserver';

const isBrowser = 'undefined' !== typeof window;

/** Cached layout measurements for the tracked element along the scroll axis. */
export type ElementBounds = {
	/** Position relative to viewport. */
	start: number;
	/** Outer visible size of element (excluding margins). */
	size: number;
	/** Offset relative to top/left of element. */
	offsetStart: number;
	/** Offset relative to bottom/right of element. */
	offsetEnd: number;
	/** Effective track size including offsets. */
	trackSize: number;
};
/** Cached layout measurements for the scroll container along the scroll axis. */
export type ContainerBounds = {
	/** Inner visible area of scroll container (excluding scrollbars). */
	clientSize: number;
	/** Offset relative to top/left of container. */
	offsetStart: number;
	/** Offset relative to bottom/right of container. */
	offsetEnd: number;
	/** Effective track size including offsets. */
	trackSize: number;
	/** Total size of content of container. */
	scrollSize: number;
};

/** Combined cached bounds for both the tracked element and its scroll container. */
export type ResolvedBounds = {
	/** Cached bounds of the tracked element. */
	element: Readonly<ElementBounds>;
	/** Cached bounds of the scroll container. */
	container: Readonly<ContainerBounds>;
};

/**
 * A ScrollMagic plugin. Plugins receive lifecycle callbacks bound to the ScrollMagic instance they are added to.
 *
 * All callbacks are optional. The `this` context inside each callback is the owning ScrollMagic instance.
 */
export interface Plugin {
	/** Unique name identifying this plugin. */
	name: string;
	/** Called when the plugin is added via {@link ScrollMagic.addPlugin}. */
	onAdd?(this: ScrollMagic): void;
	/** Called when the plugin is removed via {@link ScrollMagic.removePlugin}. */
	onRemove?(this: ScrollMagic): void;
	/** Called when the instance is enabled via {@link ScrollMagic.enable}. */
	onEnable?(this: ScrollMagic): void;
	/** Called when the instance is disabled via {@link ScrollMagic.disable}. */
	onDisable?(this: ScrollMagic): void;
	/** Called when the instance is destroyed via {@link ScrollMagic.destroy}. */
	onDestroy?(this: ScrollMagic): void;
	/** Called when options change via {@link ScrollMagic.modify}. Receives only the changed options. */
	onModify?(this: ScrollMagic, changesPublic: Options.Public): void;
}

/**
 * Core class for scroll-based animations. Each instance tracks a single DOM element
 * within a scroll container and reports scroll progress (0–1) through events.
 *
 * @example
 * ```js
 * const sm = new ScrollMagic({ element: '#hero' });
 * sm.on('progress', (e) => console.log(e.target.progress));
 * ```
 */
export class ScrollMagic {
	public readonly name = 'ScrollMagic';

	private resizeCleanup?: () => void;
	private readonly dispatcher = new EventDispatcher<ScrollMagicEvent>();
	private readonly containerProxy = new ContainerProxy(this);
	private readonly viewportObserver = new ViewportObserver(this.onIntersectionChange.bind(this));
	private readonly executionQueue = new ExecutionQueue({
		// The order is important here! They will always be executed in exactly this order when scheduled for the same animation frame
		elementBounds: this.updateElementBoundsCache.bind(this),
		containerBounds: this.updateContainerBoundsCache.bind(this),
		viewportObserver: this.updateViewportObserver.bind(this),
		progress: this.updateProgress.bind(this),
	});
	private readonly update = this.executionQueue.commands;
	private readonly plugins = new Set<Plugin>();
	protected readonly elementBoundsCache: ElementBounds = {
		// see typedef for details
		start: 0,
		size: 0,
		offsetStart: 0,
		offsetEnd: 0,
		trackSize: 0,
	};
	protected readonly containerBoundsCache: ContainerBounds = {
		// see typedef for details
		clientSize: 0,
		offsetStart: 0,
		offsetEnd: 0,
		trackSize: 0,
		scrollSize: 0,
	};

	// all below options should only ever be changed by a dedicated method
	protected optionsPublic!: Required<Options.Public>; // set in modify in constructor
	protected optionsPrivate!: Options.Private; // set in modify in constructor
	protected currentProgress = 0;
	protected intersecting?: boolean; // currently intersecting with the ViewportObserver?
	private destroyed = false; // instance is destroyed and cannot be used anymore, true if destroy() was called
	private enabled = true; // instance is enabled and can be used, false if disable() was called

	/**
	 * Create a new ScrollMagic instance.
	 *
	 * @param options - Configuration for the tracked element, scroll container, offsets, and axis.
	 *
	 * @example
	 * ```js
	 * // Track vertical scroll progress for an element
	 * const sm = new ScrollMagic({ element: '.section', container: '#scroller' });
	 *
	 * // Horizontal scroll with custom offsets
	 * const sm = new ScrollMagic({
	 *   element: '.panel',
	 *   vertical: false,
	 *   elementStart: '50%',
	 *   containerStart: 'center',
	 * });
	 * ```
	 */
	constructor(options: Options.Public = {}) {
		ScrollMagic.instances.add(this);
		const initOptions: Required<Options.Public> = {
			...ScrollMagic.defaultOptionsPublic,
			...options,
		};
		this.modify(initOptions);
	}

	private guardInert(): boolean {
		if (this.destroyed && (typeof process === 'undefined' || process.env.NODE_ENV !== 'production')) {
			console?.warn('ScrollMagic Warning: Method called on a destroyed instance.');
		}
		return this.destroyed || !isBrowser;
	}

	protected getViewportMargin(): { top: string; left: string; right: string; bottom: string } {
		const { vertical } = this.optionsPrivate;
		const axis = agnosticProps(vertical);
		const cross = agnosticProps(!vertical);
		const crossScrollSize = this.containerProxy.size[cross.scrollSize];
		const crossClientSize = this.containerProxy.size[cross.clientSize];
		const {
			clientSize: containerSize,
			offsetStart: containerOffsetStart,
			offsetEnd: containerOffsetEnd,
		} = this.containerBoundsCache;
		const { offsetStart, offsetEnd } = this.elementBoundsCache; // from cache

		const marginStart = containerSize - containerOffsetStart + offsetStart;
		const marginEnd = containerSize - containerOffsetEnd + offsetEnd;
		/**
		 ** confusingly IntersectionObserver (and thus ViewportObserver) treat margins in the opposite direction (negative means towards the center)
		 ** so we'll have to flip the signs here.
		 ** Additionally we convert it to percentages and round, as this means they are less likely to change, meaning less refreshes for the observer
		 ** (as the observer internally compares old values to new ones)
		 ** This way it won't have to internally create new IntersectionObservers, just because the container's size changes.
		 */
		const decimals = 10;
		const noSize = containerSize <= 0;
		const relMarginStart = noSize ? 0 : -marginStart / containerSize;
		const relMarginEnd = noSize ? 0 : -marginEnd / containerSize;

		// adding available scrollspace in cross direction, so element never moves out of trackable area, even when scrolling horizontally on a vertically tracked element
		const noCrossSize = crossClientSize <= 0;
		const scrollableCross =
			noCrossSize ? 0 : numberToPercString((crossScrollSize - crossClientSize) / crossClientSize, decimals);
		return {
			// the start and end values are intentionally flipped here (start value defines end margin and vice versa)
			[axis.start]: numberToPercString(relMarginEnd, decimals),
			[axis.end]: numberToPercString(relMarginStart, decimals),
			[cross.start]: scrollableCross,
			[cross.end]: scrollableCross,
		} as Record<'top' | 'left' | 'bottom' | 'right', string>;
	}

	protected getTrackSize(): number {
		return this.elementBoundsCache.trackSize + this.containerBoundsCache.trackSize;
	}

	// !update functions MUST NOT call any other functions causing side effects, with the exceptions of modify and event triggers in progress

	protected updateIntersectingState(nextIntersecting: boolean | undefined): void {
		// doesn't have to be a method, but I want to keep modifications obvious (only called from update... methods)
		this.intersecting = nextIntersecting;
	}

	protected updateElementBoundsCache(): void {
		// console.log(this.optionsPrivate.element.id, 'bounds', new Date().getMilliseconds());
		// this should be called cautiously, getBoundingClientRect costs...
		const { elementStart, elementEnd, element, vertical } = this.optionsPrivate;
		const props = agnosticProps(vertical);
		const rect = element.getBoundingClientRect();
		const start = rect[props.start];
		const size = rect[props.size];
		this.elementBoundsCache.start = start;
		// only update if size has changed, otherwise we're recalculating the offsetStart and offsetEnd for no reason
		if (size !== this.elementBoundsCache.size) {
			const offsetStart = elementStart(size);
			const offsetEnd = elementEnd(size);
			Object.assign(this.elementBoundsCache, {
				size,
				offsetStart,
				offsetEnd,
				trackSize: size - offsetStart - offsetEnd,
			});
		}
	}

	protected updateContainerBoundsCache(): void {
		// console.log(this.optionsPrivate.element.id, 'container', new Date().getMilliseconds());
		const { containerStart, containerEnd, vertical } = this.optionsPrivate;
		const containerProps = agnosticProps(vertical);
		const clientSize = this.containerProxy.size[containerProps.clientSize];
		const scrollSize = this.containerProxy.size[containerProps.scrollSize];
		const offsetStart = containerStart(clientSize);
		const offsetEnd = containerEnd(clientSize);
		Object.assign(this.containerBoundsCache, {
			clientSize,
			scrollSize,
			offsetStart,
			offsetEnd,
			trackSize: -(clientSize - offsetStart - offsetEnd), // container track is inverted (start is usually below end)
		});
	}

	protected updateProgress(): void {
		// console.log(this.optionsPrivate.element.id, 'progress', new Date().getMilliseconds());
		if (this.containerBoundsCache.clientSize <= 0) {
			return; // container has no visible area, progress is meaningless
		}
		const { offsetStart: elementOffset, start: elementPosition } = this.elementBoundsCache;
		const { offsetStart: containerOffset } = this.containerBoundsCache;
		const containerPosition = this.containerProxy.position[agnosticProps(this.optionsPrivate.vertical).start];

		const elementStart = elementPosition + elementOffset;
		const containerStart = containerPosition + containerOffset;
		const passed = containerStart - elementStart;
		const total = this.getTrackSize();

		if (total < 0) {
			return; // no overlap of track and scroll distance
		}

		const previousProgress = this.currentProgress;
		const nextProgress = Math.min(Math.max(passed / total, 0), 1); // when leaving, it will overshoot, this normalises to 0 / 1 (also when total is 0)
		const deltaProgress = nextProgress - previousProgress;

		if (deltaProgress === 0) {
			return;
		}

		this.currentProgress = nextProgress;
		const forward = deltaProgress > 0;

		if (previousProgress === 0 || previousProgress === 1) {
			this.triggerEvent(EventType.Enter, forward);
		}
		this.triggerEvent(EventType.Progress, forward);
		if (nextProgress === 0 || nextProgress === 1) {
			this.triggerEvent(EventType.Leave, forward);
		}
	}

	protected updateViewportObserver(): void {
		if (this.containerBoundsCache.clientSize <= 0) {
			this.updateIntersectingState(undefined); // reset so intersection re-evaluates when container becomes visible
			return;
		}
		const { container, vertical } = this.optionsPrivate;
		const observerOptions = {
			margin: this.getViewportMargin(),
			root: isWindow(container) ? null : container,
			vertical,
		};
		this.viewportObserver.modify(observerOptions);
	}

	protected onOptionChanges(changedOptions: Options.Public): void {
		const changes = Object.keys(changedOptions) as Array<keyof Options.Public>;
		if (changes.length === 0) {
			return;
		}
		const isChanged = changes.includes.bind(changes);
		const directionChanged = isChanged('vertical');
		const elementBoundsInvalidated = directionChanged || isChanged('elementStart') || isChanged('elementEnd');

		if (elementBoundsInvalidated) {
			this.elementBoundsCache.size = NaN; // force converter recalculation (size guard in updateElementBoundsCache)
		}
		if (this.disabled) return;

		const elementChanged = isChanged('element');
		const containerChanged = isChanged('container');
		const containerBoundsInvalidated =
			containerChanged || directionChanged || isChanged('containerStart') || isChanged('containerEnd');

		if (elementBoundsInvalidated || elementChanged) {
			this.update.elementBounds.schedule();
		}
		if (elementChanged) {
			this.updateIntersectingState(undefined);
			const { element } = this.optionsPrivate;
			this.viewportObserver.disconnect();
			this.viewportObserver.observe(element);
			this.resizeCleanup?.();
			this.resizeCleanup = observeResize(element, this.onElementResize.bind(this));
		}
		if (containerBoundsInvalidated) {
			this.update.containerBounds.schedule();
			this.update.viewportObserver.schedule();
		}
		if (containerChanged) {
			this.updateIntersectingState(undefined);
			this.containerProxy.attach(this.optionsPrivate.container, this.onContainerUpdate.bind(this)); // container updates are already throttled
		}
		// if any options changes we always have to refresh the progress
		this.update.progress.schedule();
	}

	protected onElementResize(): void {
		/**
		 * * element resized
		 * updateContainerBounds => 	never
		 * updateElementBounds =>		schedule always (obviously),		execute regardless.
		 * updateViewportObserver => 	schedule always, 					execute if start or end offset changed in trigger bounds update above
		 * updateProgress => 			schedule if currently intersecting,	execute if bounds changed (offsets or size, since size affects trackSize)
		 */
		const { update } = this;
		// Capture previous values for lazy condition checks.
		// IMPORTANT: closures must reference `this.elementBoundsCache` (not a destructured local)
		// because `updateElementBoundsCache()` replaces the entire object reference.
		const { offsetStart: startPrevious, offsetEnd: endPrevious, size: sizePrevious } = this.elementBoundsCache;
		const isOffsetChanged = () =>
			startPrevious !== this.elementBoundsCache.offsetStart || endPrevious !== this.elementBoundsCache.offsetEnd;
		const isBoundsChanged = () => isOffsetChanged() || sizePrevious !== this.elementBoundsCache.size;
		update.elementBounds.schedule();
		update.viewportObserver.schedule(isOffsetChanged);
		if (this.intersecting) {
			update.progress.schedule(isBoundsChanged);
		}
	}

	protected onContainerUpdate(e: ContainerEvent): void {
		/**
		 * * container resized
		 * updateContainerBounds => 	schedule always							execute regardless
		 * updateElementBounds => 		schedule if currently intersecting, 	execute regardless (resizes are caught in onElementResize but position might change due to container resize, which wouldn't be)
		 * updateViewportObserver => 	schedule always (to get new margins),	execute regardless.
		 * updateProgress => 			schedule if currently intersecting, 	execute if position changed in triggerBounds update
		 */
		const { update } = this;
		if ('resize' === e.type) {
			this.update.containerBounds.schedule();
			if (this.intersecting) {
				update.elementBounds.schedule();
			}
			update.viewportObserver.schedule();
			const { start: startPrevious } = this.elementBoundsCache;
			const { clientSize: sizePrevious } = this.containerBoundsCache;
			const isChanged = () =>
				startPrevious !== this.elementBoundsCache.start || sizePrevious !== this.containerBoundsCache.clientSize;
			update.progress.schedule(isChanged);
			return;
		}
		/**
		 * * container scrolled
		 * if relevant scrollDelta is 0, do nothing (scroll was in other direction)
		 * updateContainerBounds => 	never
		 * updateElementBounds =>		schedule if currently intersecting,							execute regardless
		 * updateViewportObserver => 	never
		 * updateProgress =>			schedule if currently intersecting or potentially skipped, 	execute regardless (technically only execute if triggerBounds returned a new position, but that's implied, if there was a scoll move in the relevant direction)
		 */
		const scrollDelta = e.scrollDelta[agnosticProps(this.optionsPrivate.vertical).axis];
		if (0 === scrollDelta) {
			return; // scroll was in other direction
		}
		// in case the scroll position changes by more than the total track distance, the viewport observer might miss it.
		// this means running the progress update more than we have to, but in this case we have no choice.
		const potentiallySkipped = Math.abs(scrollDelta) > this.getTrackSize();

		if (!this.intersecting && !potentiallySkipped) {
			// if we're not intersecting and there's no danger we skipped the active range, we don't have to do anything...
			return;
		}
		update.elementBounds.schedule();
		update.progress.schedule();
	}

	protected onIntersectionChange(intersecting: boolean, target: Element): void {
		// the check below should always be true, as we only ever observe one element, but you can never be too sure, I guess...
		if (target === this.optionsPrivate.element) {
			/**
			 * * intersection state changed
			 * updateContainerBounds => 	never
			 * updateElementBounds =>		schedule regardless, if intersection state changed, position likely did, too.
			 * updateViewportObserver =>	never
			 * updateProgress =>			schedule regardless, execute regardless
			 */
			this.updateIntersectingState(intersecting);

			this.update.elementBounds.schedule();
			this.update.progress.schedule();
		}
	}

	protected triggerEvent(type: EventType, forward: boolean): void {
		this.dispatcher.dispatchEvent(new ScrollMagicEvent(this, type, forward));
	}

	/**
	 * Update one or more options on this instance. Only changed values trigger internal recalculations.
	 *
	 * @param options - Partial set of public options to merge.
	 * @returns The instance, for chaining.
	 *
	 * @example
	 * ```js
	 * sm.modify({ elementStart: '25%', containerEnd: 100 });
	 * ```
	 */
	public modify(options: Options.Public): ScrollMagic {
		if (this.guardInert()) {
			return this;
		}
		const { sanitized, processed } = processOptions(options, this.optionsPrivate);

		const changedOptions =
			(
				undefined === this.optionsPublic // not set on first run, so all changed
			) ?
				sanitized
			:	pickDifferencesFlat(sanitized, this.optionsPublic);

		this.optionsPublic = { ...this.optionsPublic, ...changedOptions };
		this.optionsPrivate = processed;

		this.onOptionChanges(changedOptions);
		this.plugins.forEach(plugin => plugin.onModify?.call(this, changedOptions));
		return this;
	}

	/**
	 * Register a plugin on this instance. The plugin's `onAdd` callback is invoked immediately.
	 *
	 * @param plugin - The plugin to add.
	 * @returns The instance, for chaining.
	 */
	public addPlugin(plugin: Plugin): ScrollMagic {
		if (this.guardInert()) {
			return this;
		}
		this.plugins.add(plugin);
		plugin.onAdd?.call(this);
		return this;
	}

	/**
	 * Unregister a plugin from this instance. The plugin's `onRemove` callback is invoked immediately.
	 *
	 * @param plugin - The plugin to remove.
	 * @returns The instance, for chaining.
	 */
	public removePlugin(plugin: Plugin): ScrollMagic {
		if (this.guardInert()) {
			return this;
		}
		this.plugins.delete(plugin);
		plugin.onRemove?.call(this);
		return this;
	}

	// getter/setter public

	/** Set the tracked element. Accepts an `Element`, a CSS selector, or `null` to reset. */
	public set element(element: Required<Options.Public>['element']) {
		this.modify({ element });
	}
	/** The resolved tracked DOM element. */
	public get element(): Options.Private['element'] {
		return this.optionsPrivate.element;
	}
	/** Set the start inset on the tracked element. Positive values shrink the tracked region from the leading edge. */
	public set elementStart(elementStart: Required<Options.Public>['elementStart']) {
		this.modify({ elementStart });
	}
	/** The current start inset value for the tracked element (as originally provided). */
	public get elementStart(): Required<Options.Public>['elementStart'] {
		return this.optionsPublic.elementStart;
	}
	/** Set the end inset on the tracked element. Positive values shrink the tracked region from the trailing edge. */
	public set elementEnd(elementEnd: Required<Options.Public>['elementEnd']) {
		this.modify({ elementEnd });
	}
	/** The current end inset value for the tracked element (as originally provided). */
	public get elementEnd(): Required<Options.Public>['elementEnd'] {
		return this.optionsPublic.elementEnd;
	}
	/** Set the scroll container. Accepts a `Window`, `Element`, CSS selector, or `null` to reset. */
	public set container(container: Required<Options.Public>['container']) {
		this.modify({ container });
	}
	/** The resolved scroll container (`Window` or `HTMLElement`). */
	public get container(): Options.Private['container'] {
		return this.optionsPrivate.container;
	}
	/** Set the start inset on the scroll container. Set to `null` to infer based on `element`. */
	public set containerStart(containerStart: Required<Options.Public>['containerStart']) {
		this.modify({ containerStart });
	}
	/** The current start inset value for the scroll container (as originally provided). */
	public get containerStart(): Required<Options.Public>['containerStart'] {
		return this.optionsPublic.containerStart;
	}
	/** Set the end inset on the scroll container. Set to `null` to infer based on `element`. */
	public set containerEnd(containerEnd: Required<Options.Public>['containerEnd']) {
		this.modify({ containerEnd });
	}
	/** The current end inset value for the scroll container (as originally provided). */
	public get containerEnd(): Required<Options.Public>['containerEnd'] {
		return this.optionsPublic.containerEnd;
	}
	/** Set the scroll axis. `true` = vertical, `false` = horizontal. */
	public set vertical(vertical: Required<Options.Public>['vertical']) {
		this.modify({ vertical });
	}
	/** Whether this instance tracks vertical (`true`) or horizontal (`false`) scroll. */
	public get vertical(): Options.Private['vertical'] {
		return this.optionsPrivate.vertical;
	}

	/** Current scroll progress through the active zone, from 0 (before) to 1 (past). */
	public get progress(): number {
		return this.currentProgress;
	}
	/** Raw scroll velocity in pixels per second along the tracked axis (no smoothing). Returns 0 when disabled or not scrolling. */
	public get scrollVelocity(): number {
		if (this.disabled) {
			return 0;
		}
		return this.containerProxy.scrollVelocity[agnosticProps(this.optionsPrivate.vertical).axis];
	}
	/** Returns the scroll container's scroll positions at which tracking starts and ends. Triggers a synchronous layout read (cached values when disabled). */
	public get activeRange(): { start: number; end: number } {
		if (this.guardInert()) {
			return { start: 0, end: 0 };
		}
		if (!this.disabled) {
			this.updateElementBoundsCache(); // need to get fresh position — skip when disabled to avoid mixing fresh element bounds with stale container bounds
		}
		const { container, vertical } = this.optionsPrivate;
		const { start: elementPosition, offsetStart, trackSize } = this.elementBoundsCache;
		const {
			clientSize: containerSize,
			offsetStart: containerOffsetStart,
			offsetEnd: containerOffsetEnd,
		} = this.containerBoundsCache;
		const scrollOffset = getScrollPos(container)[agnosticProps(vertical).start];

		const absolutePosition = elementPosition + scrollOffset;
		const start = absolutePosition + offsetStart;
		const end = start + trackSize;
		return {
			start: Math.floor(start - containerOffsetStart),
			end: Math.ceil(end - containerSize + containerOffsetEnd),
		};
	}
	/** Resolved pixel offsets for container zone and element boundaries, based on current layout. */
	public get resolvedBounds(): Readonly<ResolvedBounds> {
		return {
			element: { ...this.elementBoundsCache },
			container: { ...this.containerBoundsCache },
		};
	}
	/** Snapshot of all currently registered plugins. */
	public get pluginList(): Array<Plugin> {
		return [...this.plugins];
	}
	/** Whether tracking is currently paused (via `disable()`) or the instance has been destroyed. */
	public get disabled(): boolean {
		return !this.enabled || this.destroyed;
	}

	public get [Symbol.toStringTag](): string {
		return this.name;
	}

	/**
	 * Add an event listener.
	 *
	 * @param type - The event type to listen for.
	 * @param cb - Callback invoked with a {@link ScrollMagicEvent}.
	 * @param options - Optional settings. `once` auto-removes the listener after its first invocation; `signal` removes it when the given {@link AbortSignal} aborts. Both can be combined.
	 * @returns The instance, for chaining.
	 *
	 * @example
	 * ```js
	 * sm.on('progress', (e) => {
	 *   console.log(e.target.progress); // 0–1
	 * });
	 *
	 * // Fire only once
	 * sm.on('enter', (e) => console.log('entered!'), { once: true });
	 *
	 * // Remove all listeners at once via AbortController
	 * const ac = new AbortController();
	 * sm.on('enter', onEnter, { signal: ac.signal });
	 * sm.on('leave', onLeave, { signal: ac.signal });
	 * // Later: ac.abort();
	 * ```
	 */
	public on(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, options?: ListenerOptions): ScrollMagic {
		if (this.guardInert()) {
			return this;
		}
		this.dispatcher.addEventListener(type, cb, options);
		return this;
	}
	/**
	 * Remove a previously registered event listener.
	 *
	 * @param type - The event type the listener was registered for.
	 * @param cb - The exact callback reference passed to {@link on}.
	 * @returns The instance, for chaining.
	 */
	public off(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void): ScrollMagic {
		if (this.guardInert()) {
			return this;
		}
		this.dispatcher.removeEventListener(type, cb);
		return this;
	}
	/**
	 * Add an event listener and receive a disposer function to remove it.
	 * Unlike {@link on}, this is not chainable — it returns the unsubscribe function instead.
	 *
	 * @param type - The event type to listen for.
	 * @param cb - Callback invoked with a {@link ScrollMagicEvent}.
	 * @param options - Optional settings. `once` auto-removes the listener after its first invocation; `signal` removes it when the given {@link AbortSignal} aborts. Both can be combined.
	 * @returns A function that removes the listener when called.
	 *
	 * @example
	 * ```js
	 * const unsub = sm.subscribe('enter', (e) => console.log('entered!'));
	 * // Later:
	 * unsub();
	 * ```
	 */
	public subscribe(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, options?: ListenerOptions): () => void {
		if (this.guardInert()) {
			return () => {};
		}
		return this.dispatcher.addEventListener(type, cb, options);
	}

	/**
	 * Schedule a full recalculation of element bounds, container bounds, viewport observer, and progress.
	 *
	 * @remarks Updates run asynchronously on the next animation frame.
	 * @returns The instance, for chaining.
	 */
	public refresh(): ScrollMagic {
		if (this.guardInert() || this.disabled) {
			return this;
		}
		this.update.elementBounds.schedule();
		this.update.containerBounds.schedule();
		this.update.viewportObserver.schedule();
		this.update.progress.schedule();
		return this;
	}

	/**
	 * Pause tracking — disconnects all observers and freezes progress.
	 * Options can still be modified while disabled.
	 *
	 * @returns The instance, for chaining.
	 * @see {@link enable} to resume tracking.
	 *
	 * @example
	 * ```js
	 * sm.disable();
	 * // progress and events are frozen
	 * sm.enable(); // resume
	 * ```
	 */
	public disable(): ScrollMagic {
		if (this.guardInert() || this.disabled) return this;
		this.enabled = false;
		this.executionQueue.cancel();
		this.resizeCleanup?.();
		this.resizeCleanup = undefined;
		this.viewportObserver.disconnect();
		this.containerProxy.detach();
		this.plugins.forEach(plugin => plugin.onDisable?.call(this));
		return this;
	}

	/**
	 * Resume tracking — reconnects all observers and recalculates from current state.
	 *
	 * @returns The instance, for chaining.
	 * @see {@link disable} to pause tracking.
	 *
	 * @example
	 * ```js
	 * sm.enable();
	 * ```
	 */
	public enable(): ScrollMagic {
		if (this.guardInert() || this.enabled) return this;
		this.enabled = true;
		const { element, container } = this.optionsPrivate;
		this.updateIntersectingState(undefined);
		this.viewportObserver.observe(element);
		this.resizeCleanup = observeResize(element, this.onElementResize.bind(this));
		this.containerProxy.attach(container, this.onContainerUpdate.bind(this));
		this.elementBoundsCache.size = NaN; // force converter recalculation
		this.update.elementBounds.schedule();
		this.update.containerBounds.schedule();
		this.update.viewportObserver.schedule();
		this.update.progress.schedule();
		this.plugins.forEach(plugin => plugin.onEnable?.call(this));
		return this;
	}

	/**
	 * Permanently tear down this instance — disconnects all observers, removes all plugins,
	 * and deregisters from {@link ScrollMagic.refreshAll} / {@link ScrollMagic.destroyAll}.
	 * The instance cannot be used after calling this method.
	 */
	public destroy(): void {
		if (this.destroyed || !isBrowser) {
			return;
		}
		this.disable(); // tear down observers (no-ops if already disabled), fires onDisable
		this.destroyed = true;
		ScrollMagic.instances.delete(this);
		this.plugins.forEach(plugin => plugin.onDestroy?.call(this));
		this.plugins.clear();
	}

	// static options/methods

	private static readonly instances = new Set<ScrollMagic>();
	/** Schedule a full recalculation on all active ScrollMagic instances. */
	public static refreshAll(): void {
		ScrollMagic.instances.forEach(instance => instance.refresh());
	}
	/** Destroy all active ScrollMagic instances. */
	public static destroyAll(): void {
		ScrollMagic.instances.forEach(instance => instance.destroy());
	}

	protected static defaultOptionsPublic = Options.defaults;
	/**
	 * Get or update the default options applied to all future ScrollMagic instances.
	 *
	 * @param options - Partial options to merge into the current defaults.
	 * @returns The full set of current default options.
	 *
	 * @example
	 * ```js
	 * // Set a global default container
	 * ScrollMagic.defaultOptions({ container: '#main-scroller' });
	 *
	 * // Read current defaults
	 * const defaults = ScrollMagic.defaultOptions();
	 * ```
	 */
	public static defaultOptions(options: Options.Public = {}): Required<Options.Public> {
		this.defaultOptionsPublic = {
			...this.defaultOptionsPublic,
			...sanitizeOptions(options),
		};
		return this.defaultOptionsPublic;
	}
	/** Enum of event types: `Enter`, `Leave`, `Progress`. @see {@link EventType} */
	public static readonly EventType = EventType;
	/** Enum of event locations: `Start`, `Inside`, `End`. @see {@link EventLocation} */
	public static readonly EventLocation = EventLocation;
	/** Enum of scroll directions: `Forward`, `Reverse`. @see {@link ScrollDirection} */
	public static readonly EventScrollDirection = ScrollDirection;
}


================================================
FILE: src/ScrollMagicError.ts
================================================
/** Base error class for all ScrollMagic errors. */
export class ScrollMagicError extends Error {
	public override readonly name: string = 'ScrollMagicError';
	public get [Symbol.toStringTag]() {
		return this.name;
	}
	constructor(message: string, options?: ErrorOptions) {
		super(message, options);
	}
}
/** Error class for unexpected internal failures — indicates a bug in ScrollMagic itself. */
export class ScrollMagicInternalError extends ScrollMagicError {
	public override readonly name = 'ScrollMagicInternalError';
	constructor(message: string, options?: ErrorOptions) {
		super(`Internal Error: ${message}`, options);
	}
}


================================================
FILE: src/ScrollMagicEvent.ts
================================================
import type { DispatchableEvent } from './EventDispatcher';
import type { ScrollMagic } from './ScrollMagic';

/** Lifecycle event types dispatched by a ScrollMagic instance. */
export enum EventType {
	/** Fired when the element enters the active scroll zone. */
	Enter = 'enter',
	/** Fired when the element leaves the active scroll zone. */
	Leave = 'leave',
	/** Fired on every scroll update while the element is inside the active zone. */
	Progress = 'progress',
}

/** Where the event occurred relative to the active scroll zone. */
export enum EventLocation {
	/** At the leading edge of the zone (entering forward or leaving in reverse). */
	Start = 'start',
	/** Between the start and end edges. */
	Inside = 'inside',
	/** At the trailing edge of the zone (entering in reverse or leaving forward). */
	End = 'end',
}

/** Scroll direction at the time the event was dispatched. */
export enum ScrollDirection {
	/** Scrolling toward higher scroll offsets (down or right). */
	Forward = 'forward',
	/** Scrolling toward lower scroll offsets (up or left). */
	Reverse = 'reverse',
}

type EnumOrLiteral<T extends string> = T | `${T}`;
type ScrollMagicEventType = EnumOrLiteral<EventType>;
type ScrollMagicEventLocation = EnumOrLiteral<EventLocation>;
type ScrollMagicEventScrollDirection = EnumOrLiteral<ScrollDirection>;

/**
 * Event object dispatched by ScrollMagic on lifecycle transitions and progress updates.
 *
 * Instances are created internally and passed to listeners registered via {@link ScrollMagic.on} or {@link ScrollMagic.subscribe}.
 */
export class ScrollMagicEvent implements DispatchableEvent {
	/** Where the event occurred relative to the active zone (`'start'`, `'inside'`, or `'end'`). */
	public readonly location: ScrollMagicEventLocation;
	/** Scroll direction at the time of the event (`'forward'` or `'reverse'`). */
	public readonly direction: ScrollMagicEventScrollDirection;
	constructor(
		/** The ScrollMagic instance that dispatched this event. */
		public readonly target: ScrollMagic,
		/** The event type (`'enter'`, `'leave'`, or `'progress'`). */
		public readonly type: ScrollMagicEventType,
		movingForward: boolean
	) {
		this.location = (() => {
			if (EventType.Progress === type) {
				return EventLocation.Inside;
			}
			if ((EventType.Enter === type && movingForward) || (EventType.Leave === type && !movingForward)) {
				return EventLocation.Start;
			}
			return EventLocation.End;
		})();
		this.direction = movingForward ? ScrollDirection.Forward : ScrollDirection.Reverse;
	}
}


================================================
FILE: src/ViewportObserver.ts
================================================
import { pickDifferencesFlat } from './util/pickDifferencesFlat';

type Margin = {
	top: string;
	right: string;
	bottom: string;
	left: string;
};

interface Options {
	root?: Element | null; // null is window
	margin?: Margin;
	vertical?: boolean;
}

type ObserverCallback = (isIntersecting: boolean, target: Element) => void;

// this ensures the order in the object doesn't matter
const marginObjToString = ({ top, right, bottom, left }: Margin) => [top, right, bottom, left].join(' ');

const none = '0px';

// resolves the combined state of enter/leave observers into a single boolean or undefined (if not yet fully initialized)
const resolveState = (hitEnter: boolean | undefined, hitLeave: boolean | undefined): boolean | undefined => {
	if (hitEnter === undefined || hitLeave === undefined) return undefined;
	return hitEnter && hitLeave;
};

export class ViewportObserver {
	private observerEnter?: IntersectionObserver;
	private observerLeave?: IntersectionObserver;
	private options: Required<Options> = {
		root: null,
		margin: { top: none, right: none, bottom: none, left: none },
		vertical: true,
	};
	private observedElements = new Map<Element, [boolean | undefined, boolean | undefined]>();
	constructor(
		private callback: ObserverCallback,
		options?: Options
	) {
		if (undefined === options) {
			return; // nothing will happen, until modify is called.
		}
		this.options = {
			...this.options,
			...options,
		};
	}
	private observerCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
		entries.forEach(({ target, isIntersecting }) => {
			let [hitEnter, hitLeave] = this.observedElements.get(target) ?? [];
			const prevState = resolveState(hitEnter, hitLeave);
			if (observer === this.observerEnter) {
				hitEnter = isIntersecting;
			} else {
				hitLeave = isIntersecting;
			}
			this.observedElements.set(target, [hitEnter, hitLeave]);
			const newState = resolveState(hitEnter, hitLeave);
			if (undefined === newState || prevState === newState) {
				return;
			}
			this.callback(newState, target);
		});
	}
	private createObserver(rootMargin: string) {
		const root = this.options.root;
		const observer = new IntersectionObserver(this.observerCallback.bind(this), { root, rootMargin });
		[...this.observedElements.keys()].forEach(elem => observer.observe(elem));
		return observer;
	}
	private rebuildObserver() {
		this.observerEnter?.disconnect();
		this.observerLeave?.disconnect();
		const { margin, vertical } = this.options;
		const clampPositive = (val: string) => `${Math.max(0, parseFloat(val))}%`;

		// The enter observer clips the "leave side" margin to >= 0 (so it doesn't shrink the viewport on that side).
		// The leave observer clips the "enter side" margin to >= 0.
		// For vertical: leave side = top, enter side = bottom.
		// For horizontal: leave side = left, enter side = right.
		// TODO: check what happens, if the opposite value still overlaps (due to offset / height ?)
		// TODO! I know now: if effective duration exceeds available observer height it fails... -> BUG! -> FIX...
		const marginEnter =
			vertical ? { ...margin, top: clampPositive(margin.top) } : { ...margin, left: clampPositive(margin.left) };
		const marginLeave =
			vertical ?
				{ ...margin, bottom: clampPositive(margin.bottom) }
			:	{ ...margin, right: clampPositive(margin.right) };

		this.observerEnter = this.createObserver(marginObjToString(marginEnter));
		this.observerLeave = this.createObserver(marginObjToString(marginLeave));
	}
	private optionsChanged({ root, margin, vertical }: Options) {
		if (undefined !== root && root !== this.options.root) {
			return true;
		}
		if (undefined !== vertical && vertical !== this.options.vertical) {
			return true;
		}
		if (undefined !== margin) {
			return Object.keys(pickDifferencesFlat(margin, this.options.margin)).length > 0;
		}
		return false;
	}

	public modify(options: Options): ViewportObserver {
		if (!this.optionsChanged(options)) {
			return this;
		}
		this.options = {
			...this.options,
			...options,
		};
		this.rebuildObserver();
		return this;
	}
	public observe(elem: Element): ViewportObserver {
		if (!this.observedElements.has(elem)) {
			this.observedElements.set(elem, [undefined, undefined]);
			this.observerEnter?.observe(elem);
			this.observerLeave?.observe(elem);
		}
		return this;
	}
	public unobserve(elem: Element): ViewportObserver {
		if (this.observedElements.delete(elem)) {
			this.observerEnter?.unobserve(elem);
			this.observerLeave?.unobserve(elem);
		}
		return this;
	}
	public disconnect(): void {
		this.observedElements.clear();
		this.observerEnter?.disconnect();
		this.observerLeave?.disconnect();
	}
}


================================================
FILE: src/env.d.ts
================================================
// Ambient type for process.env.NODE_ENV — used for dev-only warnings.
// Bundlers replace process.env.NODE_ENV at build time; no Node.js dependency needed.
declare const process: undefined | { env: { NODE_ENV?: string } };


================================================
FILE: src/index.ts
================================================
import type { Public as ScrollMagicOptions } from './Options';
import type { Plugin as ScrollMagicPlugin } from './ScrollMagic';
import { EventLocation, EventType, ScrollDirection } from './ScrollMagicEvent';

// make literals from enums for export
type EventTypeLiteral = `${EventType}`;
type EventLocationLiteral = `${EventLocation}`;
type ScrollDirectionLiteral = `${ScrollDirection}`;

export { ScrollMagic as default } from './ScrollMagic';

// relevant types
export type { ScrollMagicError } from './ScrollMagicError';
export type { ScrollMagicEvent } from './ScrollMagicEvent';
export type { ListenerOptions } from './EventDispatcher';
export type { ScrollMagicPlugin };
export type { ScrollMagicOptions };
export type { ElementBounds, ContainerBounds, ResolvedBounds } from './ScrollMagic';

// less relevant enum types as literals
export type {
	EventTypeLiteral as EventType,
	EventLocationLiteral as EventLocation,
	ScrollDirectionLiteral as ScrollDirection,
};


================================================
FILE: src/util/agnosticValues.ts
================================================
import { transformObject } from './transformObject';

// { agnosticProp: [verticalProp, horizontalProp] }
const translationMap = {
	start: ['top', 'left'],
	end: ['bottom', 'right'],
	size: ['height', 'width'],
	clientSize: ['clientHeight', 'clientWidth'],
	scrollSize: ['scrollHeight', 'scrollWidth'],
	axis: ['y', 'x'],
} as const;

type TranslationMap = typeof translationMap;
type AgnosticProps = keyof TranslationMap;
type TranslateProp<K extends AgnosticProps, V extends boolean> = TranslationMap[K][V extends true ? 0 : 1];
type Vertical = { [K in AgnosticProps]: TranslateProp<K, true> };
type Horizontal = { [K in AgnosticProps]: TranslateProp<K, false> };

// cache props
const flat = (index: number) => transformObject(translationMap, ([key, value]) => [key, value[index]]);
const propsV = flat(0) as Vertical;
const propsH = flat(1) as Horizontal;

/**
 * Returns a map of direction-agnostic property names to their orientation-specific counterparts.
 * @param vertical - Scroll direction (`true` = vertical, `false` = horizontal).
 * @returns A mapping like `{ start: 'top', size: 'height', ... }` for vertical, or `{ start: 'left', size: 'width', ... }` for horizontal.
 */
export function agnosticProps(vertical: true): Vertical;
export function agnosticProps(vertical: false): Horizontal;
export function agnosticProps(vertical: boolean): Vertical | Horizontal;
export function agnosticProps(vertical: boolean): Vertical | Horizontal {
	return vertical ? propsV : propsH;
}

type MatchProp<K extends string, T extends Record<string, unknown>> = K extends keyof T ? T[K] : never;
type GetType<V extends boolean, T extends Record<string, unknown>> = {
	[K in AgnosticProps]: MatchProp<TranslateProp<K, V>, T>;
};
/**
 * Extracts direction-relevant values from an object using orientation-aware property names.
 *
 * For vertical: `top` → `start`, `height` → `size`, `y` → `axis`, etc.
 * For horizontal: `left` → `start`, `width` → `size`, `x` → `axis`, etc.
 *
 * @param vertical - Scroll direction (`true` = vertical, `false` = horizontal).
 * @param obj - Source object to extract values from (e.g. a `DOMRect` or scroll delta).
 * @returns An object with agnostic keys (`start`, `end`, `size`, `clientSize`, `scrollSize`, `axis`) mapped to the corresponding values.
 */
export const agnosticValues = <V extends boolean, T extends { [key: string]: any }>(
	vertical: V,
	obj: T
): GetType<V, T> => transformObject(agnosticProps(vertical), ([key, value]) => [key, obj[value]]);


================================================
FILE: src/util/getScrollContainerDimensions.ts
================================================
import { isWindow } from './typeguards';

interface Dimensions {
	readonly clientWidth: number;
	readonly clientHeight: number;
	readonly scrollWidth: number;
	readonly scrollHeight: number;
}

// info limited to what we need...
export const getScrollContainerDimensions = (element: Window | Element): Dimensions => {
	const elem = isWindow(element) ? document.documentElement : element;
	const { clientWidth, scrollHeight, scrollWidth } = elem;
	let { clientHeight } = elem;
	if (isWindow(element) && null != window.visualViewport) {
		// visualViewport.height accounts for mobile browser chrome (address bar show/hide)
		// multiplying by scale compensates for pinch-zoom, giving us the layout viewport height
		clientHeight = window.visualViewport.height * window.visualViewport.scale;
	}
	return {
		clientWidth,
		clientHeight,
		scrollHeight,
		scrollWidth,
	};
};


================================================
FILE: src/util/getScrollPos.ts
================================================
import { isWindow } from './typeguards';

const scrollTop = (container: Window | Element): number =>
	isWindow(container) ? (window.scrollY ?? window.pageYOffset) : container.scrollTop;

const scrollLeft = (container: Window | Element): number =>
	isWindow(container) ? (window.scrollX ?? window.pageXOffset) : container.scrollLeft;

export const getScrollPos = (container: Window | Element): { left: number; top: number } => ({
	left: scrollLeft(container),
	top: scrollTop(container),
});


================================================
FILE: src/util/pickDifferencesFlat.ts
================================================
// checks an object against a reference object and returns a new object containing only differences in direct descendents (one way!)
export const pickDifferencesFlat = <T extends Record<string, any>>(part: Partial<T>, full: T): Partial<T> =>
	Object.fromEntries(Object.entries(part).filter(([key, value]) => value !== full[key])) as Partial<T>;


================================================
FILE: src/util/processProperties.ts
================================================
import { ScrollMagicError } from '../ScrollMagicError';

// type to ensure there's an output processor for every input
export type PropertyProcessors<I extends { [X in keyof I]: unknown }, O extends { [X in keyof I]: unknown }> = {
	[X in keyof I]: (value: Required<I>[X]) => O[X];
};

/**
 * A function that can be used to validate the properties of an object based on predefined rules.
 * @param obj the object that should be processed
 * @param processors an object with matching keys, which defines how to normalize and or validate a property
 * @param getErrorMessage A function that returns the format for the error message, should normalize or check fail.
 * @returns the normalized and checked object
 */

export const processProperties = <
	I extends { [X in keyof I]: any },
	P extends { [X in K]?: (value: Required<I>[X]) => any },
	O extends { [X in K]: P[X] extends (...args: any[]) => infer R ? R : I[X] },
	K extends keyof I,
>(
	obj: I,
	processors: P,
	getErrorMessage: (value: unknown, prop: keyof I) => string = (value, prop) =>
		`Invalid value ${String(value)} for ${String(prop)}.`
): O => {
	return Object.keys(obj).reduce((result, key) => {
		const prop = key as K;
		const value = obj[prop];
		const processor = processors[prop];
		let processedValue: O[K];
		try {
			// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- generic processor output is intentionally any
			processedValue = processor?.(value) ?? value;
		} catch (e: unknown) {
			const reason = e instanceof ScrollMagicError ? ` ${e.message}` : '';
			throw new ScrollMagicError(getErrorMessage(value, prop) + reason, { cause: e });
		}
		result[prop] = processedValue;
		return result;
	}, {} as O);
};


================================================
FILE: src/util/rafQueue.ts
================================================
type Flushable = { execute(): void };

/**
 * Batches execution of multiple Flushable items into a single requestAnimationFrame.
 *
 * Items marked dirty via `schedule()` are collected in a Set (deduped) and executed
 * together — either when the rAF fires or when `flush()` is called explicitly.
 *
 * In the hot path (scroll/resize), callers invoke `flush()` directly after dispatching
 * events, so all downstream work executes in the same frame. The rAF serves as a
 * fallback for work scheduled outside of event dispatch (e.g. initial setup, programmatic
 * option changes).
 */
class RafQueue {
	private dirty = new Set<Flushable>();
	private rafId = 0;

	/** Mark an item for execution. Requests a rAF if none is pending. */
	schedule(item: Flushable): void {
		this.dirty.add(item);
		if (0 === this.rafId) {
			this.rafId = requestAnimationFrame(() => {
				this.rafId = 0;
				this.flush();
			});
		}
	}

	/** Remove an item from the dirty set, preventing its execution. */
	unschedule(item: Flushable): void {
		this.dirty.delete(item);
	}

	/** Execute all dirty items immediately and cancel any pending rAF. */
	flush(): void {
		if (0 !== this.rafId) {
			cancelAnimationFrame(this.rafId);
			this.rafId = 0;
		}
		const items = [...this.dirty];
		this.dirty.clear();
		items.forEach(item => item.execute());
	}
}

export const rafQueue = new RafQueue();


================================================
FILE: src/util/registerEvent.ts
================================================
/**
 * Adds the passed listener as an event listener to the passed event target, and returns a function which reverses the
 * effect of this function.
 * @param {*} target object the listener should be attached to
 * @param {*} type type of listener
 * @param {*} listener callback
 * @param {*} options Event listener options
 */
export const registerEvent = (
	target: GlobalEventHandlers,
	type: keyof (GlobalEventHandlersEventMap & WindowEventMap), // this does not catch if the wrong event is used on the wrong target, but should be stricter than 'string'
	listener: EventListenerOrEventListenerObject,
	options?: boolean | AddEventListenerOptions
): (() => void) => {
	target.addEventListener(type, listener, options);
	return target.removeEventListener.bind(target, type, listener, options);
};


================================================
FILE: src/util/sanitizeProperties.ts
================================================
export const sanitizeProperties = <T extends Record<string, any>>(obj: T, defaults: Record<string, any>): T =>
	Object.entries(obj).reduce((res, [key, value]) => {
		if (key in defaults === false) {
			if (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {
				console?.warn(`ScrollMagic Warning: Unknown property ${key} will be disregarded`);
			}
			return res;
		}
		// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- value from Object.entries of generic Record
		res[key as keyof T] = value;
		return res;
	}, {} as T);


================================================
FILE: src/util/sharedResizeObserver.ts
================================================
/**
 * Shared ResizeObserver — uses a single observer instance for all elements,
 * routing entries to per-element callback sets via a WeakMap.
 *
 * After all callbacks for a batch of entries have fired, the RafQueue is
 * flushed so downstream work (ExecutionQueues) executes in the same frame.
 *
 * Safe to call in non-browser environments — returns a no-op cleanup if
 * ResizeObserver is unavailable.
 */
import { rafQueue } from './rafQueue';

type ResizeCallback = () => void;

const callbacks = new WeakMap<Element, Set<ResizeCallback>>();
let observer: ResizeObserver | undefined; // undefined = not yet created, vs null from getObserver = unavailable

const handleResize: ResizeObserverCallback = entries => {
	// Collect all affected callbacks first, then fire — avoids issues if a
	// callback modifies the callback set (e.g. by calling observeResize/cleanup).
	const affected = new Set<ResizeCallback>();
	for (const entry of entries) {
		callbacks.get(entry.target)?.forEach(cb => affected.add(cb));
	}
	affected.forEach(cb => cb());
	rafQueue.flush();
};

const noop = () => {};

/** Returns the shared observer, or null if ResizeObserver is unavailable (SSR). */
const getObserver = (): ResizeObserver | null => {
	if ('undefined' === typeof ResizeObserver) return null;
	if (undefined === observer) {
		observer = new ResizeObserver(handleResize);
	}
	return observer;
};

/**
 * Observe an element for resize. Returns a cleanup function that removes the
 * callback and unobserves the element when no callbacks remain.
 */
export const observeResize = (element: Element, callback: ResizeCallback): (() => void) => {
	const obs = getObserver();
	if (null === obs) return noop;
	let cbs = callbacks.get(element);
	if (undefined === cbs) {
		cbs = new Set();
		callbacks.set(element, cbs);
		obs.observe(element);
	}
	cbs.add(callback);
	return () => {
		const cbs = callbacks.get(element);
		if (undefined === cbs) return;
		cbs.delete(callback);
		if (0 === cbs.size) {
			callbacks.delete(element);
			observer?.unobserve(element);
		}
	};
};


================================================
FILE: src/util/throttleRaf.ts
================================================
export const throttleRaf = <F extends (...a: unknown[]) => any>(
	func: F
): ((this: ThisParameterType<F>, ...args: Parameters<F>) => void) & {
	cancel: () => void;
} => {
	let requestId = 0; // rAF returns non-zero values, so 0 represents no request pending

	const scheduled = function (this: ThisParameterType<F>, ...args: Parameters<F>) {
		if (0 !== requestId) {
			return;
		}
		requestId = requestAnimationFrame(() => {
			requestId = 0;
			func.apply(this, args);
		});
	};

	scheduled.cancel = () => {
		cancelAnimationFrame(requestId);
		requestId = 0;
	};
	return scheduled;
};


================================================
FILE: src/util/transformObject.ts
================================================
/**
 * Type-safe `Object.fromEntries(Object.entries(obj).map(fn))`.
 * The generics preserve key/value types through the transformation, avoiding manual casts at call sites.
 */
export function transformObject<
	T extends Record<string | number | symbol, unknown>,
	R extends [key: string | number | symbol, value: unknown],
>(object: T, transform: (entry: [key: keyof T, value: T[keyof T]]) => R) {
	return Object.fromEntries(
		Object.entries(object as Record<keyof T, T[keyof T]>).map(transform)
	) as Record<R[0], R[1]>;
}


================================================
FILE: src/util/transformers.ts
================================================
import { positionShorthands } from '../Options';
import { ScrollMagicError } from '../ScrollMagicError';
import { isHTMLElement, isSVGElement, isWindow } from './typeguards';

type PixelConverter = (size: number) => number;
type UnitTuple = [number, 'px' | '%'];

export const numberToPercString = (val: number, decimals: number): string => `${(val * 100).toFixed(decimals)}%`;

const unitTupleToPixelConverter = ([value, unit]: UnitTuple): PixelConverter => {
	return 'px' === unit || 0 === value ? () => value : (size: number) => (value / 100) * size;
};

export const unitStringToPixelConverter = (val: string): PixelConverter => {
	const match = val.match(/^([+-])?(\d+|\d*[.]\d+)(%|px)$/);
	if (null === match) {
		const names = Object.keys(positionShorthands).join(', ');
		throw new ScrollMagicError(
			`String value must be a number with unit (e.g. 20px, 80%) or a named position (${names})`
		);
	}
	const [, sign, digits, unit] = match as [string, '+' | '-' | null, string, 'px' | '%'];
	return unitTupleToPixelConverter([parseFloat(`${sign ?? ''}${digits}`), unit]);
};

export const toPixelConverter = (val: number | string | PixelConverter): PixelConverter => {
	if ('number' === typeof val) {
		return () => val;
	}
	if ('string' === typeof val) {
		const unitString = val in positionShorthands ? positionShorthands[val as keyof typeof positionShorthands] : val;
		return unitStringToPixelConverter(unitString);
	}
	// ok, user passed in a function, let's see if it works.
	let returnsNumber: boolean;
	try {
		returnsNumber = 'number' === typeof val(1);
	} catch {
		throw new ScrollMagicError('Unsupported value type');
	}
	if (!returnsNumber) {
		throw new ScrollMagicError('Function must return a number');
	}
	return val;
};

export const selectorToSingleElement = (selector: string): Element => {
	const elem = document.querySelector(selector);
	if (null === elem) {
		throw new ScrollMagicError(`No element found for selector ${selector}`);
	}
	if (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {
		const all = document.querySelectorAll(selector);
		if (all.length > 1) {
			console?.warn(
				`ScrollMagic Warning: Selector "${selector}" matched ${all.length} elements, using only the first. Create one ScrollMagic instance per element to track all of them.`
			);
		}
	}
	return elem;
};

export const toSvgOrHtmlElement = (reference: Element | string): HTMLElement | SVGElement => {
	const elem = 'string' === typeof reference ? selectorToSingleElement(reference) : reference;
	const { body } = document;
	if (!(isHTMLElement(elem) || isSVGElement(elem)) || !body.contains(elem)) {
		throw new ScrollMagicError('Invalid element supplied');
	}
	return elem;
};

export const toValidContainer = (container: Window | Element | string): HTMLElement | Window => {
	if (isWindow(container)) {
		return container;
	}
	const elem = toSvgOrHtmlElement(container);
	if (isSVGElement(elem)) {
		throw new ScrollMagicError(`Can't use SVG as container`);
	}
	return elem;
};

/** Wraps a function to pass `null` through without calling it. */
export const skipNull =
	<T, R>(fn: (val: T) => R) =>
	(val: T | null): R | null =>
		null === val ? null : fn(val);


================================================
FILE: src/util/typeguards.ts
================================================
export const isWindow = (val: unknown): val is Window => val instanceof Window;
export const isHTMLElement = (val: unknown): val is HTMLElement => val instanceof HTMLElement;
export const isSVGElement = (val: unknown): val is SVGElement => val instanceof SVGElement;


================================================
FILE: src/util.ts
================================================
export { agnosticProps, agnosticValues } from './util/agnosticValues';


================================================
FILE: tests/e2e/UNTESTED-KNOWN-BUGS.md
================================================
# Untested Known Bugs

Documentation for potential v3 issues derived from v2 bug reports that cannot be covered by automated e2e tests.
Testable cases are covered in `reported-issues.test.ts`.

## Require Real Mobile Devices

| Issue | Concern                                                                                 | Why untestable                                                                     |
| ----- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| #789  | iOS Safari momentum scroll may not fire IO callbacks reliably during fast flings        | Needs real iOS device; Playwright chromium can't reproduce momentum scroll physics |
| #479  | iOS momentum scrolling + toolbar resize compounding viewport measurement issues         | Same as #789 — real iOS Safari + physical momentum scrolling required              |
| #381  | Android virtual keyboard popup resizes viewport, may cause unexpected IO recalculations | Can't simulate keyboard appearance in headless/automated browsers                  |

## Require External Libraries or Specific Browser Features

| Issue | Concern                                                                                                      | Why untestable                                                                                                                                        |
| ----- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| #652  | Smooth scrollbar libraries using CSS transforms (no native scroll) bypass both IO and scroll events entirely | Known architectural limitation — transform-based scrolling is fundamentally incompatible with IO. Would need an actual smooth-scroll library to test. |
| #470  | Shadow DOM containers — IO `root` compatibility varies by browser                                            | Requires cross-browser testing (Shadow DOM IO root support is inconsistent). Single-browser Playwright test wouldn't catch the real issue.            |

## Not Assertable in Automated Tests

| Issue | Concern                                                                                                                 | Why untestable                                                                                                                                  |
| ----- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| #817  | Sub-pixel rendering differences across browsers may cause jitter in progress-driven transform animations                | Visual/rendering concern — no pass/fail assertion possible. Users applying scroll-driven transforms should use `Math.round()` or `will-change`. |
| #503  | Background tab throttling: rAF-driven progress events stall when tab is suspended, may cause state jumps on tab refocus | Can't suspend own tab programmatically. Inherent browser behavior outside library control.                                                      |


================================================
FILE: tests/e2e/caching.test.ts
================================================
/**
 * PixelConverter caching and bounds invalidation.
 * Tests for: converters not re-called on scroll (only on resize), modify() forcing recalculation,
 * direction changes invalidating caches, stale containerBoundsCache after container option changes.
 */
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, wait, waitForFrames } from './helpers';

describe('PixelConverter caching', () => {
	afterEach(cleanup);

	test('elementStart/elementEnd are not called on scroll when element size is unchanged', async () => {
		await page.viewport(1024, 768);
		// elementTop=300, height=200 — element is visible initially, stays intersecting as we scroll
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });

		let elementStartCalls = 0;
		let elementEndCalls = 0;
		const sm = new ScrollMagic({
			element: target,
			elementStart: () => {
				elementStartCalls++;
				return 0;
			},
			elementEnd: () => {
				elementEndCalls++;
				return 0;
			},
		});

		await waitForFrames();
		const callsAfterInit = elementStartCalls + elementEndCalls;

		// scroll while element remains intersecting (no resize)
		window.scrollTo(0, 100);
		await waitForFrames();
		window.scrollTo(0, 200);
		await waitForFrames();
		window.scrollTo(0, 250);
		await waitForFrames();

		expect(elementStartCalls + elementEndCalls).toBe(callsAfterInit);
		sm.destroy();
	});

	test('elementStart/elementEnd are called when element resizes', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });

		let elementStartCalls = 0;
		const sm = new ScrollMagic({
			element: target,
			elementStart: () => {
				elementStartCalls++;
				return 0;
			},
		});

		await waitForFrames();
		const callsAfterInit = elementStartCalls;

		target.style.height = '400px';
		await waitForFrames();

		expect(elementStartCalls).toBeGreaterThan(callsAfterInit);
		sm.destroy();
	});

	test('containerStart/containerEnd are not called on scroll', async () => {
		await page.viewport(1024, 768);
		// element taller than viewport so containerStart/End returning 0 doesn't cause a no-overlap warning
		const { target } = setupWindow({ elementTop: 300, elementHeight: 900 });

		let containerStartCalls = 0;
		let containerEndCalls = 0;
		const sm = new ScrollMagic({
			element: target,
			containerStart: () => {
				containerStartCalls++;
				return 0;
			},
			containerEnd: () => {
				containerEndCalls++;
				return 0;
			},
		});

		await waitForFrames();
		const callsAfterInit = containerStartCalls + containerEndCalls;

		window.scrollTo(0, 100);
		await waitForFrames();
		window.scrollTo(0, 200);
		await waitForFrames();
		window.scrollTo(0, 300);
		await waitForFrames();

		expect(containerStartCalls + containerEndCalls).toBe(callsAfterInit);
		sm.destroy();
	});

	test('containerStart/containerEnd are called when container resizes', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300 });

		let containerStartCalls = 0;
		const sm = new ScrollMagic({
			element: target,
			containerStart: () => {
				containerStartCalls++;
				return 0;
			},
		});

		await waitForFrames();
		const callsAfterInit = containerStartCalls;

		await page.viewport(1024, 500);
		await wait(50); // give ResizeObserver a moment to fire
		await waitForFrames();

		expect(containerStartCalls).toBeGreaterThan(callsAfterInit);
		sm.destroy();
	});

	test('elementStart/elementEnd are re-called after modify() even if element size is unchanged', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });

		const sm = new ScrollMagic({ element: target });
		await waitForFrames();

		let newConverterCalls = 0;
		sm.modify({
			elementStart: size => {
				newConverterCalls++;
				return size * 0.1;
			},
		});
		await waitForFrames();

		expect(newConverterCalls).toBeGreaterThan(0);
		sm.destroy();
	});

	test('elementBounds are recalculated when direction changes via modify()', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });

		let elementStartCalls = 0;
		const sm = new ScrollMagic({
			element: target,
			elementStart: () => {
				elementStartCalls++;
				return 0;
			},
		});

		await waitForFrames();
		const callsAfterInit = elementStartCalls;

		sm.modify({ vertical: false });
		await waitForFrames();

		expect(elementStartCalls).toBeGreaterThan(callsAfterInit);
		sm.destroy();
	});

	test('containerBounds are recalculated when direction changes via modify()', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });

		let containerStartCalls = 0;
		const sm = new ScrollMagic({
			element: target,
			containerStart: () => {
				containerStartCalls++;
				return 0;
			},
		});

		await waitForFrames();
		const callsAfterInit = containerStartCalls;

		sm.modify({ vertical: false });
		await waitForFrames();

		expect(containerStartCalls).toBeGreaterThan(callsAfterInit);
		sm.destroy();
	});

	test('containerStart/containerEnd take effect after modify() — stale containerBoundsCache', async () => {
		// Bug: containerBounds was not rescheduled when container options changed via modify(),
		// leaving stale offsetStart/offsetEnd in the cache.
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });

		const sm = new ScrollMagic({ element: target, containerStart: '0%' });
		window.scrollTo(0, 200);
		await waitForFrames();
		const progressBefore = sm.progress;

		sm.modify({ containerStart: '50%' });
		await waitForFrames();
		const progressAfter = sm.progress;

		expect(progressAfter).not.toBe(progressBefore);
		sm.destroy();
	});
});


================================================
FILE: tests/e2e/containers.test.ts
================================================
/**
 * Scroll container behavior: non-window scroll parents, container edge cases, viewport resize.
 * Tests for: div containers, position:fixed containers, container position initialization,
 * zero-size containers, viewport resize (mobile address bar).
 */
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import type { ScrollMagicEvent } from '../../src/index';
import { cleanup, setupContainer, setupWindow, wait, waitForFrames } from './helpers';

// #905, #1004: Non-window scroll containers with overflow:scroll.
describe('non-window scroll containers', () => {
	afterEach(cleanup);

	test('enter/leave/progress events fire in scrollable div container', async () => {
		await page.viewport(1024, 768);
		const { container, target } = setupContainer();

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target, container });
		sm.on('enter', () => events.push('enter'));
		sm.on('progress', () => events.push('progress'));
		sm.on('leave', () => events.push('leave'));

		container.scrollTop = 700;
		await waitForFrames(3);
		expect(events).toContain('enter');
		expect(sm.progress).toBeGreaterThan(0);

		container.scrollTop = 1500;
		await waitForFrames(3);
		expect(events).toContain('leave');
		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('scroll direction is correct in non-window container', async () => {
		await page.viewport(1024, 768);
		const { container, target } = setupContainer();

		const directions: Array<{ type: string; direction: string }> = [];
		const sm = new ScrollMagic({ element: target, container });
		sm.on('enter', (e: ScrollMagicEvent) => directions.push({ type: 'enter', direction: e.direction }));
		sm.on('leave', (e: ScrollMagicEvent) => directions.push({ type: 'leave', direction: e.direction }));

		// Scroll forward past element
		container.scrollTop = 1500;
		await waitForFrames(3);

		const forwardEnter = directions.find(d => 'enter' === d.type && 'forward' === d.direction);
		const forwardLeave = directions.find(d => 'leave' === d.type && 'forward' === d.direction);
		expect(forwardEnter).toBeDefined();
		expect(forwardLeave).toBeDefined();

		directions.length = 0;

		// Scroll backward past element
		container.scrollTop = 0;
		await waitForFrames(3);

		const reverseEnter = directions.find(d => 'enter' === d.type && 'reverse' === d.direction);
		const reverseLeave = directions.find(d => 'leave' === d.type && 'reverse' === d.direction);
		expect(reverseEnter).toBeDefined();
		expect(reverseLeave).toBeDefined();

		sm.destroy();
	});

	// #905: position:fixed containers as IO root.
	test('works with position:fixed scroll container', async () => {
		await page.viewport(1024, 768);
		const { container, target } = setupContainer({
			containerCss: {
				position: 'fixed',
				top: '0',
				left: '0',
				width: '100%',
			},
		});

		const sm = new ScrollMagic({ element: target, container });

		container.scrollTop = 700;
		await waitForFrames(3);
		expect(sm.progress).toBeGreaterThan(0);

		container.scrollTop = 1500;
		await waitForFrames(3);
		expect(sm.progress).toBe(1);

		container.scrollTop = 0;
		await waitForFrames(3);
		expect(sm.progress).toBe(0);

		sm.destroy();
	});
});

// positionCache for non-window containers was initialized to {top:0,left:0} and only updated on
// window scroll/resize events (via subscribeMove). For a container offset from the viewport top,
// the initial progress calculation used containerPosition=0 instead of the actual position.
describe('container position initialization', () => {
	afterEach(cleanup);

	test('correct initial progress when container is offset from viewport top', async () => {
		await page.viewport(1024, 768);
		document.body.style.margin = '0';
		document.body.style.padding = '0';

		// Push the container down so it's not at y=0
		const spacer = document.createElement('div');
		spacer.style.height = '300px';
		document.body.appendChild(spacer);

		// Container is now at y=300, height=400; element at contentTop=800, height=100
		const { container, target } = setupContainer({ elementTop: 800, elementHeight: 100 });

		const sm = new ScrollMagic({ element: target, container });
		await waitForFrames(3); // let initialization settle

		// Scroll without triggering any window scroll/resize (which would fix positionCache via subscribeMove)
		container.scrollTop = 600;
		await waitForFrames(5);

		// With fix: containerPosition=300, containerStart=700, elementStart=500, passed=200, progress=0.4
		// Without fix: containerPosition=0, containerStart=400, elementStart=500, passed=-100, progress=0
		expect(sm.progress).toBeGreaterThan(0);

		sm.destroy();
	});
});

// When container clientSize is 0 (hidden/collapsed), updateProgress() would compute wrong values:
// containerOffset collapsed to 0 and the calculation produced a different (incorrect) progress,
// firing spurious events. updateViewportObserver() also passed broken 0% margins to the observer.
describe('zero-size scroll container', () => {
	afterEach(cleanup);

	test('no events fire and progress stays frozen when container height becomes zero', async () => {
		await page.viewport(1024, 768);
		const { container, target } = setupContainer({ elementTop: 800, elementHeight: 100 });

		const sm = new ScrollMagic({ element: target, container });
		await waitForFrames(3);

		container.scrollTop = 850;
		await waitForFrames(5);

		const progressBefore = sm.progress;
		expect(progressBefore).toBeGreaterThan(0); // sanity check

		const events: string[] = [];
		sm.on('enter', () => events.push('enter'));
		sm.on('leave', () => events.push('leave'));
		sm.on('progress', () => events.push('progress'));

		// Collapse the container
		container.style.height = '0px';
		await wait(50); // allow ResizeObserver to fire
		await waitForFrames(5);

		// Without fix: updateProgress() ran with containerSize=0, computed a different value
		// and fired a spurious 'progress' event.
		expect(Number.isNaN(sm.progress)).toBe(false);
		expect(isFinite(sm.progress)).toBe(true);
		expect(events).toHaveLength(0);
		expect(sm.progress).toBe(progressBefore);

		sm.destroy();
	});
});

// #883, #372: Mobile address bar show/hide changes viewport dimensions.
// Tested by shrinking the viewport programmatically (simulates address bar appearing).
describe('viewport resize', () => {
	afterEach(cleanup);

	test('progress updates after viewport height change', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 600, elementHeight: 200 });

		const sm = new ScrollMagic({ element: target });

		// Scroll so element is partially in view
		window.scrollTo(0, 500);
		await waitForFrames(3);
		const progressBefore = sm.progress;
		expect(progressBefore).toBeGreaterThan(0);
		expect(progressBefore).toBeLessThan(1);

		// Shrink viewport (simulates mobile address bar appearing)
		await page.viewport(1024, 400);
		await wait(50); // give ResizeObserver / window resize event a moment to fire
		await waitForFrames(5);

		// Progress should have changed since viewport size affects the tracking calculation
		expect(sm.progress).not.toBe(progressBefore);

		sm.destroy();
	});
});


================================================
FILE: tests/e2e/destroy.test.ts
================================================
import { describe, test, expect, afterEach, vi } from 'vitest';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow } from './helpers';

afterEach(() => {
	cleanup();
	vi.restoreAllMocks();
});

describe('destroy: idempotency', () => {
	test('calling destroy twice does not warn', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

		sm.destroy();
		sm.destroy();

		expect(warnSpy).not.toHaveBeenCalled();
	});
});

describe('destroy: post-destroy dev warnings', () => {
	const makeScene = () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		return { sm, target };
	};

	test('modify() warns', () => {
		const { sm, target } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.modify({ element: target });
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('refresh() warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.refresh();
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('addPlugin() warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.addPlugin({ name: 'test' });
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('removePlugin() warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.removePlugin({ name: 'test' });
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('on() warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.on('progress', () => {});
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('off() warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.off('progress', () => {});
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('subscribe() warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.subscribe('progress', () => {});
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});

	test('activeRange warns', () => {
		const { sm } = makeScene();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		void sm.activeRange;
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});
});

describe('destroy: plugin cleanup', () => {
	test('destroy() calls onDestroy (not onRemove) on all plugins', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const plugin1 = { name: 'p1', onRemove: vi.fn(), onDestroy: vi.fn() };
		const plugin2 = { name: 'p2', onRemove: vi.fn(), onDestroy: vi.fn() };
		sm.addPlugin(plugin1);
		sm.addPlugin(plugin2);

		sm.destroy();

		expect(plugin1.onDestroy).toHaveBeenCalledOnce();
		expect(plugin2.onDestroy).toHaveBeenCalledOnce();
		expect(plugin1.onRemove).not.toHaveBeenCalled();
		expect(plugin2.onRemove).not.toHaveBeenCalled();
		expect(sm.pluginList).toHaveLength(0);
	});

	test('destroy() calls onDisable before onDestroy (when enabled)', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const order: string[] = [];
		const plugin = {
			name: 'order-test',
			onDisable: vi.fn(() => order.push('disable')),
			onDestroy: vi.fn(() => order.push('destroy')),
		};
		sm.addPlugin(plugin);

		sm.destroy();

		expect(order).toEqual(['disable', 'destroy']);
	});

	test('destroy() skips onDisable when already disabled', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const plugin = {
			name: 'test',
			onDisable: vi.fn(),
			onDestroy: vi.fn(),
		};
		sm.addPlugin(plugin);

		sm.disable();
		plugin.onDisable.mockClear(); // reset from the explicit disable() call

		sm.destroy();

		expect(plugin.onDisable).not.toHaveBeenCalled();
		expect(plugin.onDestroy).toHaveBeenCalledOnce();
	});
});

describe('destroy: post-destroy no-op behaviour', () => {
	test('modify() does not change options', () => {
		const { target } = setupWindow();
		const otherElement = document.createElement('div');
		document.body.appendChild(otherElement);

		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		sm.modify({ element: otherElement });
		expect(sm.element).toBe(target);
	});

	test('addPlugin() does not register the plugin', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		const plugin = { name: 'test', onAdd: vi.fn() };
		sm.addPlugin(plugin);

		expect(plugin.onAdd).not.toHaveBeenCalled();
		expect(sm.pluginList).toHaveLength(0);
	});

	test('subscribe() returns a no-op cleanup function', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		const unsub = sm.subscribe('progress', () => {});
		expect(unsub).toBeTypeOf('function');
		expect(() => unsub()).not.toThrow();
	});

	test('activeRange returns { start: 0, end: 0 }', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		expect(sm.activeRange).toEqual({ start: 0, end: 0 });
	});
});


================================================
FILE: tests/e2e/dev-warnings.test.ts
================================================
import { describe, test, expect, afterEach, vi } from 'vitest';
import ScrollMagic from '../../src/index';
import { cleanup } from './helpers';

afterEach(() => {
	cleanup();
	vi.restoreAllMocks();
});

describe('Dev warnings: element / container relationship', () => {
	test('logs error when element is not a descendant of container', () => {
		const container = document.createElement('div');
		container.style.height = '400px';
		container.style.overflow = 'auto';
		document.body.appendChild(container);

		const outsideElement = document.createElement('div');
		outsideElement.style.height = '100px';
		document.body.appendChild(outsideElement);

		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		const sm = new ScrollMagic({ element: outsideElement, container });
		sm.destroy(); // destroy before rAF fires to prevent IntersectionObserver errors from invalid config

		expect(errorSpy).toHaveBeenCalledOnce();
		expect(errorSpy.mock.calls[0][0]).toContain('not a descendant');
	});

	test('does not log error when element is a descendant of container', () => {
		const container = document.createElement('div');
		container.style.height = '400px';
		container.style.overflow = 'auto';

		const innerElement = document.createElement('div');
		innerElement.style.height = '100px';
		container.appendChild(innerElement);
		document.body.appendChild(container);

		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		const sm = new ScrollMagic({ element: innerElement, container });
		sm.destroy();

		expect(errorSpy).not.toHaveBeenCalled();
	});

	test('does not log error when container is window (default)', () => {
		const element = document.createElement('div');
		element.style.height = '100px';
		document.body.appendChild(element);

		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
		vi.spyOn(console, 'warn').mockImplementation(() => {});

		const sm = new ScrollMagic({ element });
		sm.destroy();

		expect(errorSpy).not.toHaveBeenCalled();
	});

	test('logs error when container is updated via modify and element is not a descendant', () => {
		const container = document.createElement('div');
		container.style.height = '400px';
		container.style.overflow = 'auto';
		document.body.appendChild(container);

		const outsideElement = document.createElement('div');
		outsideElement.style.height = '100px';
		document.body.appendChild(outsideElement);

		vi.spyOn(console, 'warn').mockImplementation(() => {});

		const sm = new ScrollMagic({ element: outsideElement }); // window — ok

		const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
		sm.modify({ container }); // now non-descendant
		sm.destroy();

		expect(errorSpy).toHaveBeenCalledOnce();
		expect(errorSpy.mock.calls[0][0]).toContain('not a descendant');
	});
});


================================================
FILE: tests/e2e/element-tracking.test.ts
================================================
/**
 * Element tracking edge cases: resize, DOM mutations, SVG elements.
 * Tests for: layout shifts from element resize, content removal above element, SVG as tracked element.
 */
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, wait, waitForFrames } from './helpers';

// #986: Lazy-loaded images changing layout may not trigger ResizeObserver or bounds recalculation in time.
// Tested by resizing the tracked element after initial scroll (simulates lazy image load).
describe('element resize / layout shifts', () => {
	afterEach(cleanup);

	test('progress recalculates when tracked element changes size', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 300, elementHeight: 200 });
		const sm = new ScrollMagic({ element: target });

		// Scroll and wait for IO to fire and set intersecting=true
		window.scrollTo(0, 200);
		await waitForFrames(3);
		const progressBefore = sm.progress;
		expect(progressBefore).toBeGreaterThan(0);

		// Simulate lazy image loading — element grows taller
		target.style.height = '600px';
		// ResizeObserver fires before next paint; wait generously for it + scheduler flush
		await wait(500);
		await waitForFrames(5);

		// Track size changed, so progress should differ
		expect(sm.progress).not.toBe(progressBefore);

		sm.destroy();
	});
});

// #911: Major DOM mutations (removing large nodes) while scrolled could cause scroll position drift.
// Tested by removing a large block above the tracked element mid-scroll.
describe('DOM mutation', () => {
	afterEach(cleanup);

	test('handles removal of content above tracked element', async () => {
		await page.viewport(1024, 768);
		document.body.style.margin = '0';
		document.body.style.padding = '0';

		const wrapper = document.createElement('div');
		wrapper.style.position = 'relative';

		const aboveBlock = document.createElement('div');
		aboveBlock.style.height = '800px';

		const target = document.createElement('div');
		target.style.height = '200px';
		target.style.width = '100%';
		target.style.background = 'red';

		const belowBlock = document.createElement('div');
		belowBlock.style.height = '2000px';

		wrapper.appendChild(aboveBlock);
		wrapper.appendChild(target);
		wrapper.appendChild(belowBlock);
		document.body.appendChild(wrapper);

		const sm = new ScrollMagic({ element: target });

		// Scroll to see the target
		window.scrollTo(0, 500);
		await waitForFrames(3);
		expect(sm.progress).toBeGreaterThan(0);

		// Remove the 800px block above — target shifts up in layout
		aboveBlock.remove();
		await waitForFrames(5);

		// Should not crash; progress should be a valid number
		expect(sm.progress).toBeGreaterThanOrEqual(0);
		expect(sm.progress).toBeLessThanOrEqual(1);
		expect(Number.isNaN(sm.progress)).toBe(false);

		sm.destroy();
	});
});

// #618, #460: SVG elements may report incorrect bounding boxes in Firefox.
// SVG child elements (<path>, <g>, <use>) as IO targets may behave unexpectedly.
describe('SVG elements', () => {
	afterEach(cleanup);

	test('tracks an SVG element', async () => {
		await page.viewport(1024, 768);
		document.body.style.margin = '0';
		document.body.style.padding = '0';

		const spacer = document.createElement('div');
		spacer.style.height = '3000px';
		spacer.style.position = 'relative';

		const svgNS = 'http://www.w3.org/2000/svg';
		const svg = document.createElementNS(svgNS, 'svg');
		svg.setAttribute('width', '200');
		svg.setAttribute('height', '200');
		svg.style.position = 'absolute';
		svg.style.top = '1000px';

		const rect = document.createElementNS(svgNS, 'rect');
		rect.setAttribute('width', '200');
		rect.setAttribute('height', '200');
		rect.setAttribute('fill', 'blue');
		svg.appendChild(rect);

		spacer.appendChild(svg);
		document.body.appendChild(spacer);

		const events: string[] = [];
		const sm = new ScrollMagic({ element: svg });
		sm.on('enter', () => events.push('enter'));
		sm.on('leave', () => events.push('leave'));

		// Scroll into view
		window.scrollTo(0, 800);
		await waitForFrames(3);
		expect(events).toContain('enter');
		expect(sm.progress).toBeGreaterThan(0);

		// Scroll past
		window.scrollTo(0, 1500);
		await waitForFrames(3);
		expect(sm.progress).toBe(1);

		sm.destroy();
	});
});


================================================
FILE: tests/e2e/enable-disable.test.ts
================================================
/**
 * Enable/disable: pause and resume tracking without destroying.
 * Tests for: state & getters, method guards, idempotency, chaining,
 * tracking pause/resume, event suppression, modify-while-disabled.
 */
import { describe, test, expect, afterEach, vi } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, waitForFrames } from './helpers';

describe('enable/disable: state & guards', () => {
	afterEach(() => {
		cleanup();
		vi.restoreAllMocks();
	});

	test('disabled is false by default', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		expect(sm.disabled).toBe(false);
		sm.destroy();
	});

	test('disabled is true after disable(), false after enable()', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.disable();
		expect(sm.disabled).toBe(true);
		sm.enable();
		expect(sm.disabled).toBe(false);
		sm.destroy();
	});

	test('disable() and enable() return the instance (chaining)', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		expect(sm.disable()).toBe(sm);
		expect(sm.enable()).toBe(sm);
		sm.destroy();
	});

	test('double disable() does not throw', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.disable();
		expect(() => sm.disable()).not.toThrow();
		sm.destroy();
	});

	test('double enable() does not throw', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		expect(() => sm.enable()).not.toThrow();
		sm.destroy();
	});

	test('modify() works when disabled (updates options)', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.disable();
		sm.modify({ containerStart: 0.5 });
		expect(sm.containerStart).toBe(0.5);
		sm.destroy();
	});

	test('on()/off()/subscribe() work when disabled', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.disable();
		const handler = () => {};
		expect(() => sm.on('progress', handler)).not.toThrow();
		expect(() => sm.off('progress', handler)).not.toThrow();
		const unsub = sm.subscribe('progress', handler);
		expect(typeof unsub).toBe('function');
		sm.destroy();
	});

	test('addPlugin()/removePlugin() work when disabled', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.disable();
		const plugin = { name: 'test', onAdd: vi.fn(), onRemove: vi.fn() };
		sm.addPlugin(plugin);
		expect(plugin.onAdd).toHaveBeenCalledOnce();
		expect(sm.pluginList).toHaveLength(1);
		sm.removePlugin(plugin);
		expect(plugin.onRemove).toHaveBeenCalledOnce();
		expect(sm.pluginList).toHaveLength(0);
		sm.destroy();
	});

	test('disable() calls onDisable on all plugins', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const plugin = { name: 'test', onDisable: vi.fn() };
		sm.addPlugin(plugin);

		sm.disable();
		expect(plugin.onDisable).toHaveBeenCalledOnce();
		sm.destroy();
	});

	test('enable() calls onEnable on all plugins', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const plugin = { name: 'test', onEnable: vi.fn() };
		sm.addPlugin(plugin);

		sm.disable();
		sm.enable();
		expect(plugin.onEnable).toHaveBeenCalledOnce();
		sm.destroy();
	});

	test('onDisable/onEnable are not called on idempotent calls', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const plugin = { name: 'test', onEnable: vi.fn(), onDisable: vi.fn() };
		sm.addPlugin(plugin);

		// already enabled — enable() should no-op
		sm.enable();
		expect(plugin.onEnable).not.toHaveBeenCalled();

		sm.disable();
		expect(plugin.onDisable).toHaveBeenCalledOnce();

		// already disabled — disable() should no-op
		sm.disable();
		expect(plugin.onDisable).toHaveBeenCalledOnce(); // still just once

		sm.destroy();
	});

	test('progress getter returns last known value when disabled', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		window.scrollTo(0, 2000);
		await waitForFrames(3);
		expect(sm.progress).toBe(1);

		sm.disable();
		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('activeRange still works when disabled', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		await waitForFrames(3);
		const offsetBefore = sm.activeRange;

		sm.disable();
		const offsetWhileDisabled = sm.activeRange;

		expect(offsetWhileDisabled).toEqual(offsetBefore);
		sm.destroy();
	});

	test('refresh() is a no-op when disabled (no errors)', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.disable();
		expect(() => sm.refresh()).not.toThrow();
		sm.destroy();
	});

	test('destroy() fully tears down a disabled instance', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('enter', () => events.push('enter'));
		sm.on('progress', () => events.push('progress'));

		sm.disable();
		sm.destroy();

		// re-enable should be impossible (destroyed)
		vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.enable();
		expect(sm.disabled).toBe(true); // still disabled — enable was a no-op

		window.scrollTo(0, 2000);
		await waitForFrames(3);

		expect(events).toHaveLength(0);
	});

	test('disabled is true after destroy() (without prior disable())', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		expect(sm.disabled).toBe(true);
	});

	test('enable() after destroy() warns and no-ops', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.enable();
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
		expect(sm.disabled).toBe(true);
	});

	test('disable() after destroy() warns and no-ops', () => {
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		sm.destroy();
		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
		sm.disable();
		expect(warnSpy).toHaveBeenCalledOnce();
		expect(warnSpy.mock.calls[0][0]).toContain('destroyed');
	});
});

describe('enable/disable: tracking behavior', () => {
	afterEach(cleanup);

	test('disable() stops events — scroll after disable fires nothing', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('enter', () => events.push('enter'));
		sm.on('progress', () => events.push('progress'));
		sm.on('leave', () => events.push('leave'));

		sm.disable();

		window.scrollTo(0, 2000);
		await waitForFrames(3);

		expect(events).toHaveLength(0);

		sm.destroy();
	});

	test('disable() freezes progress at pre-disable value', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		window.scrollTo(0, 2000);
		await waitForFrames(3);
		expect(sm.progress).toBe(1);

		sm.disable();

		window.scrollTo(0, 0);
		await waitForFrames(3);

		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('enable() resumes tracking — progress updates after re-enable', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		sm.disable();

		// Scroll past while disabled
		window.scrollTo(0, 2000);
		await waitForFrames(3);
		expect(sm.progress).toBe(0); // frozen at initial value

		sm.enable();
		await waitForFrames(3);

		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('enable() fires events after resuming', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		sm.disable();

		// Scroll past while disabled
		window.scrollTo(0, 2000);
		await waitForFrames(3);

		const events: string[] = [];
		sm.on('enter', () => events.push('enter'));
		sm.on('progress', () => events.push('progress'));
		sm.on('leave', () => events.push('leave'));

		sm.enable();
		await waitForFrames(3);

		expect(events).toContain('enter');
		expect(events).toContain('progress');

		sm.destroy();
	});

	test('rapid toggle (disable → enable → disable) in one frame cancels scheduled work', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('enter', () => events.push('enter'));
		sm.on('progress', () => events.push('progress'));

		// Rapid toggle before rAF fires — enable() schedules work, disable() cancels it
		sm.disable();
		sm.enable();
		sm.disable();

		expect(sm.disabled).toBe(true);

		window.scrollTo(0, 2000);
		await waitForFrames(3);

		expect(events).toHaveLength(0);
		expect(sm.progress).toBe(0);

		sm.destroy();
	});

	test('modify({ element }) while disabled takes effect on enable()', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		// Create a second element further down
		const newTarget = document.createElement('div');
		newTarget.style.position = 'absolute';
		newTarget.style.top = '1500px';
		newTarget.style.height = '100px';
		newTarget.style.width = '100%';
		target.parentElement!.appendChild(newTarget);

		sm.disable();
		sm.modify({ element: newTarget });
		expect(sm.element).toBe(newTarget);

		// Scroll past the NEW element position
		window.scrollTo(0, 2500);
		await waitForFrames(3);
		expect(sm.progress).toBe(0); // still frozen

		sm.enable();
		await waitForFrames(3);

		expect(sm.progress).toBe(1); // tracking the new element

		sm.destroy();
	});

	test('modify({ container }) while disabled takes effect on enable()', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		sm.disable();
		sm.modify({ container: window }); // same container in this case, but exercises the code path
		expect(sm.container).toBe(window);

		sm.enable();
		await waitForFrames(3);

		// Should be tracking normally after re-enable with new container
		window.scrollTo(0, 2000);
		await waitForFrames(3);
		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('modify() while disabled takes effect on enable()', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		sm.disable();
		sm.modify({ containerStart: 0.5 });

		sm.enable();
		await waitForFrames(3);

		expect(sm.containerStart).toBe(0.5);
		// Verify the new containerStart is actually in effect by checking resolved offsets
		expect(sm.resolvedBounds.container.offsetStart).toBeGreaterThan(0);

		sm.destroy();
	});
});


================================================
FILE: tests/e2e/helpers.ts
================================================
export const waitForFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
export const waitForFrames = async (n = 3) => {
	for (let i = 0; i < n; i++) await waitForFrame();
};
export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export const cleanup = () => {
	document.body.innerHTML = '';
	window.scrollTo(0, 0);
};

/** Standard setup: scrollable page with a positioned target element (window scroll) */
export const setupWindow = (opts: { contentHeight?: number; elementTop?: number; elementHeight?: number } = {}) => {
	const { contentHeight = 3000, elementTop = 1000, elementHeight = 200 } = opts;
	document.body.style.margin = '0';
	document.body.style.padding = '0';

	const spacer = document.createElement('div');
	spacer.style.height = `${contentHeight}px`;
	spacer.style.position = 'relative';

	const target = document.createElement('div');
	target.style.position = 'absolute';
	target.style.top = `${elementTop}px`;
	target.style.height = `${elementHeight}px`;
	target.style.width = '100%';

	spacer.appendChild(target);
	document.body.appendChild(spacer);
	return { spacer, target };
};

/** Setup: scrollable container div (non-window scroll parent) */
export const setupContainer = (
	opts: {
		containerHeight?: number;
		contentHeight?: number;
		elementTop?: number;
		elementHeight?: number;
		containerCss?: Partial<CSSStyleDeclaration>;
	} = {}
) => {
	const { containerHeight = 400, contentHeight = 2000, elementTop = 800, elementHeight = 100, containerCss = {} } = opts;
	document.body.style.margin = '0';
	document.body.style.padding = '0';

	const container = document.createElement('div');
	container.style.height = `${containerHeight}px`;
	container.style.overflow = 'auto';
	container.style.position = 'relative';
	Object.assign(container.style, containerCss);

	const content = document.createElement('div');
	content.style.height = `${contentHeight}px`;
	content.style.position = 'relative';

	const target = document.createElement('div');
	target.style.position = 'absolute';
	target.style.top = `${elementTop}px`;
	target.style.height = `${elementHeight}px`;
	target.style.width = '100%';

	content.appendChild(target);
	container.appendChild(content);
	document.body.appendChild(container);
	return { container, content, target };
};


================================================
FILE: tests/e2e/refresh.test.ts
================================================
/**
 * Manual refresh API: refresh(), refreshAll(), destroyAll().
 * Tests for: position change detection via refresh, class-based changes,
 * chaining, multi-instance refreshAll, destroyed instance exclusion, destroyAll.
 */
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import { cleanup, setupWindow, waitForFrames } from './helpers';

describe('refresh', () => {
	afterEach(cleanup);

	// Note on shift magnitude: position shifts must be small enough that the element stays
	// well within the IntersectionObserver's margin region. If the shift moves the element
	// outside the IO boundary, IO fires automatically and progress updates without refresh(),
	// defeating the test's purpose. With default options (IO margins ≈ 0% on a 768px viewport),
	// the element must stay clearly visible in the viewport after the shift.
	test('refresh() picks up position changes from inline style', async () => {
		await page.viewport(1024, 768);
		// element at visual ~150px after scroll — centered in viewport
		const { target } = setupWindow({ elementTop: 550, elementHeight: 100 });

		const sm = new ScrollMagic({ element: target });
		window.scrollTo(0, 400);
		await waitForFrames();
		const progressBefore = sm.progress;
		expect(progressBefore).toBeGreaterThan(0);

		// small shift (50px) keeps element well within viewport (visual 150px → 100px)
		// ResizeObserver won't fire (size unchanged), IO margins aren't crossed
		target.style.top = '500px';
		await waitForFrames();
		const progressWithoutRefresh = sm.progress;

		sm.refresh();
		await waitForFrames();
		const progressAfterRefresh = sm.progress;

		// element moved closer to top → progress should increase
		expect(progressAfterRefresh).toBeGreaterThan(progressBefore);
		expect(progressAfterRefresh).not.toBe(progressWithoutRefresh);

		sm.destroy();
	});

	test('refresh() picks up class-based position changes', async () => {
		await page.viewport(1024, 768);
		// element at visual ~150px after scroll
		const { target } = setupWindow({ elementTop: 550, elementHeight: 100 });

		// CSS class that shifts position by 50px (keeps element in viewport)
		const style = document.createElement('style');
		style.textContent = '.shifted { top: 500px !important; }';
		document.head.appendChild(style);

		const sm = new ScrollMagic({ element: target });
		window.scrollTo(0, 400);
		await waitForFrames();
		const progressBefore = sm.progress;
		expect(progressBefore).toBeGreaterThan(0);

		target.classList.add('shifted');
		sm.refresh();
		await waitForFrames();

		expect(sm.progress).not.toBe(progressBefore);

		sm.destroy();
		style.remove();
	});

	test('refresh() is chainable', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow();
		const sm = new ScrollMagic({ element: target });
		const result = sm.refresh();
		expect(result).toBe(sm);
		sm.destroy();
	});

	test('refreshAll() updates all active instances', async () => {
		await page.viewport(1024, 768);
		// elements well within viewport after scroll: visual ~150px and ~250px
		const { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });

		const target2 = document.createElement('div');
		target2.style.position = 'absolute';
		target2.style.top = '650px';
		target2.style.height = '100px';
		target2.style.width = '100%';
		spacer.appendChild(target2);

		const scene1 = new ScrollMagic({ element: target });
		const scene2 = new ScrollMagic({ element: target2 });
		window.scrollTo(0, 400);
		await waitForFrames();
		const p1Before = scene1.progress;
		const p2Before = scene2.progress;

		// small shifts (50px each) — stay within viewport
		target.style.top = '500px';
		target2.style.top = '600px';

		ScrollMagic.refreshAll();
		await waitForFrames();

		expect(scene1.progress).not.toBe(p1Before);
		expect(scene2.progress).not.toBe(p2Before);

		scene1.destroy();
		scene2.destroy();
	});

	test('refreshAll() skips disabled instances while refreshing enabled ones', async () => {
		await page.viewport(1024, 768);
		const { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });

		const target2 = document.createElement('div');
		target2.style.position = 'absolute';
		target2.style.top = '650px';
		target2.style.height = '100px';
		target2.style.width = '100%';
		spacer.appendChild(target2);

		const scene1 = new ScrollMagic({ element: target });
		const scene2 = new ScrollMagic({ element: target2 });
		window.scrollTo(0, 400);
		await waitForFrames();
		const p1Before = scene1.progress;
		const p2Before = scene2.progress;

		scene1.disable();

		// small shifts — stay within viewport
		target.style.top = '500px';
		target2.style.top = '600px';

		ScrollMagic.refreshAll();
		await waitForFrames();

		// disabled instance: progress frozen
		expect(scene1.progress).toBe(p1Before);
		// enabled instance: progress updated
		expect(scene2.progress).not.toBe(p2Before);

		scene1.destroy();
		scene2.destroy();
	});

	test('destroyed instances are excluded from refreshAll()', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 550, elementHeight: 100 });

		const sm = new ScrollMagic({ element: target });
		window.scrollTo(0, 400);
		await waitForFrames();
		const progressBefore = sm.progress;

		sm.destroy();

		target.style.top = '500px';
		ScrollMagic.refreshAll();
		await waitForFrames();

		// progress is frozen at whatever it was before destroy
		expect(sm.progress).toBe(progressBefore);
	});

	test('destroyAll() destroys all active instances', async () => {
		await page.viewport(1024, 768);
		const { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });

		const target2 = document.createElement('div');
		target2.style.position = 'absolute';
		target2.style.top = '650px';
		target2.style.height = '100px';
		target2.style.width = '100%';
		spacer.appendChild(target2);

		const scene1 = new ScrollMagic({ element: target });
		const scene2 = new ScrollMagic({ element: target2 });
		window.scrollTo(0, 400);
		await waitForFrames();
		const p1Before = scene1.progress;
		const p2Before = scene2.progress;

		ScrollMagic.destroyAll();

		// scroll should not update progress on destroyed instances
		window.scrollTo(0, 600);
		await waitForFrames();

		expect(scene1.progress).toBe(p1Before);
		expect(scene2.progress).toBe(p2Before);
	});
});


================================================
FILE: tests/e2e/scroll-progress.test.ts
================================================
/**
 * Core scroll progress tracking and event behavior.
 * Tests for: progress 0→1 lifecycle, enter/leave/progress events, event direction,
 * fast scrolling, programmatic scroll jumps, scroll state initialization, destroy,
 * on() with { once: true }.
 */
import { describe, test, expect, afterEach } from 'vitest';
import { page } from 'vitest/browser';
import ScrollMagic from '../../src/index';
import type { ScrollMagicEvent } from '../../src/index';
import { cleanup, setupWindow, waitForFrames } from './helpers';

describe('progress lifecycle', () => {
	afterEach(cleanup);

	test('fires enter and progress events on scroll', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow();

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('enter', () => events.push('enter'));
		sm.on('progress', () => events.push('progress'));
		sm.on('leave', () => events.push('leave'));

		// Scroll to a position where the element should be intersecting
		window.scrollTo(0, 600);
		await waitForFrames(3);

		expect(events).toContain('enter');
		expect(events).toContain('progress');
		expect(sm.progress).toBeGreaterThan(0);

		sm.destroy();
	});

	test('progress reaches 1 when fully scrolled past', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const sm = new ScrollMagic({ element: target });

		// Scroll well past the element
		window.scrollTo(0, 2000);
		await waitForFrames(3);

		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('progress is 0 before element enters viewport', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 2000 });

		const sm = new ScrollMagic({ element: target });
		await waitForFrames(3);

		expect(sm.progress).toBe(0);

		sm.destroy();
	});

	test('fires leave event when scrolling past', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('leave', () => events.push('leave'));

		window.scrollTo(0, 2000);
		await waitForFrames(3);

		expect(events).toContain('leave');

		sm.destroy();
	});

	test('destroy stops event processing', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('progress', () => events.push('progress'));

		sm.destroy();

		window.scrollTo(0, 1000);
		await waitForFrames(3);

		expect(events).toHaveLength(0);
	});
});

// #633: Fast scrolling could skip intermediate IO callbacks — elements scrolled past entirely in one frame.
describe('fast scrolling', () => {
	afterEach(cleanup);

	test('progress is correct after instant scroll past element and back', async () => {
		await page.viewport(1024, 768);
		// Element at 1500px — well below 768px viewport when scrolled to 0
		const { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });
		const sm = new ScrollMagic({ element: target });

		// Instant scroll well past element
		window.scrollTo(0, 2500);
		await waitForFrames(3);
		expect(sm.progress).toBe(1);

		// Instant scroll back to top — element now below viewport
		window.scrollTo(0, 0);
		await waitForFrames(3);
		expect(sm.progress).toBe(0);

		sm.destroy();
	});

	test('all enter/leave events fire during instant scroll through', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		const events: string[] = [];
		const sm = new ScrollMagic({ element: target });
		sm.on('enter', () => events.push('enter'));
		sm.on('leave', () => events.push('leave'));

		// Single scroll that jumps completely past the element
		window.scrollTo(0, 2000);
		await waitForFrames(3);

		expect(events).toContain('enter');
		expect(events).toContain('leave');
		expect(sm.progress).toBe(1);

		sm.destroy();
	});
});

// #630, #596: Browser scroll restoration — instances created at non-zero scroll positions.
describe('scroll state initialization', () => {
	afterEach(cleanup);

	test('correct initial progress when instance created at non-zero scroll', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 100 });

		// Scroll past element BEFORE creating instance
		window.scrollTo(0, 2000);
		await waitForFrames(3);

		// Now create instance — should detect current position
		const sm = new ScrollMagic({ element: target });
		await waitForFrames(3);

		expect(sm.progress).toBe(1);

		sm.destroy();
	});

	test('correct initial progress when element is partially visible on creation', async () => {
		await page.viewport(1024, 768);
		const { target } = setupWindow({ elementTop: 500, elementHeight: 400 });

		// Scroll to a position where element is partially visible
		window.scrollTo(0, 500);
		await waitForFrames(3);

		const sm = new ScrollMagic({ element: target });
		await waitForFrames(3);

		expect(sm.progress).toBeGreaterThan(0);
		expect(sm.progress).toBeLessThan(1);

		sm.destroy();
	});

	test('fires enter event when created at position where element is visible', async
Download .txt
gitextract_3ruugf_l/

├── .gitignore
├── .prettierignore
├── CHANGELOG.md
├── LICENSE.md
├── MAINTAINING.md
├── PLUGINS.md
├── README.md
├── ROADMAP.md
├── config/
│   └── banner.txt
├── docs/
│   └── diagrams/
│       ├── contain.html
│       ├── intersect.html
│       └── shared.css
├── eslint.config.mjs
├── package.json
├── prettier.config.mjs
├── rollup.config.mjs
├── scripts/
│   └── export-diagrams.mjs
├── src/
│   ├── Container.ts
│   ├── ContainerProxy.ts
│   ├── EventDispatcher.ts
│   ├── ExecutionQueue.ts
│   ├── Options.processors.ts
│   ├── Options.ts
│   ├── ScrollMagic.ts
│   ├── ScrollMagicError.ts
│   ├── ScrollMagicEvent.ts
│   ├── ViewportObserver.ts
│   ├── env.d.ts
│   ├── index.ts
│   ├── util/
│   │   ├── agnosticValues.ts
│   │   ├── getScrollContainerDimensions.ts
│   │   ├── getScrollPos.ts
│   │   ├── pickDifferencesFlat.ts
│   │   ├── processProperties.ts
│   │   ├── rafQueue.ts
│   │   ├── registerEvent.ts
│   │   ├── sanitizeProperties.ts
│   │   ├── sharedResizeObserver.ts
│   │   ├── throttleRaf.ts
│   │   ├── transformObject.ts
│   │   ├── transformers.ts
│   │   └── typeguards.ts
│   └── util.ts
├── tests/
│   ├── e2e/
│   │   ├── UNTESTED-KNOWN-BUGS.md
│   │   ├── caching.test.ts
│   │   ├── containers.test.ts
│   │   ├── destroy.test.ts
│   │   ├── dev-warnings.test.ts
│   │   ├── element-tracking.test.ts
│   │   ├── enable-disable.test.ts
│   │   ├── helpers.ts
│   │   ├── refresh.test.ts
│   │   ├── scroll-progress.test.ts
│   │   └── scroll-velocity.test.ts
│   └── unit/
│       ├── ContainerProxy.test.ts
│       ├── EventDispatcher.test.ts
│       ├── ExecutionQueue.test.ts
│       ├── Options.processors.test.ts
│       ├── ScrollMagicError.test.ts
│       ├── ScrollMagicEvent.test.ts
│       ├── agnosticValues.test.ts
│       ├── getScrollContainerDimensions.test.ts
│       ├── getScrollPos.test.ts
│       ├── pickDifferencesFlat.test.ts
│       ├── processProperties.test.ts
│       ├── rafQueue.test.ts
│       ├── registerEvent.test.ts
│       ├── sanitizeProperties.test.ts
│       ├── sharedResizeObserver.test.ts
│       ├── throttleRaf.test.ts
│       ├── transformObject.test.ts
│       ├── transformers.test.ts
│       └── typeguards.test.ts
├── tsconfig.json
├── typedoc.json
└── vitest.config.ts
Download .txt
SYMBOL INDEX (167 symbols across 21 files)

FILE: scripts/export-diagrams.mjs
  function exportDiagram (line 33) | async function exportDiagram(name, htmlPath) {

FILE: src/Container.ts
  type ScrollContainer (line 10) | type ScrollContainer = HTMLElement | Window;
  type CleanUpFunction (line 12) | type CleanUpFunction = () => void;
  type Vector (line 13) | type Vector = {
  constant ZERO_VECTOR (line 17) | const ZERO_VECTOR: Readonly<Vector> = Object.freeze({ x: 0, y: 0 });
  type EventType (line 20) | enum EventType {
  class ContainerEvent (line 24) | class ContainerEvent implements DispatchableEvent {
    method constructor (line 25) | constructor(
  class Container (line 32) | class Container {
    method constructor (line 62) | constructor(public readonly containerElement: ScrollContainer) {
    method updateScrollPos (line 86) | private updateScrollPos() {
    method updateDimensions (line 103) | private updateDimensions() {
    method updatePosition (line 108) | private updatePosition() {
    method subscribeResize (line 115) | private subscribeResize(onResize: () => void) {
    method subscribeScroll (line 124) | private subscribeScroll(onScroll: () => void) {
    method subscribeMove (line 128) | private subscribeMove(onMove: () => void) {
    method subscribe (line 137) | public subscribe(type: `${EventType}`, cb: (e: ContainerEvent) => void...
    method size (line 141) | public get size(): Readonly<Container['dimensions']> {
    method position (line 145) | public get position(): Readonly<Container['positionCache']> {
    method scrollVelocity (line 149) | public get scrollVelocity(): Readonly<Vector> {
    method destroy (line 156) | public destroy(): void {

FILE: src/ContainerProxy.ts
  type EventCallback (line 4) | type EventCallback = (e: ContainerEvent) => void;
  type CleanUpFunction (line 5) | type CleanUpFunction = () => void;
  type Velocity (line 6) | type Velocity = {
  class ContainerProxy (line 11) | class ContainerProxy {
    method constructor (line 15) | constructor(private readonly sm: ScrollMagic) {}
    method attach (line 18) | public attach(containerElement: ScrollContainer, onUpdate: EventCallba...
    method detach (line 33) | public detach(): void {
    method size (line 54) | public get size(): Container['size'] {
    method position (line 61) | public get position(): Container['position'] {
    method scrollVelocity (line 68) | public get scrollVelocity(): Velocity {

FILE: src/EventDispatcher.ts
  type EventType (line 2) | type EventType = string;
  type DispatchableEvent (line 3) | interface DispatchableEvent {
  type ListenerOptions (line 9) | type ListenerOptions = {
  type Callback (line 15) | type Callback<E extends DispatchableEvent> = (event: E) => void;
  type ListenerEntry (line 16) | type ListenerEntry<E extends DispatchableEvent> = { cb: Callback<E>; opt...
  class EventDispatcher (line 18) | class EventDispatcher<E extends DispatchableEvent = DispatchableEvent> {
    method addEventListener (line 22) | public addEventListener(type: E['type'], cb: Callback<E>, options: Lis...
    method removeEventListener (line 43) | public removeEventListener(type: E['type'], cb: Callback<E>): void {
    method dispatchEvent (line 57) | public dispatchEvent(event: E): void {
    method removeEntry (line 71) | private removeEntry(type: string, entry: ListenerEntry<E>): void {

FILE: src/ExecutionQueue.ts
  type Callback (line 3) | type Callback = () => void;
  type ExecutionCondition (line 4) | type ExecutionCondition = () => boolean;
  type CommandList (line 6) | type CommandList<T extends string> = Record<T, Command>;
  class ExecutionQueue (line 32) | class ExecutionQueue<C extends string> {
    method constructor (line 35) | constructor(queueItems: Record<C, Callback>) {
    method execute (line 40) | public execute(): void {
    method cancel (line 48) | public cancel(): void {
  class Command (line 79) | class Command {
    method constructor (line 81) | constructor(
    method schedule (line 85) | public schedule(condition?: ExecutionCondition) {
    method resetConditions (line 94) | public resetConditions() {
    method conditionsMet (line 97) | public get conditionsMet() {

FILE: src/Options.ts
  type NullableProperties (line 1) | type NullableProperties<T extends object, K extends keyof T> = Omit<T, K...
  type UnitString (line 4) | type UnitString = `${number}px` | `${number}%`;
  type PositionShorthand (line 5) | type PositionShorthand = keyof typeof positionShorthands;
  type CssSelector (line 6) | type CssSelector = string;
  type PixelConverter (line 9) | type PixelConverter = (size: number) => number;
  type Public (line 12) | type Public = {
  type Private (line 30) | type Private = {
  type PrivateUninferred (line 41) | type PrivateUninferred = NullableProperties<

FILE: src/ScrollMagic.ts
  type ElementBounds (line 19) | type ElementBounds = {
  type ContainerBounds (line 32) | type ContainerBounds = {
  type ResolvedBounds (line 46) | type ResolvedBounds = {
  type Plugin (line 58) | interface Plugin {
  class ScrollMagic (line 85) | class ScrollMagic {
    method constructor (line 145) | constructor(options: Options.Public = {}) {
    method guardInert (line 154) | private guardInert(): boolean {
    method getViewportMargin (line 161) | protected getViewportMargin(): { top: string; left: string; right: str...
    method getTrackSize (line 201) | protected getTrackSize(): number {
    method updateIntersectingState (line 207) | protected updateIntersectingState(nextIntersecting: boolean | undefine...
    method updateElementBoundsCache (line 212) | protected updateElementBoundsCache(): void {
    method updateContainerBoundsCache (line 234) | protected updateContainerBoundsCache(): void {
    method updateProgress (line 251) | protected updateProgress(): void {
    method updateViewportObserver (line 289) | protected updateViewportObserver(): void {
    method onOptionChanges (line 303) | protected onOptionChanges(changedOptions: Options.Public): void {
    method onElementResize (line 345) | protected onElementResize(): void {
    method onContainerUpdate (line 368) | protected onContainerUpdate(e: ContainerEvent): void {
    method onIntersectionChange (line 414) | protected onIntersectionChange(intersecting: boolean, target: Element)...
    method triggerEvent (line 431) | protected triggerEvent(type: EventType, forward: boolean): void {
    method modify (line 446) | public modify(options: Options.Public): ScrollMagic {
    method addPlugin (line 473) | public addPlugin(plugin: Plugin): ScrollMagic {
    method removePlugin (line 488) | public removePlugin(plugin: Plugin): ScrollMagic {
    method element (line 500) | public set element(element: Required<Options.Public>['element']) {
    method element (line 504) | public get element(): Options.Private['element'] {
    method elementStart (line 508) | public set elementStart(elementStart: Required<Options.Public>['elemen...
    method elementStart (line 512) | public get elementStart(): Required<Options.Public>['elementStart'] {
    method elementEnd (line 516) | public set elementEnd(elementEnd: Required<Options.Public>['elementEnd...
    method elementEnd (line 520) | public get elementEnd(): Required<Options.Public>['elementEnd'] {
    method container (line 524) | public set container(container: Required<Options.Public>['container']) {
    method container (line 528) | public get container(): Options.Private['container'] {
    method containerStart (line 532) | public set containerStart(containerStart: Required<Options.Public>['co...
    method containerStart (line 536) | public get containerStart(): Required<Options.Public>['containerStart'] {
    method containerEnd (line 540) | public set containerEnd(containerEnd: Required<Options.Public>['contai...
    method containerEnd (line 544) | public get containerEnd(): Required<Options.Public>['containerEnd'] {
    method vertical (line 548) | public set vertical(vertical: Required<Options.Public>['vertical']) {
    method vertical (line 552) | public get vertical(): Options.Private['vertical'] {
    method progress (line 557) | public get progress(): number {
    method scrollVelocity (line 561) | public get scrollVelocity(): number {
    method activeRange (line 568) | public get activeRange(): { start: number; end: number } {
    method resolvedBounds (line 593) | public get resolvedBounds(): Readonly<ResolvedBounds> {
    method pluginList (line 600) | public get pluginList(): Array<Plugin> {
    method disabled (line 604) | public get disabled(): boolean {
    method on (line 636) | public on(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, opt...
    method off (line 650) | public off(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void): S...
    method subscribe (line 673) | public subscribe(type: `${EventType}`, cb: (e: ScrollMagicEvent) => vo...
    method refresh (line 686) | public refresh(): ScrollMagic {
    method disable (line 711) | public disable(): ScrollMagic {
    method enable (line 734) | public enable(): ScrollMagic {
    method destroy (line 756) | public destroy(): void {
    method refreshAll (line 771) | public static refreshAll(): void {
    method destroyAll (line 775) | public static destroyAll(): void {
    method defaultOptions (line 795) | public static defaultOptions(options: Options.Public = {}): Required<O...
  method [Symbol.toStringTag] (line 608) | public get [Symbol.toStringTag](): string {

FILE: src/ScrollMagicError.ts
  class ScrollMagicError (line 2) | class ScrollMagicError extends Error {
    method constructor (line 7) | constructor(message: string, options?: ErrorOptions) {
  method [Symbol.toStringTag] (line 4) | public get [Symbol.toStringTag]() {
  class ScrollMagicInternalError (line 12) | class ScrollMagicInternalError extends ScrollMagicError {
    method constructor (line 14) | constructor(message: string, options?: ErrorOptions) {

FILE: src/ScrollMagicEvent.ts
  type EventType (line 5) | enum EventType {
  type EventLocation (line 15) | enum EventLocation {
  type ScrollDirection (line 25) | enum ScrollDirection {
  type EnumOrLiteral (line 32) | type EnumOrLiteral<T extends string> = T | `${T}`;
  type ScrollMagicEventType (line 33) | type ScrollMagicEventType = EnumOrLiteral<EventType>;
  type ScrollMagicEventLocation (line 34) | type ScrollMagicEventLocation = EnumOrLiteral<EventLocation>;
  type ScrollMagicEventScrollDirection (line 35) | type ScrollMagicEventScrollDirection = EnumOrLiteral<ScrollDirection>;
  class ScrollMagicEvent (line 42) | class ScrollMagicEvent implements DispatchableEvent {
    method constructor (line 47) | constructor(

FILE: src/ViewportObserver.ts
  type Margin (line 3) | type Margin = {
  type Options (line 10) | interface Options {
  type ObserverCallback (line 16) | type ObserverCallback = (isIntersecting: boolean, target: Element) => void;
  class ViewportObserver (line 29) | class ViewportObserver {
    method constructor (line 38) | constructor(
    method observerCallback (line 50) | private observerCallback(entries: IntersectionObserverEntry[], observe...
    method createObserver (line 67) | private createObserver(rootMargin: string) {
    method rebuildObserver (line 73) | private rebuildObserver() {
    method optionsChanged (line 95) | private optionsChanged({ root, margin, vertical }: Options) {
    method modify (line 108) | public modify(options: Options): ViewportObserver {
    method observe (line 119) | public observe(elem: Element): ViewportObserver {
    method unobserve (line 127) | public unobserve(elem: Element): ViewportObserver {
    method disconnect (line 134) | public disconnect(): void {

FILE: src/index.ts
  type EventTypeLiteral (line 6) | type EventTypeLiteral = `${EventType}`;
  type EventLocationLiteral (line 7) | type EventLocationLiteral = `${EventLocation}`;
  type ScrollDirectionLiteral (line 8) | type ScrollDirectionLiteral = `${ScrollDirection}`;

FILE: src/util/agnosticValues.ts
  type TranslationMap (line 13) | type TranslationMap = typeof translationMap;
  type AgnosticProps (line 14) | type AgnosticProps = keyof TranslationMap;
  type TranslateProp (line 15) | type TranslateProp<K extends AgnosticProps, V extends boolean> = Transla...
  type Vertical (line 16) | type Vertical = { [K in AgnosticProps]: TranslateProp<K, true> };
  type Horizontal (line 17) | type Horizontal = { [K in AgnosticProps]: TranslateProp<K, false> };
  function agnosticProps (line 32) | function agnosticProps(vertical: boolean): Vertical | Horizontal {
  type MatchProp (line 36) | type MatchProp<K extends string, T extends Record<string, unknown>> = K ...
  type GetType (line 37) | type GetType<V extends boolean, T extends Record<string, unknown>> = {

FILE: src/util/getScrollContainerDimensions.ts
  type Dimensions (line 3) | interface Dimensions {

FILE: src/util/processProperties.ts
  type PropertyProcessors (line 4) | type PropertyProcessors<I extends { [X in keyof I]: unknown }, O extends...

FILE: src/util/rafQueue.ts
  type Flushable (line 1) | type Flushable = { execute(): void };
  class RafQueue (line 14) | class RafQueue {
    method schedule (line 19) | schedule(item: Flushable): void {
    method unschedule (line 30) | unschedule(item: Flushable): void {
    method flush (line 35) | flush(): void {

FILE: src/util/sharedResizeObserver.ts
  type ResizeCallback (line 13) | type ResizeCallback = () => void;

FILE: src/util/transformObject.ts
  function transformObject (line 5) | function transformObject<

FILE: src/util/transformers.ts
  type PixelConverter (line 5) | type PixelConverter = (size: number) => number;
  type UnitTuple (line 6) | type UnitTuple = [number, 'px' | '%'];

FILE: tests/unit/ContainerProxy.test.ts
  method constructor (line 15) | constructor(containerElement: unknown) {

FILE: tests/unit/EventDispatcher.test.ts
  type TestEvent (line 4) | interface TestEvent extends DispatchableEvent {

FILE: tests/unit/sharedResizeObserver.test.ts
  class MockResizeObserver (line 10) | class MockResizeObserver {
    method constructor (line 11) | constructor(cb: ResizeObserverCallback) {
Condensed preview — 76 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (286K chars).
[
  {
    "path": ".gitignore",
    "chars": 56,
    "preview": ".DS_Store\nnode_modules\n/dist\n/docs/tsdoc\n__screenshots__"
  },
  {
    "path": ".prettierignore",
    "chars": 13,
    "preview": "dist\n.claude\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 6157,
    "preview": "# Changelog\n\n## Unreleased\n\n#### New Features\n\n- **`{ signal: AbortSignal }` event listener option** — follows the DOM `"
  },
  {
    "path": "LICENSE.md",
    "chars": 1075,
    "preview": "MIT License\n\nCopyright (c) 2014-present Jan Paepke\n\nPermission is hereby granted, free of charge, to any person obtainin"
  },
  {
    "path": "MAINTAINING.md",
    "chars": 6679,
    "preview": "# Maintaining ScrollMagic\n\nReference for triaging issues and PRs against **v3**.\n\n---\n\n## Philosophy\n\n- **Actually read "
  },
  {
    "path": "PLUGINS.md",
    "chars": 3840,
    "preview": "# ScrollMagic Plugins\n\nPlugins extend ScrollMagic instances with custom behaviour — class toggles, debug overlays, anima"
  },
  {
    "path": "README.md",
    "chars": 15241,
    "preview": "# ScrollMagic 3\n\n<!--\nTODO: Replace static shields (license, bundle, dependencies) once published\n![license](https://img"
  },
  {
    "path": "ROADMAP.md",
    "chars": 3030,
    "preview": "# Roadmap\n\nIdeas and future directions for ScrollMagic v3. Nothing here is committed, just a notepad for enhancements.\n\n"
  },
  {
    "path": "config/banner.txt",
    "chars": 414,
    "preview": "<%= pkg.title %> v<%= pkg.version %>\nAuthor: <%= pkg.author.name %> (<%= pkg.author.url %>)\n<%= pkg.contributors && pkg."
  },
  {
    "path": "docs/diagrams/contain.html",
    "chars": 3722,
    "preview": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"UTF-8\" />\n\t\t<title>ScrollMagic – Contain Diagram</title>\n\t\t<li"
  },
  {
    "path": "docs/diagrams/intersect.html",
    "chars": 5684,
    "preview": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"UTF-8\" />\n\t\t<title>ScrollMagic – Intersect Diagram</title>\n\t\t<"
  },
  {
    "path": "docs/diagrams/shared.css",
    "chars": 3772,
    "preview": "/* shared styles for ScrollMagic animated diagrams */\n:root {\n\t--viewport-width: 260px;\n\t--viewport-height: 174px;\n\t--vi"
  },
  {
    "path": "eslint.config.mjs",
    "chars": 930,
    "preview": "//@ts-check\n\nimport eslint from '@eslint/js';\nimport compat from 'eslint-plugin-compat';\nimport { defineConfig } from 'e"
  },
  {
    "path": "package.json",
    "chars": 2378,
    "preview": "{\n\t\"name\": \"scrollmagic\",\n\t\"title\": \"ScrollMagic\",\n\t\"version\": \"3.0.0-beta.4\",\n\t\"description\": \"The lightweight library "
  },
  {
    "path": "prettier.config.mjs",
    "chars": 355,
    "preview": "/**\n * @see https://prettier.io/docs/configuration\n * @type {import(\"prettier\").Config}\n */\nconst config = {\n\tsemi: true"
  },
  {
    "path": "rollup.config.mjs",
    "chars": 1278,
    "preview": "import terser from '@rollup/plugin-terser';\nimport typescript from '@rollup/plugin-typescript';\nimport bundleSize from '"
  },
  {
    "path": "scripts/export-diagrams.mjs",
    "chars": 5094,
    "preview": "/**\n * Converts HTML animation files to GIF using Playwright + gifenc + pngjs.\n *\n * Usage:\n *   npm run export-diagrams"
  },
  {
    "path": "src/Container.ts",
    "chars": 5824,
    "preview": "import { type DispatchableEvent, EventDispatcher } from './EventDispatcher';\nimport { getScrollContainerDimensions } fro"
  },
  {
    "path": "src/ContainerProxy.ts",
    "chars": 2325,
    "preview": "import { Container, type ContainerEvent, type ScrollContainer } from './Container';\nimport { ScrollMagic } from './Scrol"
  },
  {
    "path": "src/EventDispatcher.ts",
    "chars": 2428,
    "preview": "const noop = () => {};\ntype EventType = string;\nexport interface DispatchableEvent {\n\treadonly target: unknown;\n\treadonl"
  },
  {
    "path": "src/ExecutionQueue.ts",
    "chars": 3144,
    "preview": "import { rafQueue } from './util/rafQueue';\nimport { transformObject } from './util/transformObject';\ntype Callback = ()"
  },
  {
    "path": "src/Options.processors.ts",
    "chars": 4351,
    "preview": "import {\n\tPixelConverter,\n\tPrivate,\n\tPrivateUninferred,\n\tPublic,\n\tinferredContainerDefaults,\n\tdefaults as optionDefaults"
  },
  {
    "path": "src/Options.ts",
    "chars": 3218,
    "preview": "type NullableProperties<T extends object, K extends keyof T> = Omit<T, K> & {\n\t[X in K]: T[X] | null;\n};\ntype UnitString"
  },
  {
    "path": "src/ScrollMagic.ts",
    "chars": 31837,
    "preview": "import type { ContainerEvent } from './Container';\nimport { ContainerProxy } from './ContainerProxy';\nimport { EventDisp"
  },
  {
    "path": "src/ScrollMagicError.ts",
    "chars": 635,
    "preview": "/** Base error class for all ScrollMagic errors. */\nexport class ScrollMagicError extends Error {\n\tpublic override reado"
  },
  {
    "path": "src/ScrollMagicEvent.ts",
    "chars": 2541,
    "preview": "import type { DispatchableEvent } from './EventDispatcher';\nimport type { ScrollMagic } from './ScrollMagic';\n\n/** Lifec"
  },
  {
    "path": "src/ViewportObserver.ts",
    "chars": 4686,
    "preview": "import { pickDifferencesFlat } from './util/pickDifferencesFlat';\n\ntype Margin = {\n\ttop: string;\n\tright: string;\n\tbottom"
  },
  {
    "path": "src/env.d.ts",
    "chars": 224,
    "preview": "// Ambient type for process.env.NODE_ENV — used for dev-only warnings.\n// Bundlers replace process.env.NODE_ENV at build"
  },
  {
    "path": "src/index.ts",
    "chars": 973,
    "preview": "import type { Public as ScrollMagicOptions } from './Options';\nimport type { Plugin as ScrollMagicPlugin } from './Scrol"
  },
  {
    "path": "src/util/agnosticValues.ts",
    "chars": 2494,
    "preview": "import { transformObject } from './transformObject';\n\n// { agnosticProp: [verticalProp, horizontalProp] }\nconst translat"
  },
  {
    "path": "src/util/getScrollContainerDimensions.ts",
    "chars": 871,
    "preview": "import { isWindow } from './typeguards';\n\ninterface Dimensions {\n\treadonly clientWidth: number;\n\treadonly clientHeight: "
  },
  {
    "path": "src/util/getScrollPos.ts",
    "chars": 491,
    "preview": "import { isWindow } from './typeguards';\n\nconst scrollTop = (container: Window | Element): number =>\n\tisWindow(container"
  },
  {
    "path": "src/util/pickDifferencesFlat.ts",
    "chars": 345,
    "preview": "// checks an object against a reference object and returns a new object containing only differences in direct descendent"
  },
  {
    "path": "src/util/processProperties.ts",
    "chars": 1712,
    "preview": "import { ScrollMagicError } from '../ScrollMagicError';\n\n// type to ensure there's an output processor for every input\ne"
  },
  {
    "path": "src/util/rafQueue.ts",
    "chars": 1371,
    "preview": "type Flushable = { execute(): void };\n\n/**\n * Batches execution of multiple Flushable items into a single requestAnimati"
  },
  {
    "path": "src/util/registerEvent.ts",
    "chars": 802,
    "preview": "/**\n * Adds the passed listener as an event listener to the passed event target, and returns a function which reverses t"
  },
  {
    "path": "src/util/sanitizeProperties.ts",
    "chars": 568,
    "preview": "export const sanitizeProperties = <T extends Record<string, any>>(obj: T, defaults: Record<string, any>): T =>\n\tObject.e"
  },
  {
    "path": "src/util/sharedResizeObserver.ts",
    "chars": 2062,
    "preview": "/**\n * Shared ResizeObserver — uses a single observer instance for all elements,\n * routing entries to per-element callb"
  },
  {
    "path": "src/util/throttleRaf.ts",
    "chars": 589,
    "preview": "export const throttleRaf = <F extends (...a: unknown[]) => any>(\n\tfunc: F\n): ((this: ThisParameterType<F>, ...args: Para"
  },
  {
    "path": "src/util/transformObject.ts",
    "chars": 527,
    "preview": "/**\n * Type-safe `Object.fromEntries(Object.entries(obj).map(fn))`.\n * The generics preserve key/value types through the"
  },
  {
    "path": "src/util/transformers.ts",
    "chars": 3199,
    "preview": "import { positionShorthands } from '../Options';\nimport { ScrollMagicError } from '../ScrollMagicError';\nimport { isHTML"
  },
  {
    "path": "src/util/typeguards.ts",
    "chars": 267,
    "preview": "export const isWindow = (val: unknown): val is Window => val instanceof Window;\nexport const isHTMLElement = (val: unkno"
  },
  {
    "path": "src/util.ts",
    "chars": 71,
    "preview": "export { agnosticProps, agnosticValues } from './util/agnosticValues';\n"
  },
  {
    "path": "tests/e2e/UNTESTED-KNOWN-BUGS.md",
    "chars": 3455,
    "preview": "# Untested Known Bugs\n\nDocumentation for potential v3 issues derived from v2 bug reports that cannot be covered by autom"
  },
  {
    "path": "tests/e2e/caching.test.ts",
    "chars": 5936,
    "preview": "/**\n * PixelConverter caching and bounds invalidation.\n * Tests for: converters not re-called on scroll (only on resize)"
  },
  {
    "path": "tests/e2e/containers.test.ts",
    "chars": 7254,
    "preview": "/**\n * Scroll container behavior: non-window scroll parents, container edge cases, viewport resize.\n * Tests for: div co"
  },
  {
    "path": "tests/e2e/destroy.test.ts",
    "chars": 6058,
    "preview": "import { describe, test, expect, afterEach, vi } from 'vitest';\nimport ScrollMagic from '../../src/index';\nimport { clea"
  },
  {
    "path": "tests/e2e/dev-warnings.test.ts",
    "chars": 2960,
    "preview": "import { describe, test, expect, afterEach, vi } from 'vitest';\nimport ScrollMagic from '../../src/index';\nimport { clea"
  },
  {
    "path": "tests/e2e/element-tracking.test.ts",
    "chars": 4380,
    "preview": "/**\n * Element tracking edge cases: resize, DOM mutations, SVG elements.\n * Tests for: layout shifts from element resize"
  },
  {
    "path": "tests/e2e/enable-disable.test.ts",
    "chars": 11757,
    "preview": "/**\n * Enable/disable: pause and resume tracking without destroying.\n * Tests for: state & getters, method guards, idemp"
  },
  {
    "path": "tests/e2e/helpers.ts",
    "chars": 2331,
    "preview": "export const waitForFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()));\nexport const wai"
  },
  {
    "path": "tests/e2e/refresh.test.ts",
    "chars": 6445,
    "preview": "/**\n * Manual refresh API: refresh(), refreshAll(), destroyAll().\n * Tests for: position change detection via refresh, c"
  },
  {
    "path": "tests/e2e/scroll-progress.test.ts",
    "chars": 14142,
    "preview": "/**\n * Core scroll progress tracking and event behavior.\n * Tests for: progress 0→1 lifecycle, enter/leave/progress even"
  },
  {
    "path": "tests/e2e/scroll-velocity.test.ts",
    "chars": 4977,
    "preview": "/**\n * Scroll velocity: per-container px/s computation exposed via ScrollMagic getter.\n * Tests for: non-zero during scr"
  },
  {
    "path": "tests/unit/ContainerProxy.test.ts",
    "chars": 4463,
    "preview": "import { describe, test, expect, vi, beforeEach } from 'vitest';\nimport type { ScrollMagic } from '../../src/ScrollMagic"
  },
  {
    "path": "tests/unit/EventDispatcher.test.ts",
    "chars": 8846,
    "preview": "import { describe, test, expect, vi } from 'vitest';\nimport { EventDispatcher, type DispatchableEvent } from '../../src/"
  },
  {
    "path": "tests/unit/ExecutionQueue.test.ts",
    "chars": 3319,
    "preview": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { ExecutionQueue } from '../../src/Ex"
  },
  {
    "path": "tests/unit/Options.processors.test.ts",
    "chars": 6818,
    "preview": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { sanitizeOptions, processOptions } f"
  },
  {
    "path": "tests/unit/ScrollMagicError.test.ts",
    "chars": 1864,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { ScrollMagicError, ScrollMagicInternalError } from '../../src/S"
  },
  {
    "path": "tests/unit/ScrollMagicEvent.test.ts",
    "chars": 2347,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { ScrollMagicEvent, EventType, EventLocation, ScrollDirection } "
  },
  {
    "path": "tests/unit/agnosticValues.test.ts",
    "chars": 1788,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { agnosticProps, agnosticValues } from '../../src/util/agnosticV"
  },
  {
    "path": "tests/unit/getScrollContainerDimensions.test.ts",
    "chars": 1147,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { getScrollContainerDimensions } from '../../src/util/getScrollC"
  },
  {
    "path": "tests/unit/getScrollPos.test.ts",
    "chars": 831,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { getScrollPos } from '../../src/util/getScrollPos';\n\n// NOTE: j"
  },
  {
    "path": "tests/unit/pickDifferencesFlat.test.ts",
    "chars": 1083,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { pickDifferencesFlat } from '../../src/util/pickDifferencesFlat"
  },
  {
    "path": "tests/unit/processProperties.test.ts",
    "chars": 2095,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { processProperties } from '../../src/util/processProperties';\ni"
  },
  {
    "path": "tests/unit/rafQueue.test.ts",
    "chars": 3156,
    "preview": "import { describe, test, expect, vi, beforeEach } from 'vitest';\nimport { rafQueue } from '../../src/util/rafQueue';\n\nco"
  },
  {
    "path": "tests/unit/registerEvent.test.ts",
    "chars": 1092,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { registerEvent } from '../../src/util/registerEvent';\n\ndescribe"
  },
  {
    "path": "tests/unit/sanitizeProperties.test.ts",
    "chars": 1513,
    "preview": "import { describe, test, expect, vi, afterEach } from 'vitest';\nimport { sanitizeProperties } from '../../src/util/sanit"
  },
  {
    "path": "tests/unit/sharedResizeObserver.test.ts",
    "chars": 3663,
    "preview": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { rafQueue } from '../../src/util/raf"
  },
  {
    "path": "tests/unit/throttleRaf.test.ts",
    "chars": 2551,
    "preview": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { throttleRaf } from '../../src/util/"
  },
  {
    "path": "tests/unit/transformObject.test.ts",
    "chars": 1089,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { transformObject } from '../../src/util/transformObject';\n\ndesc"
  },
  {
    "path": "tests/unit/transformers.test.ts",
    "chars": 6441,
    "preview": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport {\n\tnumberToPercString,\n\tunitStringToP"
  },
  {
    "path": "tests/unit/typeguards.test.ts",
    "chars": 1622,
    "preview": "import { describe, test, expect } from 'vitest';\nimport { isWindow, isHTMLElement, isSVGElement } from '../../src/util/t"
  },
  {
    "path": "tsconfig.json",
    "chars": 446,
    "preview": "{\n\t\"compilerOptions\": {\n\t\t\"outDir\": \"./dist\",\n\t\t\"target\": \"ES6\",\n\t\t\"lib\": [\"ES2020\", \"ES2022.Error\", \"DOM\"],\n\t\t\"module\":"
  },
  {
    "path": "typedoc.json",
    "chars": 228,
    "preview": "{\n\t\"$schema\": \"https://typedoc.org/schema.json\",\n\t\"entryPoints\": [\"src/index.ts\", \"src/util.ts\"],\n\t\"out\": \"docs/tsdoc\",\n"
  },
  {
    "path": "vitest.config.ts",
    "chars": 699,
    "preview": "import { defineConfig } from 'vitest/config';\nimport { playwright } from '@vitest/browser-playwright';\n\n// `vitest` (wat"
  }
]

About this extraction

This page contains the full source code of the janpaepke/ScrollMagic GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 76 files (257.9 KB), approximately 67.4k tokens, and a symbol index with 167 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!