[
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n/docs/tsdoc\n__screenshots__"
  },
  {
    "path": ".prettierignore",
    "content": "dist\n.claude\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## Unreleased\n\n#### New Features\n\n- **`{ 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.\n\n### 3.0.0-beta.4\n\n#### Internal\n\n- Codebase terminology cleanup (remove legacy \"scene\" naming, rename `scrollOffset` → `activeRange`).\n- ContainerProxy: separate `size`/`position` getters replace combined `rect`.\n- Unit test coverage expanded from 84 to 164 tests.\n\n### 3.0.0-beta.3\n\n#### Breaking Changes\n\n- **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 }`.\n\n#### New Features\n\n- **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.\n\n#### Build\n\n- **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.\n\n### 3.0.0-beta.2\n\n#### Breaking Changes\n\n- **`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).\n- **`computedOptions` removed** — replaced by `resolvedBounds`, which returns `{ element: ElementBounds, container: ContainerBounds }` (cached layout bounds only, no longer leaks the full internal options structure).\n- **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.\n\n#### New Features\n\n- **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.\n- **`scrollmagic/util` subpath export** — exposes `agnosticValues` and `agnosticProps` via `import { ... } from 'scrollmagic/util'` for plugin authors working with direction-agnostic bounds.\n- **`ElementBounds`, `ContainerBounds`, `ResolvedBounds` types exported** — available from the main entry point for plugin and integration authors.\n\n## 3.0.0-beta.1\n\n### New Features\n\n- **`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).\n- **`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.\n- **`{ once: true }` event listener option** — follows the DOM `addEventListener` options bag pattern. Works with both `.on()` and `.subscribe()`.\n- **`refresh()` / `refreshAll()` / `destroyAll()`** — force bounds recalculation after layout changes invisible to ResizeObserver (position shifts, class toggles, sibling DOM mutations, font loading, etc.).\n- **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.\n- **Element–container ancestry validation** (dev mode) — `console.error` when the tracked element isn't a descendant of its container, catching silent IntersectionObserver misconfiguration.\n\n### Bug Fixes\n\n- **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.\n- **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.\n- **Direction change not invalidating elementBoundsCache** — changing `vertical` via `modify()` left stale axis-dependent bounds in the cache.\n- **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.\n- **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.\n- **`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.\n\n### Performance\n\n- **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.\n- **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.\n\n### Internal\n\n- Explicit `type` keyword on type-only imports for better tree-shaking.\n- New `Vector` type for `{x, y}` pairs, replacing the old `ScrollDelta` shape.\n- E2e tests reorganized from origin-based to feature-based structure. 13 regression tests covering v2-reported edge cases added.\n- Added MAINTAINING.md and ROADMAP.md.\n"
  },
  {
    "path": "LICENSE.md",
    "content": "MIT License\n\nCopyright (c) 2014-present Jan Paepke\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MAINTAINING.md",
    "content": "# Maintaining ScrollMagic\n\nReference for triaging issues and PRs against **v3**.\n\n---\n\n## Philosophy\n\n- **Actually read the issue** — every response should show it. No copy-paste walls.\n- **Close fast over leaving open** — an open issue implies intent to act. If you won't act on it, close it clearly.\n- **Specific > comprehensive** — 2–4 sentences that address the actual scenario beats a thorough generic answer.\n\n---\n\n## Issue Triage\n\n### Categories\n\n**Bug Reports** — broken behavior, browser-specific failures, wrong values, etc.\n\n- Investigate. If confirmed, fix or document in `tests/e2e/UNTESTED-KNOWN-BUGS.md`.\n- Label: `bug`\n\n**Support / How-To** — \"How do I...\", \"Is it possible to...\", \"Not working with X\"\n\n- Check comments first — if already answered, reference the solution: _\"Looks like [username] provided a working solution above.\"_\n- If you can answer confidently, do so briefly.\n- Label: `support`\n\n**Feature Requests** — new capabilities, API additions\n\n- Evaluate fit with v3's scope and architecture.\n- If in scope and a PR would be welcome, say so and label `help wanted`.\n- Label: `enhancement`\n\n**Build / Module Issues** — webpack, npm, TypeScript types, bundler problems\n\n- v3 is a native ES module with TypeScript built-in — many v2-era build problems don't apply.\n- Label: `support` or `bug` depending on whether something is broken vs. misunderstood.\n\n**Meta / Admin** — license questions, dead links, repo housekeeping\n\n- Handle case-by-case.\n- Label: `invalid` or close without comment if clearly stale.\n\n---\n\n### Labels\n\n| Label                | Meaning                                             |\n| -------------------- | --------------------------------------------------- |\n| `bug`                | Confirmed broken behavior                           |\n| `enhancement`        | New capability request                              |\n| `support`            | Usage question, how-to                              |\n| `needs-info`         | Waiting on reporter to provide more context         |\n| `needs-reproduction` | Cannot reproduce; a minimal repro is required       |\n| `help wanted`        | Community contribution welcome                      |\n| `good first issue`   | Low barrier, good entry point for new contributors  |\n| `duplicate`          | Already tracked elsewhere — close and link          |\n| `wontfix`            | Deliberate decision not to address                  |\n| `invalid`            | Off-topic, spam, malformed                          |\n| `mobile`             | Mobile-specific concern (IO, viewport, touch, etc.) |\n\n---\n\n### Response Guidelines\n\n**Format:** No emojis in the body. 2–4 sentences specific to the issue, then close.\n\n**Close reason:**\n\n- Won't fix / out of scope: `not planned`\n- Duplicate: `duplicate`\n- Resolved or confirmed working: `completed`\n- Spam / off-topic: `not planned`\n\n### Response Templates\n\n**Cannot reproduce:**\n\n> 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.\n\n**Already answered in comments:**\n\n> 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.\n\n**Feature request, not accepting:**\n\n> 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.\n\n---\n\n## Pull Requests\n\n### Merge Criteria\n\n- CI passes (types, lint, tests)\n- New behavior has test coverage\n- No unrelated changes bundled in\n- Commit messages follow conventional commits\n\n### Review Checklist\n\n- Does the change match the stated intent?\n- Is there a simpler approach?\n- Are edge cases handled?\n- Does it follow existing code style?\n\n### Abandoned PRs\n\nComment 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.\n\n---\n\n## Stale Issue Policy\n\n1. Apply `needs-info` or `needs-reproduction` when waiting on the reporter.\n2. If no response after 30 days, leave one comment asking for an update.\n3. Close after 60 days total: _\"Closing for inactivity — feel free to reopen or link a reproduction if you revisit this.\"_\n\n<!-- GitHub Actions stale bot (https://github.com/actions/stale) can automate steps 2–3 if the volume warrants it -->\n\n---\n\n## Release Process\n\n- Semver: patch for bug fixes, minor for new features, major for breaking changes\n- Changelog maintained in `CHANGELOG.md`\n- Tagged GitHub releases with release notes\n- Published to npm as `scrollmagic`\n\n---\n\n## Handling v2 Issues\n\nv2 is in maintenance-only mode — 2.0.9 is the final release. Issues filed against v2 should be acknowledged and redirected to v3.\n\n### Approach by Category\n\n**Bug:** Acknowledge the specific bug. Mention if v3's architecture approaches it differently (but don't promise a fix). Close as `not planned`.\n\n**Support / How-To:** Answer briefly if confident — it helps people who find the issue via search. Then redirect to v3.\n\n**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`.\n\n**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`.\n\nWhen redirecting to v3, link to the [README on main](https://github.com/janpaepke/ScrollMagic/blob/main/README.md).\n\n### v3 Feature Reference\n\nQuick reference for assessing whether a v2 request or bug is addressed in v3:\n\n- **No Controller** — each `new ScrollMagic({ element })` is self-contained\n- **No pinning** — no pin system; CSS `position: sticky` covers most use cases\n- **No built-in animation** — pair with GSAP, Motion, anime.js, etc.\n- **Horizontal scroll** — `vertical: false` option\n- **Any scroll container** — `scrollParent` accepts `window` or any element\n- **Plugin system** — `addPlugin()` with `onAdd`, `onRemove`, `onModify` lifecycle hooks\n- **Named position shorthands** — `'here'` (0%), `'center'` (50%), `'opposite'` (100%)\n- **Inset functions** — `(size) => number` for dynamic computation\n- **Native TypeScript** with full type exports\n- **ES module** with UMD fallback, zero dependencies\n- **SSR safe**\n- **MIT license only** (v2 was dual MIT/GPL-3.0+)\n- **Events:** `enter`, `leave`, `progress` — each with `direction`, `location`, `event.target`\n- **Getters/setters** for all options; `modify()` for batch updates\n- **`enable()` / `disable()`** — pause/resume tracking without destroying; `modify()` works while disabled\n"
  },
  {
    "path": "PLUGINS.md",
    "content": "# ScrollMagic Plugins\n\nPlugins 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.\n\n## Basic Example\n\n```ts\nimport ScrollMagic, { type ScrollMagicPlugin } from 'scrollmagic';\n\nconst myPlugin: ScrollMagicPlugin = {\n\tname: 'my-plugin',\n\tonAdd() {\n\t\t// `this` is the ScrollMagic instance\n\t\tthis.on('enter', () => {\n\t\t\t/* ... */\n\t\t});\n\t},\n\tonRemove() {\n\t\tthis.off('enter' /* ... */);\n\t},\n};\n\nconst sm = new ScrollMagic({ element: '#target' });\nsm.addPlugin(myPlugin);\nsm.removePlugin(myPlugin); // or let destroy() handle cleanup\n```\n\n## Lifecycle Hooks\n\nAll hooks are optional. In every hook, `this` is bound to the ScrollMagic instance the plugin is attached to.\n\n| Hook | When it fires |\n| --- | --- |\n| `onAdd()` | Immediately when `addPlugin()` is called. |\n| `onRemove()` | When `removePlugin()` is called. The instance is still alive. |\n| `onEnable()` | When `enable()` resumes tracking. |\n| `onDisable()` | When `disable()` pauses tracking. Also fires during `destroy()` (before `onDestroy`) if the instance was enabled. |\n| `onDestroy()` | When `destroy()` tears down the instance. The instance is already disabled at this point. |\n| `onModify(changes)` | When options change via `modify()` or a setter. `changes` contains only the options that actually changed. |\n\n### Hook Sequence\n\n**`removePlugin(plugin)`** fires `onRemove` only.\n\n**`destroy()`** on an enabled instance fires in order:\n\n1. `onDisable` — tracking paused (observers disconnected)\n2. `onDestroy` — instance dying (final cleanup)\n\nIf the instance was already disabled before `destroy()`, only `onDestroy` fires.\n\nNote: `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:\n\n```ts\nconst cleanup = function (this: ScrollMagic) {\n\t/* ... */\n};\nconst plugin: ScrollMagicPlugin = {\n\tname: 'shared-cleanup',\n\tonRemove: cleanup,\n\tonDestroy: cleanup,\n};\n```\n\n## Using Utilities\n\nScrollMagic exposes direction-agnostic helpers via a separate entry point. These are useful for plugins that need to work with both vertical and horizontal scrolling:\n\n```ts\nimport { agnosticValues, agnosticProps } from 'scrollmagic/util';\n```\n\n- **`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.).\n- **`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, ... }`.\n\n## Plugin Instance Methods\n\nThese methods are available on every ScrollMagic instance — plugins use them via `this` in hooks:\n\n```ts\n// Event listeners\nthis.on(type, callback);\nthis.off(type, callback);\nthis.subscribe(type, callback); // returns unsubscribe function\n\n// Read state\nthis.progress; // 0–1\nthis.disabled; // true when disabled or destroyed\nthis.scrollVelocity; // px/s along tracked axis\nthis.activeRange; // { start, end } container scroll positions where tracking is active\nthis.resolvedBounds; // { element, container } cached layout bounds\n\n// Read/write options\nthis.element;\nthis.elementStart;\nthis.elementEnd;\nthis.container;\nthis.containerStart;\nthis.containerEnd;\nthis.vertical;\n\n// Actions\nthis.modify(options); // update multiple options at once\nthis.refresh(); // force bounds recalculation\nthis.enable();\nthis.disable();\nthis.destroy();\n\n// Plugin management\nthis.addPlugin(plugin);\nthis.removePlugin(plugin);\nthis.pluginList; // snapshot of registered plugins\n```\n"
  },
  {
    "path": "README.md",
    "content": "# ScrollMagic 3\n\n<!--\nTODO: Replace static shields (license, bundle, dependencies) once published\n![license](https://img.shields.io/npm/l/scrollmagic)\n-->\n\n[![npm version](https://img.shields.io/npm/v/scrollmagic/next)](https://www.npmjs.com/package/scrollmagic/v/next)\n[![license](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE.md)\n[![bundle size](https://img.shields.io/badge/gzip-~6kb-brightgreen)](https://bundlephobia.com/package/scrollmagic)\n[![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://npmgraph.js.org/?q=scrollmagic)\n[![TypeScript](https://img.shields.io/badge/TypeScript-native-blue)](https://www.typescriptlang.org/)\n\n### The lightweight library for magical scroll interactions\n\n> **Looking for ScrollMagic v2?** The legacy version is on the [`v2-stable`](https://github.com/janpaepke/ScrollMagic/tree/v2-stable) branch.\n\nScrollMagic tells you where an element is relative to the viewport as the user scrolls — and fires events when that changes.\n\nIt'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.\n\n[![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!')\n\n### Not an animation library – unless you want it to be\n\nBy 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/).\n\nFor 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.\n\nScrollMagic 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.\n\n### Why ScrollMagic?\n\n- Tiny footprint, zero dependencies\n- Free to use ([open source](LICENSE.md))\n- Optimized for performance (shared observers, batched rAF, single-frame updates)\n- Built for modern browsers, mobile compatible\n- Native TypeScript support\n- SSR safe\n- Works with any scroll container (window or custom element)\n- Horizontal and vertical scrolling\n- Plugin system for extensibility\n- Framework agnostic — works with React, Vue, vanilla JS, anything\n\n## Installation\n\n```sh\nnpm install scrollmagic@next\n```\n\n## Quick Start\n\n```js\nimport ScrollMagic from 'scrollmagic';\n\nnew ScrollMagic({ element: '#my-element' })\n\t.on('enter', () => console.log('visible!'))\n\t.on('leave', () => console.log('gone!'))\n\t.on('progress', e => console.log(`${(e.target.progress * 100).toFixed(0)}%`));\n```\n\n## How It Works\n\nScrollMagic uses two sets of bounds to define the active range:\n\n- **Container bounds** — a zone on the scroll container, defined by `containerStart` and `containerEnd`\n- **Element bounds** — a zone on the tracked element, defined by `elementStart` and `elementEnd`\n\nProgress goes from `0` to `1` as the element bounds pass through the container bounds. Events fire on enter, leave, and progress change.\n\n### Contain and Intersect\n\nThe two most common configurations are **contain** and **intersect**. They differ in where the container bounds are positioned:\n\n#### Contain (default when `element` is `null`)\n\n<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\" />\n\nThe 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.\n\nTypical uses: scroll progress bars, parallax, scroll-linked video, scroll-driven storytelling.\n\n<br clear=\"both\" />\n\n#### Intersect (default when `element` is set)\n\n<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\" />\n\nThe 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.\n\nTypical uses: enter/leave animations, lazy loading, class toggles, visibility tracking.\n\n<br clear=\"both\" />\n\n#### Not just defaults\n\nWhile _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**.\n\n#### Native scroll-driven animation ranges\n\nIf 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:\n\n| Native range | ScrollMagic equivalent |\n| ------------ | ---------------------- |\n| `cover`      | _intersect_ default — `containerStart: 'opposite', containerEnd: 'opposite'` |\n| `contain`    | _contain_ default — `containerStart: 0, containerEnd: 0` |\n| `entry`      | `containerStart: 'opposite', containerEnd: 0` — container zone collapses to the trailing edge |\n| `exit`       | `containerStart: 0, containerEnd: 'opposite'` — container zone collapses to the leading edge |\n\nThe 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.\n\n## Options\n\nAll options are optional. They can be passed to the constructor and updated at any time via setters or `.modify()`.\n\n| Option           | Type                                   | Default                    | Description                                           |\n| ---------------- | -------------------------------------- | -------------------------- | ----------------------------------------------------- |\n| `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. |\n| `elementStart`   | `number \\| string \\| function`         | `0`                        | Start **inset** on the element.                       |\n| `elementEnd`     | `number \\| string \\| function`         | `0`                        | End **inset** on the element.                         |\n| `container`      | `Window \\| Element \\| string \\| null`  | `window`                   | The scroll container (or CSS selector). Selectors use the first match. |\n| `containerStart` | `number \\| string \\| function \\| null` | inferred (see below)       | Start **inset** on the scroll container.              |\n| `containerEnd`   | `number \\| string \\| function \\| null` | inferred (see below)       | End **inset** on the scroll container.                |\n| `vertical`       | `boolean`                              | `true`                     | Scroll axis. `true` = vertical, `false` = horizontal. |\n\n**Inset values** work like CSS `top`/`bottom`: positive values offset inward from the respective edge in the tracked direction. Accepted value types:\n\n- **Numbers** — pixel values (e.g. `50`)\n- **Strings** — percentage or pixel strings (e.g. `'50%'`, `'20px'`), relative to the parent size (scroll container for container options, element for element options)\n- **Named positions** — `'here'` (0%), `'center'` (50%), `'opposite'` (100%)\n- **Functions** — `(size) => number` for dynamic computation\n\n**`null` means infer:** For `element`, `container`, `containerStart`, or `containerEnd`, setting it to `null` resets them to their inferred default.\n\nFor `containerStart`/`containerEnd` the inferred values depend on `element`:\n\n- **`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.\n- **`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.\n\n## Events\n\nSubscribe 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.\n\n| Event      | When                                                     |\n| ---------- | -------------------------------------------------------- |\n| `enter`    | Element enters the active zone (progress leaves 0 or 1)  |\n| `leave`    | Element leaves the active zone (progress reaches 0 or 1) |\n| `progress` | Progress value changes while in the active zone          |\n\nEvery event provides:\n\n```ts\nevent.target; // the ScrollMagic instance (access all properties, e.g. event.target.progress, event.target.element)\nevent.type; // 'enter' | 'leave' | 'progress'\nevent.direction; // 'forward' | 'reverse'\nevent.location; // 'start' | 'inside' | 'end'\n```\n\n## Examples\n\n```js\n// Intersect (default): active while any part of the element\n// is visible in the viewport\nnew ScrollMagic({\n\telement: '#a',\n});\n\n// Intersect with narrowed container zone:\n// active while the element passes through the center line\nnew ScrollMagic({\n\telement: '#b',\n\tcontainerStart: 'center',\n\tcontainerEnd: 'center',\n});\n\n// Same as above, but with element offsets:\n// starts 50px before the element, ends 100px after it\nnew ScrollMagic({\n\telement: '#c',\n\tcontainerStart: 'center',\n\tcontainerEnd: 'center',\n\telementStart: -50,\n\telementEnd: -100,\n});\n\n// Fixed scroll distance of 150px, regardless of element height.\n// elementEnd receives the element's size and offsets from\n// the bottom — (size - 150) leaves only 150px of track.\nnew ScrollMagic({\n\telement: '#d',\n\tcontainerStart: 'center',\n\tcontainerEnd: 'center',\n\telementEnd: size => size - 150,\n});\n\n// Contain: active only while the element is fully visible\n// (element insets pushed to opposite edges = full element height)\nnew ScrollMagic({\n\telement: '#e',\n\telementStart: 'opposite', // same as '100%'\n\telementEnd: 'opposite', // same as '100%'\n});\n\n// Contain (default when no element): track overall scroll progress\nnew ScrollMagic();\n```\n\n## API\n\n```ts\nconst sm = new ScrollMagic(options);\n\n// Event listeners\nsm.on(type, callback); // add listener, returns instance (chainable)\nsm.on(type, callback, { once: true }); // listener auto-removes after first invocation\nsm.off(type, callback); // remove listener, returns instance (chainable)\nsm.subscribe(type, callback); // add listener, returns unsubscribe function\nsm.subscribe(type, callback, { once: true }); // both auto-removes and returns unsubscribe\n\n// Modify options after creation\nsm.modify({ containerStart: 'center' });\n\n// All options can also be directly read and written\nconst elem = sm.element; // get the tracked element\nsm.containerStart = 'center'; // set individual options\n\n// Read-only getters\nsm.progress; // 0–1, how far through the active zone\nsm.activeRange; // { start, end } container scroll positions where tracking is active\nsm.scrollVelocity; // px/s along tracked axis, 0 when idle\nsm.resolvedBounds; // { element, container } cached layout bounds\n\n// Refresh — recalculate bounds after external layout changes\nsm.refresh();\n\n// Pause / resume tracking without destroying\nsm.disable(); // disconnects all observers, freezes progress\nsm.enable(); // reconnects observers, recalculates from current state\nsm.disabled; // read-only, true when disabled or destroyed\n\n// Lifecycle\nsm.destroy();\n\n// Static\nScrollMagic.defaultOptions({ vertical: false }); // get/set defaults for new instances\nScrollMagic.refreshAll(); // refresh every active instance\nScrollMagic.destroyAll(); // destroy every active instance\n```\n\n## When to use `refresh()`\n\nScrollMagic 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.\n\nCall `refresh()` (or `ScrollMagic.refreshAll()`) after:\n\n- **CSS position/margin/padding changes** — `element.style.marginTop = '20px'`\n- **CSS class toggles that affect layout** — `element.classList.add('expanded')`\n- **DOM structure changes** — siblings added/removed above the element, shifting its position\n- **Images loading without explicit dimensions** — an `<img>` above the tracked element loads and expands, pushing it down\n- **Font loading** — `document.fonts.ready.then(() => ScrollMagic.refreshAll())`\n- **Route changes in SPAs** — content swap changes scroll height\n- **Dynamic content loading** — CMS-injected content, third-party widgets\n\n```js\n// After changing a style that affects position\nelement.style.marginTop = '100px';\nsm.refresh();\n\n// After fonts finish loading (affects text reflow)\ndocument.fonts.ready.then(() => ScrollMagic.refreshAll());\n\n// After a framework re-render that changes layout\nonRouteChange(() => ScrollMagic.refreshAll());\n```\n\nNote 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.\n\n`refresh()` is asynchronous — it schedules recalculation for the next animation frame and returns immediately. Multiple `refresh()` calls within the same frame are batched automatically.\n\n## Plugins\n\nScrollMagic has a plugin system for extending instance behaviour.\n\n```ts\nsm.addPlugin(myPlugin);\nsm.removePlugin(myPlugin);\n```\n\nSee [PLUGINS.md](PLUGINS.md) for the full plugin authoring guide.\n\n## Browser Support\n\nChrome 73+, Firefox 69+, Safari 13.1+, Edge 79+ (aligned to `ResizeObserver` support).\n\n## License\n\nMIT — [Jan Paepke](https://janpaepke.de)\n\n<!-- TODO: link to extended documentation, demos, migration guide -->\n"
  },
  {
    "path": "ROADMAP.md",
    "content": "# Roadmap\n\nIdeas and future directions for ScrollMagic v3. Nothing here is committed, just a notepad for enhancements.\n\n## Documentation & Demo Pages\n\nAPI docs and interactive demos for v3. (Highest priority)\n\n## API Gaps\n\n### Plugin candidates\n\n- **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.\n- **CSS variable output** — expose `--progress`, `--visible` etc. as CSS custom properties on elements. Enables pure-CSS scroll effects with zero JS callbacks.\n- **Batch coordination** — when N elements enter the viewport in the same frame, fire one coordinated callback with stagger support. Essential for grid/list reveals.\n- **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.\n\n## Plugin Ideas\n\n### Auto-refresh (MutationObserver + PositionObserver)\n\nAn opt-in plugin that automatically calls `refresh()` when layout-affecting changes are detected on the tracked element. Two complementary approaches:\n\n- **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.\n- **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.\n\nNeither 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.\n\n### Debug indicators\n\nVisual 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.\n\n### Pin\n\nElement 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.\n\n## Framework Integrations\n\n### React\n\nReact 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.\n"
  },
  {
    "path": "config/banner.txt",
    "content": "<%= pkg.title %> v<%= pkg.version %>\nAuthor: <%= pkg.author.name %> (<%= pkg.author.url %>)\n<%= 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') %>\nLicense: <%= pkg.license %>\nDocs & Demos: <%= pkg.homepage %>"
  },
  {
    "path": "docs/diagrams/contain.html",
    "content": "<!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<link rel=\"stylesheet\" href=\"shared.css\" />\n\t\t<style>\n\t\t\t/*\n\t\t\t * Contain mode: element taller than container.\n\t\t\t * containerStart at top edge, containerEnd at bottom edge.\n\t\t\t * No approach/exit — starts at progress 0%, ends at 100%.\n\t\t\t *\n\t\t\t * Layout geometry (all relative to stage top):\n\t\t\t *   stage-padding-top = 120px\n\t\t\t *   viewport-height = 174px\n\t\t\t *   element-height = 260px\n\t\t\t *\n\t\t\t * Progress 0% (element top at viewport top): top = 120px\n\t\t\t * Progress 100% (element bottom at viewport bottom): top = 294 - 260 = 34px\n\t\t\t *\n\t\t\t * Total travel: 86px. 5% pause at each end.\n\t\t\t */\n\n\t\t\t:root {\n\t\t\t\t--element-height: 260px;\n\t\t\t\t--element-width: calc(var(--viewport-width) - 6px);\n\t\t\t\t--anim-duration: 4s;\n\t\t\t}\n\n\t\t\t.element {\n\t\t\t\theight: var(--element-height);\n\t\t\t\twidth: var(--element-width);\n\t\t\t\tanimation: scroll-element var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes scroll-element {\n\t\t\t\t0%,\n\t\t\t\t5% {\n\t\t\t\t\ttop: 120px;\n\t\t\t\t}\n\t\t\t\t95%,\n\t\t\t\t100% {\n\t\t\t\t\ttop: 34px;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t/* markers at viewport edges */\n\t\t\t.marker-start {\n\t\t\t\ttop: var(--stage-padding-top);\n\t\t\t}\n\t\t\t.marker-end {\n\t\t\t\ttop: calc(\n\t\t\t\t\tvar(--stage-padding-top) + var(--viewport-height) - 1px\n\t\t\t\t);\n\t\t\t}\n\n\t\t\t/* progress track spans viewport height, fill grows bottom→up */\n\t\t\t.progress-track {\n\t\t\t\ttop: var(--stage-padding-top);\n\t\t\t\theight: var(--viewport-height);\n\t\t\t}\n\t\t\t.progress-fill {\n\t\t\t\theight: 0%;\n\t\t\t\tanimation: fill-progress var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes fill-progress {\n\t\t\t\t0%,\n\t\t\t\t5% {\n\t\t\t\t\theight: 0%;\n\t\t\t\t}\n\t\t\t\t95%,\n\t\t\t\t100% {\n\t\t\t\t\theight: 100%;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t/* progress label tracks the fill top, bottom→up — stays visible at 100% */\n\t\t\t.progress-label {\n\t\t\t\tanimation: label-pos var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes label-pos {\n\t\t\t\t0%,\n\t\t\t\t4% {\n\t\t\t\t\ttop: calc(var(--stage-padding-top) + var(--viewport-height));\n\t\t\t\t\topacity: 0;\n\t\t\t\t}\n\t\t\t\t5% {\n\t\t\t\t\ttop: calc(var(--stage-padding-top) + var(--viewport-height));\n\t\t\t\t\topacity: 1;\n\t\t\t\t}\n\t\t\t\t95%,\n\t\t\t\t100% {\n\t\t\t\t\ttop: var(--stage-padding-top);\n\t\t\t\t\topacity: 1;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"stage\">\n\t\t\t<div class=\"mask-top\"></div>\n\t\t\t<div class=\"mask-bottom\"></div>\n\n\t\t\t<div class=\"viewport\">\n\t\t\t\t<span class=\"viewport-label\">Container</span>\n\t\t\t</div>\n\n\t\t\t<div class=\"element\">Element</div>\n\n\t\t\t<div class=\"marker marker-start\">\n\t\t\t\t<div class=\"marker-label-group\">\n\t\t\t\t\t<span class=\"marker-name\">containerStart</span>\n\t\t\t\t\t<span class=\"marker-value\">'here' (0%)</span>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"marker-line\"></div>\n\t\t\t</div>\n\t\t\t<div class=\"marker marker-end\">\n\t\t\t\t<div class=\"marker-label-group\">\n\t\t\t\t\t<span class=\"marker-name\">containerEnd</span>\n\t\t\t\t\t<span class=\"marker-value\">'here' (0%)</span>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"marker-line\"></div>\n\t\t\t</div>\n\n\t\t\t<div class=\"progress-track\">\n\t\t\t\t<div class=\"progress-fill\"></div>\n\t\t\t</div>\n\t\t\t<div class=\"progress-label\">\n\t\t\t\t<span class=\"progress-counter\">0%</span>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<script>\n\t\t\t// Progress calculation exposed as global for both live preview and export.\n\t\t\t// Motion and progress both: 5%→95%\n\t\t\twindow.getProgress = function (frac) {\n\t\t\t\tif (frac < 0.05) return 0;\n\t\t\t\tif (frac >= 0.95) return 100;\n\t\t\t\treturn Math.round(((frac - 0.05) / 0.9) * 100);\n\t\t\t};\n\n\t\t\tconst label = document.querySelector('.progress-counter');\n\t\t\tconst duration = 4000;\n\t\t\tconst start = performance.now();\n\t\t\t(function update(now) {\n\t\t\t\tconst frac = ((now - start) % duration) / duration;\n\t\t\t\tlabel.textContent = window.getProgress(frac) + '%';\n\t\t\t\trequestAnimationFrame(update);\n\t\t\t})(start);\n\t\t</script>\n\t</body>\n</html>\n"
  },
  {
    "path": "docs/diagrams/intersect.html",
    "content": "<!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<link rel=\"stylesheet\" href=\"shared.css\" />\n\t\t<style>\n\t\t\t/*\n\t\t\t * Intersect mode: element shorter than container.\n\t\t\t * Element enters from below, exits above.\n\t\t\t *\n\t\t\t * Layout geometry (all relative to stage top):\n\t\t\t *   stage-padding-top = 120px\n\t\t\t *   viewport-height = 174px\n\t\t\t *   element-height = 100px\n\t\t\t *   stage-padding-bottom = 120px\n\t\t\t *\n\t\t\t * containerStart = viewport bottom edge = 294px\n\t\t\t * containerEnd = viewport top edge = 120px\n\t\t\t *\n\t\t\t * Element-top positions:\n\t\t\t *   start (below): 354px\n\t\t\t *   progress 0% (elem top at viewport bottom): 294px\n\t\t\t *   progress 100% (elem bottom at viewport top): 20px\n\t\t\t *   end (above): -40px\n\t\t\t *\n\t\t\t * Total travel: 394px. Constant speed.\n\t\t\t * With 5% pause at each end, motion: 5% → 95%\n\t\t\t *   progress 0% at: 5% + (60/394)*90% ≈ 18.7%\n\t\t\t *   progress 100% at: 5% + (334/394)*90% ≈ 81.3%\n\t\t\t */\n\n\t\t\t:root {\n\t\t\t\t--element-height: 100px;\n\t\t\t\t--anim-duration: 4s;\n\t\t\t}\n\n\t\t\t.element {\n\t\t\t\theight: var(--element-height);\n\t\t\t\tanimation: scroll-element var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes scroll-element {\n\t\t\t\t0%,\n\t\t\t\t5% {\n\t\t\t\t\ttop: 354px;\n\t\t\t\t}\n\t\t\t\t95%,\n\t\t\t\t100% {\n\t\t\t\t\ttop: -40px;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t/* markers at viewport edges */\n\t\t\t.marker-start {\n\t\t\t\ttop: calc(\n\t\t\t\t\tvar(--stage-padding-top) + var(--viewport-height) - 1px\n\t\t\t\t);\n\t\t\t}\n\t\t\t.marker-end {\n\t\t\t\ttop: var(--stage-padding-top);\n\t\t\t}\n\n\t\t\t/* progress track spans viewport height, fill grows bottom→up */\n\t\t\t.progress-track {\n\t\t\t\ttop: var(--stage-padding-top);\n\t\t\t\theight: var(--viewport-height);\n\t\t\t}\n\t\t\t.progress-fill {\n\t\t\t\theight: 0%;\n\t\t\t\tanimation: fill-progress var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes fill-progress {\n\t\t\t\t0%,\n\t\t\t\t18.7% {\n\t\t\t\t\theight: 0%;\n\t\t\t\t}\n\t\t\t\t81.3%,\n\t\t\t\t100% {\n\t\t\t\t\theight: 100%;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t/* progress label tracks the fill top, bottom→up — stays visible at 100% */\n\t\t\t.progress-label {\n\t\t\t\tanimation: label-pos var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes label-pos {\n\t\t\t\t0%,\n\t\t\t\t17.7% {\n\t\t\t\t\ttop: calc(var(--stage-padding-top) + var(--viewport-height));\n\t\t\t\t\topacity: 0;\n\t\t\t\t}\n\t\t\t\t18.7% {\n\t\t\t\t\ttop: calc(var(--stage-padding-top) + var(--viewport-height));\n\t\t\t\t\topacity: 1;\n\t\t\t\t}\n\t\t\t\t81.3%,\n\t\t\t\t95%,\n\t\t\t\t100% {\n\t\t\t\t\ttop: var(--stage-padding-top);\n\t\t\t\t\topacity: 1;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t/* event badges — flash at enter/leave moments */\n\t\t\t.event-badge {\n\t\t\t\tposition: absolute;\n\t\t\t\tz-index: 5;\n\t\t\t\tleft: calc(\n\t\t\t\t\tvar(--viewport-left) + var(--viewport-width) / 2\n\t\t\t\t);\n\t\t\t\ttransform: translateX(-50%);\n\t\t\t\tpadding: 3px 12px;\n\t\t\t\tborder-radius: 10px;\n\t\t\t\tfont-size: 12px;\n\t\t\t\tfont-weight: 600;\n\t\t\t\twhite-space: nowrap;\n\t\t\t\tcolor: #fff;\n\t\t\t\topacity: 0;\n\t\t\t}\n\t\t\t.event-enter {\n\t\t\t\ttop: calc(\n\t\t\t\t\tvar(--stage-padding-top) + var(--viewport-height) - 32px\n\t\t\t\t);\n\t\t\t\tbackground: #27ae60;\n\t\t\t\tanimation: flash-enter var(--anim-duration) linear infinite;\n\t\t\t}\n\t\t\t.event-leave {\n\t\t\t\ttop: calc(var(--stage-padding-top) + 12px);\n\t\t\t\tbackground: #e67e22;\n\t\t\t\tanimation: flash-leave var(--anim-duration) linear infinite;\n\t\t\t}\n\n\t\t\t@keyframes flash-enter {\n\t\t\t\t0%,\n\t\t\t\t17% {\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransform: translateX(-50%) scale(0.8);\n\t\t\t\t}\n\t\t\t\t18.7% {\n\t\t\t\t\topacity: 1;\n\t\t\t\t\ttransform: translateX(-50%) scale(1);\n\t\t\t\t}\n\t\t\t\t27% {\n\t\t\t\t\topacity: 1;\n\t\t\t\t\ttransform: translateX(-50%) scale(1);\n\t\t\t\t}\n\t\t\t\t30% {\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransform: translateX(-50%) scale(0.8);\n\t\t\t\t}\n\t\t\t\t100% {\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransform: translateX(-50%) scale(0.8);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@keyframes flash-leave {\n\t\t\t\t0%,\n\t\t\t\t79.5% {\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransform: translateX(-50%) scale(0.8);\n\t\t\t\t}\n\t\t\t\t81.3% {\n\t\t\t\t\topacity: 1;\n\t\t\t\t\ttransform: translateX(-50%) scale(1);\n\t\t\t\t}\n\t\t\t\t89% {\n\t\t\t\t\topacity: 1;\n\t\t\t\t\ttransform: translateX(-50%) scale(1);\n\t\t\t\t}\n\t\t\t\t92% {\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransform: translateX(-50%) scale(0.8);\n\t\t\t\t}\n\t\t\t\t100% {\n\t\t\t\t\topacity: 0;\n\t\t\t\t\ttransform: translateX(-50%) scale(0.8);\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"stage\">\n\t\t\t<div class=\"mask-top\"></div>\n\t\t\t<div class=\"mask-bottom\"></div>\n\n\t\t\t<div class=\"viewport\">\n\t\t\t\t<span class=\"viewport-label\">Container</span>\n\t\t\t</div>\n\n\t\t\t<div class=\"element\">Element</div>\n\n\t\t\t<div class=\"event-badge event-enter\">enter</div>\n\t\t\t<div class=\"event-badge event-leave\">leave</div>\n\n\t\t\t<div class=\"marker marker-start\">\n\t\t\t\t<div class=\"marker-label-group\">\n\t\t\t\t\t<span class=\"marker-name\">containerStart</span>\n\t\t\t\t\t<span class=\"marker-value\">'opposite' (100%)</span>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"marker-line\"></div>\n\t\t\t</div>\n\t\t\t<div class=\"marker marker-end\">\n\t\t\t\t<div class=\"marker-label-group\">\n\t\t\t\t\t<span class=\"marker-name\">containerEnd</span>\n\t\t\t\t\t<span class=\"marker-value\">'opposite' (100%)</span>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"marker-line\"></div>\n\t\t\t</div>\n\n\t\t\t<div class=\"progress-track\">\n\t\t\t\t<div class=\"progress-fill\"></div>\n\t\t\t</div>\n\t\t\t<div class=\"progress-label\">\n\t\t\t\t<span class=\"progress-counter\">0%</span>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<script>\n\t\t\t// Progress calculation exposed as global for both live preview and export.\n\t\t\t// Motion: 5%→95%, progress active: 18.7%→81.3%\n\t\t\twindow.getProgress = function (frac) {\n\t\t\t\tif (frac < 0.187) return 0;\n\t\t\t\tif (frac >= 0.813) return 100;\n\t\t\t\treturn Math.round(\n\t\t\t\t\t((frac - 0.187) / (0.813 - 0.187)) * 100\n\t\t\t\t);\n\t\t\t};\n\n\t\t\tconst label = document.querySelector('.progress-counter');\n\t\t\tconst duration = 4000;\n\t\t\tconst start = performance.now();\n\t\t\t(function update(now) {\n\t\t\t\tconst frac = ((now - start) % duration) / duration;\n\t\t\t\tlabel.textContent = window.getProgress(frac) + '%';\n\t\t\t\trequestAnimationFrame(update);\n\t\t\t})(start);\n\t\t</script>\n\t</body>\n</html>\n"
  },
  {
    "path": "docs/diagrams/shared.css",
    "content": "/* shared styles for ScrollMagic animated diagrams */\n:root {\n\t--viewport-width: 260px;\n\t--viewport-height: 174px;\n\t--viewport-left: 170px;\n\t--element-width: 100px;\n\t--stage-padding-top: 120px;\n\t--stage-padding-bottom: 120px;\n\n\t--color-viewport: #e74c3c;\n\t--color-element: #3498db;\n\t--color-marker: #888;\n\t--color-progress: #2ecc71;\n\t--color-bg: #fff;\n\t--color-label: #555;\n}\n\n* {\n\tmargin: 0;\n\tpadding: 0;\n\tbox-sizing: border-box;\n}\n\nbody {\n\tbackground: var(--color-bg);\n\tfont-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tmin-height: 100vh;\n\toverflow: hidden;\n}\n\n.stage {\n\tposition: relative;\n\t/* viewport-left + viewport + gap + progress track + label */\n\twidth: calc(var(--viewport-left) + var(--viewport-width) + 80px);\n\theight: calc(var(--viewport-height) + var(--stage-padding-top) + var(--stage-padding-bottom));\n}\n\n/* mask overlays — white semi-transparent, dims the element outside the container */\n.mask-top,\n.mask-bottom {\n\tposition: absolute;\n\tleft: var(--viewport-left);\n\twidth: var(--viewport-width);\n\tbackground: rgba(255, 255, 255, 0.7);\n\tz-index: 2;\n}\n.mask-top {\n\ttop: 0;\n\theight: var(--stage-padding-top);\n}\n.mask-bottom {\n\tbottom: 0;\n\theight: var(--stage-padding-bottom);\n}\n\n/* container box */\n.viewport {\n\tposition: absolute;\n\tleft: var(--viewport-left);\n\ttop: var(--stage-padding-top);\n\twidth: var(--viewport-width);\n\theight: var(--viewport-height);\n\tborder: 3px solid var(--color-viewport);\n\tborder-radius: 6px;\n\tbackground: transparent;\n\tz-index: 3;\n}\n.viewport-label {\n\tposition: absolute;\n\ttop: -22px;\n\tleft: 50%;\n\ttransform: translateX(-50%);\n\tfont-size: 13px;\n\tfont-weight: 600;\n\tcolor: var(--color-viewport);\n\twhite-space: nowrap;\n}\n\n/* element (moves via animation) */\n.element {\n\tposition: absolute;\n\tleft: calc(var(--viewport-left) + var(--viewport-width) / 2);\n\ttransform: translateX(-50%);\n\twidth: var(--element-width);\n\tbackground: var(--color-element);\n\tborder-radius: 5px;\n\tz-index: 1;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tcolor: #fff;\n\tfont-size: 13px;\n\tfont-weight: 600;\n}\n\n/* container markers — left side, with arrow pointing to container edge */\n.marker {\n\tposition: absolute;\n\tz-index: 4;\n\tdisplay: flex;\n\talign-items: center;\n\tright: calc(100% - var(--viewport-left) + 10px);\n\tflex-direction: row;\n\tgap: 6px;\n\ttransform: translateY(-50%);\n}\n.marker-label-group {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: flex-end;\n\tgap: 1px;\n}\n.marker-name {\n\tfont-size: 11px;\n\tcolor: var(--color-label);\n\twhite-space: nowrap;\n\tfont-weight: 500;\n}\n.marker-value {\n\tfont-size: 10px;\n\tfont-family: 'SF Mono', 'Menlo', 'Consolas', monospace;\n\tcolor: #999;\n\twhite-space: nowrap;\n}\n.marker-line {\n\tposition: relative;\n\theight: 0;\n\twidth: 40px;\n\tborder-top: 2px dashed var(--color-marker);\n\tflex-shrink: 0;\n}\n/* arrowhead pointing right toward the container edge */\n.marker-line::after {\n\tcontent: '';\n\tposition: absolute;\n\tright: -7px;\n\ttop: -5px;\n\twidth: 0;\n\theight: 0;\n\tborder-left: 7px solid var(--color-marker);\n\tborder-top: 5px solid transparent;\n\tborder-bottom: 5px solid transparent;\n}\n\n/* progress indicator — right side of container */\n.progress-track {\n\tposition: absolute;\n\tz-index: 4;\n\tleft: calc(var(--viewport-left) + var(--viewport-width) + 20px);\n\twidth: 4px;\n\tbackground: #e0e0e0;\n\tborder-radius: 2px;\n}\n.progress-fill {\n\tposition: absolute;\n\ttop: auto;\n\tbottom: 0;\n\tleft: 0;\n\twidth: 100%;\n\tbackground: var(--color-progress);\n\tborder-radius: 2px;\n}\n.progress-label {\n\tposition: absolute;\n\tz-index: 4;\n\tleft: calc(var(--viewport-left) + var(--viewport-width) + 32px);\n\tfont-size: 14px;\n\tfont-weight: 700;\n\tfont-variant-numeric: tabular-nums;\n\tcolor: var(--color-progress);\n\twhite-space: nowrap;\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "//@ts-check\n\nimport eslint from '@eslint/js';\nimport compat from 'eslint-plugin-compat';\nimport { defineConfig } from 'eslint/config';\nimport globals from 'globals';\nimport tseslint from 'typescript-eslint';\n\nexport default defineConfig(\n\t{ ignores: ['dist/', 'docs/tsdoc'] },\n\teslint.configs.recommended,\n\ttseslint.configs.recommendedTypeChecked,\n\tcompat.configs['flat/recommended'],\n\t{\n\t\tlanguageOptions: {\n\t\t\tglobals: {\n\t\t\t\t...globals.browser,\n\t\t\t},\n\t\t\tparserOptions: {\n\t\t\t\tprojectService: {\n\t\t\t\t\tallowDefaultProject: ['vitest.config.ts'],\n\t\t\t\t},\n\t\t\t\ttsconfigRootDir: import.meta.dirname,\n\t\t\t},\n\t\t},\n\t\trules: {\n\t\t\t'no-useless-rename': 'warn',\n\t\t\t'@typescript-eslint/no-explicit-any': 'off',\n\t\t},\n\t},\n\t{\n\t\tfiles: ['**/*.mjs'],\n\t\textends: [tseslint.configs.disableTypeChecked],\n\t\tlanguageOptions: {\n\t\t\tglobals: {\n\t\t\t\t...globals.node,\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tfiles: ['scripts/*.mjs'],\n\t\trules: {\n\t\t\t'compat/compat': 'off',\n\t\t},\n\t}\n);\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"scrollmagic\",\n\t\"title\": \"ScrollMagic\",\n\t\"version\": \"3.0.0-beta.4\",\n\t\"description\": \"The lightweight library for magical scroll interactions.\",\n\t\"type\": \"module\",\n\t\"main\": \"./dist/scrollmagic.umd.js\",\n\t\"module\": \"./dist/scrollmagic.esm.js\",\n\t\"types\": \"./dist/types/index.d.ts\",\n\t\"exports\": {\n\t\t\".\": {\n\t\t\t\"types\": \"./dist/types/index.d.ts\",\n\t\t\t\"import\": \"./dist/scrollmagic.esm.js\",\n\t\t\t\"require\": \"./dist/scrollmagic.umd.js\"\n\t\t},\n\t\t\"./util\": {\n\t\t\t\"types\": \"./dist/types/util.d.ts\",\n\t\t\t\"import\": \"./dist/scrollmagic.util.esm.js\",\n\t\t\t\"require\": \"./dist/scrollmagic.util.umd.js\"\n\t\t}\n\t},\n\t\"sideEffects\": false,\n\t\"files\": [\n\t\t\"dist\"\n\t],\n\t\"devDependencies\": {\n\t\t\"@eslint/js\": \"^10.0.1\",\n\t\t\"@rollup/plugin-terser\": \"^0.4.4\",\n\t\t\"@rollup/plugin-typescript\": \"^12.3.0\",\n\t\t\"@vitest/browser-playwright\": \"^4.0.18\",\n\t\t\"eslint\": \"^10.0.0\",\n\t\t\"eslint-plugin-compat\": \"^6.2.0\",\n\t\t\"gifenc\": \"^1.0.3\",\n\t\t\"globals\": \"^17.3.0\",\n\t\t\"jsdom\": \"^28.1.0\",\n\t\t\"pngjs\": \"^7.0.0\",\n\t\t\"prettier\": \"^3.8.1\",\n\t\t\"rollup\": \"^4.57.1\",\n\t\t\"rollup-plugin-bundle-size\": \"^1.0.3\",\n\t\t\"rollup-plugin-delete\": \"^3.0.2\",\n\t\t\"rollup-plugin-license\": \"^3.7.0\",\n\t\t\"tslib\": \"^2.8.1\",\n\t\t\"typedoc\": \"^0.28.17\",\n\t\t\"typescript\": \"^5.9.3\",\n\t\t\"typescript-eslint\": \"^8.56.0\",\n\t\t\"vitest\": \"^4.0.18\"\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=18\"\n\t},\n\t\"scripts\": {\n\t\t\"build\": \"rollup -c\",\n\t\t\"dev\": \"npm run build -- --watch\",\n\t\t\"test\": \"npm run typecheck && vitest run\",\n\t\t\"test:unit\": \"vitest run --project unit\",\n\t\t\"test:e2e\": \"vitest run --project e2e\",\n\t\t\"test:watch\": \"vitest\",\n\t\t\"typecheck\": \"tsc --noEmit\",\n\t\t\"lint\": \"eslint .\",\n\t\t\"lint:fix\": \"npm run lint -- --fix\",\n\t\t\"prettier\": \"prettier --check .\",\n\t\t\"prettier:fix\": \"prettier --write .\",\n\t\t\"preversion\": \"npm run build && npm run test\",\n\t\t\"prepublishOnly\": \"npm run build\",\n\t\t\"docs:api\": \"typedoc\",\n\t\t\"doces:export-diagrams\": \"node scripts/export-diagrams.mjs\"\n\t},\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/janpaepke/ScrollMagic.git\"\n\t},\n\t\"author\": {\n\t\t\"name\": \"Jan Paepke\",\n\t\t\"url\": \"https://janpaepke.de\",\n\t\t\"email\": \"e-mail@janpaepke.de\"\n\t},\n\t\"contributors\": [\n\t\t{}\n\t],\n\t\"license\": \"MIT\",\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/janpaepke/ScrollMagic/issues\"\n\t},\n\t\"homepage\": \"https://scrollmagic.io\",\n\t\"browserslist\": [\n\t\t\"Chrome >= 73\",\n\t\t\"Firefox >= 69\",\n\t\t\"Safari >= 13.1\",\n\t\t\"Edge >= 79\",\n\t\t\"iOS >= 13.4\",\n\t\t\"Samsung >= 9.2\",\n\t\t\"not dead\"\n\t]\n}\n"
  },
  {
    "path": "prettier.config.mjs",
    "content": "/**\n * @see https://prettier.io/docs/configuration\n * @type {import(\"prettier\").Config}\n */\nconst config = {\n\tsemi: true,\n\tuseTabs: true,\n\tprintWidth: 120,\n\tbracketSpacing: true,\n\tarrowParens: 'avoid',\n\thtmlWhitespaceSensitivity: 'css',\n\tendOfLine: 'lf',\n\tsingleQuote: true,\n\ttrailingComma: 'es5',\n\texperimentalTernaries: true,\n};\n\nexport default config;\n"
  },
  {
    "path": "rollup.config.mjs",
    "content": "import terser from '@rollup/plugin-terser';\nimport typescript from '@rollup/plugin-typescript';\nimport bundleSize from 'rollup-plugin-bundle-size';\nimport clean from 'rollup-plugin-delete';\nimport license from 'rollup-plugin-license';\n\nimport pkg from './package.json' with { type: 'json' };\nimport cfg from './tsconfig.json' with { type: 'json' };\n\nconst createCommonPlugins = () => [\n\tbundleSize(),\n\ttypescript({\n\t\tdeclarationDir: './dist/types',\n\t\texclude: ['tests/**/*'],\n\t}),\n\tterser(),\n\tlicense({\n\t\tbanner: {\n\t\t\tcommentStyle: 'ignored',\n\t\t\tcontent: {\n\t\t\t\tfile: './config/banner.txt',\n\t\t\t\tencoding: 'utf-8',\n\t\t\t},\n\t\t},\n\t}),\n];\n\nconst main = {\n\tinput: './src/index.ts',\n\toutput: [\n\t\t{\n\t\t\tformat: 'umd',\n\t\t\tfile: pkg.main,\n\t\t\tname: pkg.title,\n\t\t\tsourcemap: true,\n\t\t},\n\t\t{\n\t\t\tformat: 'esm',\n\t\t\tfile: pkg.module,\n\t\t\tsourcemap: true,\n\t\t},\n\t],\n\tplugins: [\n\t\tclean({\n\t\t\ttargets: `${cfg.compilerOptions.outDir}/*`,\n\t\t}),\n\t\t...createCommonPlugins(),\n\t],\n};\n\nconst util = {\n\tinput: './src/util.ts',\n\toutput: [\n\t\t{\n\t\t\tformat: 'umd',\n\t\t\tfile: pkg.exports['./util'].require,\n\t\t\tname: `${pkg.title}Utils`,\n\t\t\tsourcemap: true,\n\t\t},\n\t\t{\n\t\t\tformat: 'esm',\n\t\t\tfile: pkg.exports['./util'].import,\n\t\t\tsourcemap: true,\n\t\t},\n\t],\n\tplugins: createCommonPlugins(),\n};\n\nexport default [main, util];\n"
  },
  {
    "path": "scripts/export-diagrams.mjs",
    "content": "/**\n * Converts HTML animation files to GIF using Playwright + gifenc + pngjs.\n *\n * Usage:\n *   npm run export-diagrams                        # export all diagrams\n *   node scripts/export-diagrams.mjs intersect    # export only intersect.gif\n *   node scripts/export-diagrams.mjs contain      # export only contain.gif\n *\n * Options (env vars):\n *   FPS=15        frames per second (default: 15)\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'fs';\nimport { resolve, dirname } from 'path';\nimport { fileURLToPath } from 'url';\nimport { chromium } from 'playwright';\nimport pkg from 'pngjs';\nconst { PNG } = pkg;\nimport gifenc from 'gifenc';\nconst { GIFEncoder, quantize, applyPalette } = gifenc;\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst srcDir = resolve(__dirname, '..', 'docs', 'diagrams');\nconst outDir = resolve(__dirname, '..', 'docs', 'dist', 'gfx');\n\nconst diagrams = {\n\tintersect: resolve(srcDir, 'intersect.html'),\n\tcontain: resolve(srcDir, 'contain.html'),\n};\n\nconst fps = parseInt(process.env.FPS || '15', 10);\n\nasync function exportDiagram(name, htmlPath) {\n\tconsole.log(`\\n--- Exporting ${name} ---`);\n\tconsole.log(`  Source: ${htmlPath}`);\n\n\tconst browser = await chromium.launch();\n\tconst page = await browser.newPage();\n\n\t// Set viewport large enough to contain the stage (170+260+80=510 wide, 120+174+120=414 tall)\n\tawait page.setViewportSize({ width: 540, height: 450 });\n\n\tawait page.goto(`file://${htmlPath}`, { waitUntil: 'domcontentloaded' });\n\n\t// Wait for animations to start\n\tawait page.waitForFunction(() => document.getAnimations().length > 0);\n\n\t// Get animation duration and detect all animations\n\tconst animInfo = await page.evaluate(() => {\n\t\tconst animations = document.getAnimations();\n\t\tconst durations = animations.map(a => {\n\t\t\tconst timing = a.effect.getComputedTiming();\n\t\t\treturn timing.duration + timing.delay;\n\t\t});\n\t\treturn {\n\t\t\tcount: animations.length,\n\t\t\tduration: Math.max(...durations),\n\t\t};\n\t});\n\n\tconsole.log(`  Animations: ${animInfo.count}, duration: ${animInfo.duration}ms`);\n\tconsole.log(`  FPS: ${fps}`);\n\n\tconst duration = animInfo.duration;\n\tconst frameCount = Math.ceil((duration / 1000) * fps);\n\tconst frameDelay = Math.round(1000 / fps); // ms per frame for GIF\n\n\tconsole.log(`  Frames: ${frameCount}, delay: ${frameDelay}ms`);\n\n\t// Pause all animations at time 0 and kill the rAF-based text loop\n\tawait page.evaluate(() => {\n\t\tdocument.getAnimations().forEach(a => {\n\t\t\ta.pause();\n\t\t\ta.currentTime = 0;\n\t\t});\n\t\t// Override rAF so the HTML's live-preview loop can't overwrite text\n\t\twindow.requestAnimationFrame = () => 0;\n\t});\n\n\t// Determine tight bounding box of the .stage element\n\tconst stageBox = await page.evaluate(() => {\n\t\tconst stage = document.querySelector('.stage');\n\t\tconst rect = stage.getBoundingClientRect();\n\t\treturn {\n\t\t\tx: Math.round(rect.x),\n\t\t\ty: Math.round(rect.y),\n\t\t\twidth: Math.round(rect.width),\n\t\t\theight: Math.round(rect.height),\n\t\t};\n\t});\n\n\tconsole.log(`  Stage bounds: ${stageBox.width}x${stageBox.height} at (${stageBox.x}, ${stageBox.y})`);\n\n\tconst gif = GIFEncoder();\n\tlet width, height;\n\n\tfor (let i = 0; i < frameCount; i++) {\n\t\tconst time = (i / frameCount) * duration;\n\n\t\t// Seek all animations to this time\n\t\tawait page.evaluate(t => {\n\t\t\tdocument.getAnimations().forEach(a => {\n\t\t\t\ta.currentTime = t;\n\t\t\t});\n\t\t}, time);\n\n\t\t// Update the JS-driven progress counter via the page's own getProgress()\n\t\tawait page.evaluate(\n\t\t\t({ t, dur }) => {\n\t\t\t\tconst el = document.querySelector('.progress-counter');\n\t\t\t\tif (el && typeof window.getProgress === 'function') {\n\t\t\t\t\tel.textContent = window.getProgress(t / dur) + '%';\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ t: time, dur: duration }\n\t\t);\n\n\t\t// Take screenshot of just the stage area\n\t\tconst pngBuffer = await page.screenshot({\n\t\t\tclip: stageBox,\n\t\t\ttype: 'png',\n\t\t});\n\n\t\tconst png = PNG.sync.read(pngBuffer);\n\t\tif (i === 0) {\n\t\t\twidth = png.width;\n\t\t\theight = png.height;\n\t\t}\n\n\t\tconst palette = quantize(png.data, 256);\n\t\tconst index = applyPalette(png.data, palette);\n\t\tgif.writeFrame(index, width, height, {\n\t\t\tpalette,\n\t\t\tdelay: frameDelay,\n\t\t\trepeat: 0,\n\t\t});\n\n\t\tif ((i + 1) % 10 === 0 || i === frameCount - 1) {\n\t\t\tprocess.stdout.write(`\\r  Frame ${i + 1}/${frameCount} (${Math.round(time)}ms)`);\n\t\t}\n\t}\n\n\tgif.finish();\n\tconst gifBytes = gif.bytes();\n\n\t// Ensure output directory\n\tif (!existsSync(outDir)) {\n\t\tmkdirSync(outDir, { recursive: true });\n\t}\n\n\tconst outPath = resolve(outDir, `${name}.gif`);\n\twriteFileSync(outPath, gifBytes);\n\n\tconst sizeKB = (gifBytes.length / 1024).toFixed(1);\n\tconsole.log(`\\n  Output: ${outPath} (${sizeKB} KB)`);\n\n\tawait browser.close();\n}\n\n// Main\nconst requested = process.argv[2];\nconst names = requested ? [requested] : Object.keys(diagrams);\n\nfor (const name of names) {\n\tif (!diagrams[name]) {\n\t\tconsole.error(`Unknown diagram: ${name}`);\n\t\tconsole.error(`Available: ${Object.keys(diagrams).join(', ')}`);\n\t\tprocess.exit(1);\n\t}\n}\n\nconsole.log(`Exporting diagrams: ${names.join(', ')}`);\n\nfor (const name of names) {\n\tawait exportDiagram(name, diagrams[name]);\n}\n\nconsole.log('\\nDone!');\n"
  },
  {
    "path": "src/Container.ts",
    "content": "import { type DispatchableEvent, EventDispatcher } from './EventDispatcher';\nimport { getScrollContainerDimensions } from './util/getScrollContainerDimensions';\nimport { getScrollPos } from './util/getScrollPos';\nimport { registerEvent } from './util/registerEvent';\nimport { rafQueue } from './util/rafQueue';\nimport { observeResize } from './util/sharedResizeObserver';\nimport { throttleRaf } from './util/throttleRaf';\nimport { isWindow } from './util/typeguards';\n\nexport type ScrollContainer = HTMLElement | Window;\n\ntype CleanUpFunction = () => void;\ntype Vector = {\n\tx: number;\n\ty: number;\n};\nconst ZERO_VECTOR: Readonly<Vector> = Object.freeze({ x: 0, y: 0 });\n\n// type EventType = 'scroll' | 'resize';\nenum EventType {\n\tScroll = 'scroll',\n\tResize = 'resize',\n}\nexport class ContainerEvent implements DispatchableEvent {\n\tconstructor(\n\t\tpublic readonly target: Container,\n\t\tpublic readonly type: `${EventType}`,\n\t\tpublic 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...\n\t) {}\n}\n\nexport class Container {\n\t/** Time in milliseconds after which the scroll velocity is considered stale. */\n\tprivate static readonly VELOCITY_STALE_MS = 100;\n\tprivate dimensions = {\n\t\t// inner size excluding scrollbars\n\t\tclientWidth: 0,\n\t\tclientHeight: 0,\n\t\t// size of scrollable content\n\t\tscrollWidth: 0,\n\t\tscrollHeight: 0,\n\t};\n\tprivate scrollPos = {\n\t\ttop: 0,\n\t\tleft: 0,\n\t};\n\tprivate positionCache = {\n\t\t// position of scroll parent (if not window) relative to window\n\t\ttop: 0,\n\t\tleft: 0,\n\t};\n\tprivate destroyed = false;\n\tprivate lastScrollTime: number | undefined;\n\tprivate readonly scrollVelocityCache: Vector = { x: 0, y: 0 };\n\tprivate readonly dispatcher = new EventDispatcher<ContainerEvent>();\n\tprivate readonly cleanups: CleanUpFunction[] = [];\n\t/**\n\t * TODO: Currently we have no way of detecting, when physical scrollbars appear or disappear, which should technically trigger a resize event.\n\t * 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)\n\t * But this seems quite hacky and code intense for this edge case scenario. It would also work for document scrolls, not for Element scrolls.\n\t */\n\tconstructor(public readonly containerElement: ScrollContainer) {\n\t\tconst throttledScroll = throttleRaf(() => {\n\t\t\tthis.updateScrollPos();\n\t\t\trafQueue.flush();\n\t\t});\n\t\tconst throttledResize = throttleRaf(() => {\n\t\t\tthis.updateDimensions();\n\t\t\trafQueue.flush();\n\t\t});\n\t\tif (!isWindow(containerElement)) {\n\t\t\tconst throttledMove = throttleRaf(this.updatePosition.bind(this));\n\t\t\tthis.cleanups.push(throttledMove.cancel, this.subscribeMove(throttledMove));\n\t\t\tthis.updatePosition(); // initialize synchronously; subsequent updates are throttled via subscribeMove\n\t\t}\n\t\tthis.cleanups.push(\n\t\t\tthrottledScroll.cancel,\n\t\t\tthrottledResize.cancel,\n\t\t\tthis.subscribeScroll(throttledScroll),\n\t\t\tthis.subscribeResize(throttledResize)\n\t\t);\n\t\tthis.updateScrollPos();\n\t\tthis.updateDimensions();\n\t}\n\n\tprivate updateScrollPos() {\n\t\tconst prevScrollPos = this.scrollPos;\n\t\tthis.scrollPos = getScrollPos(this.containerElement);\n\t\tconst deltaY = this.scrollPos.top - prevScrollPos.top;\n\t\tconst deltaX = this.scrollPos.left - prevScrollPos.left;\n\t\tconst now = performance.now();\n\t\tif (undefined !== this.lastScrollTime) {\n\t\t\tconst dt = now - this.lastScrollTime;\n\t\t\tif (dt > 0) {\n\t\t\t\tthis.scrollVelocityCache.x = (deltaX / dt) * 1000;\n\t\t\t\tthis.scrollVelocityCache.y = (deltaY / dt) * 1000;\n\t\t\t}\n\t\t}\n\t\tthis.lastScrollTime = now;\n\t\tthis.dispatcher.dispatchEvent(new ContainerEvent(this, EventType.Scroll, { x: deltaX, y: deltaY }));\n\t}\n\n\tprivate updateDimensions() {\n\t\tthis.dimensions = getScrollContainerDimensions(this.containerElement);\n\t\tthis.dispatcher.dispatchEvent(new ContainerEvent(this, EventType.Resize));\n\t}\n\n\tprivate updatePosition() {\n\t\t// this should only be executed, when containerElement is NOT window\n\t\tconst { top, left } = (this.containerElement as HTMLElement).getBoundingClientRect();\n\t\tthis.positionCache = { top, left };\n\t}\n\n\t// subscribes to resize events of containerElement and returns a function to reverse the effect\n\tprivate subscribeResize(onResize: () => void) {\n\t\tconst { containerElement } = this;\n\t\tif (isWindow(containerElement)) {\n\t\t\treturn registerEvent(containerElement, EventType.Resize, onResize);\n\t\t}\n\t\treturn observeResize(containerElement, onResize);\n\t}\n\n\t// subscribes to scroll events of containerElement and returns a function to reverse the effect\n\tprivate subscribeScroll(onScroll: () => void) {\n\t\treturn registerEvent(this.containerElement, EventType.Scroll, onScroll, { passive: true });\n\t}\n\n\tprivate subscribeMove(onMove: () => void) {\n\t\tconst listeners = [\n\t\t\tregisterEvent(window, EventType.Scroll, onMove, { passive: true }),\n\t\t\tregisterEvent(window, EventType.Resize, onMove),\n\t\t];\n\t\treturn () => listeners.forEach(cleanup => cleanup());\n\t}\n\n\t// subscribes Container and returns a function to reverse the effect\n\tpublic subscribe(type: `${EventType}`, cb: (e: ContainerEvent) => void): () => void {\n\t\treturn this.dispatcher.addEventListener(type, cb);\n\t}\n\n\tpublic get size(): Readonly<Container['dimensions']> {\n\t\treturn this.dimensions;\n\t}\n\n\tpublic get position(): Readonly<Container['positionCache']> {\n\t\treturn this.positionCache;\n\t}\n\n\tpublic get scrollVelocity(): Readonly<Vector> {\n\t\tif (undefined === this.lastScrollTime || performance.now() - this.lastScrollTime > Container.VELOCITY_STALE_MS) {\n\t\t\treturn ZERO_VECTOR;\n\t\t}\n\t\treturn this.scrollVelocityCache;\n\t}\n\n\tpublic destroy(): void {\n\t\tif (this.destroyed) {\n\t\t\treturn;\n\t\t}\n\t\tthis.destroyed = true;\n\t\tthis.cleanups.forEach(cleanup => cleanup());\n\t\tthis.cleanups.length = 0;\n\t}\n}\n"
  },
  {
    "path": "src/ContainerProxy.ts",
    "content": "import { Container, type ContainerEvent, type ScrollContainer } from './Container';\nimport { ScrollMagic } from './ScrollMagic';\nimport { ScrollMagicInternalError } from './ScrollMagicError';\ntype EventCallback = (e: ContainerEvent) => void;\ntype CleanUpFunction = () => void;\ntype Velocity = {\n\tx: number;\n\ty: number;\n};\n\nexport class ContainerProxy {\n\tprivate static cache = new WeakMap<ScrollContainer, [Container, Set<ScrollMagic>]>();\n\n\tprivate container?: Container;\n\tconstructor(private readonly sm: ScrollMagic) {}\n\tprivate unsubscribers: CleanUpFunction[] = [];\n\n\tpublic attach(containerElement: ScrollContainer, onUpdate: EventCallback): void {\n\t\tif (undefined !== this.container) {\n\t\t\tthis.detach();\n\t\t}\n\t\tlet cache = ContainerProxy.cache.get(containerElement);\n\t\tif (undefined === cache) {\n\t\t\tcache = [new Container(containerElement), new Set()];\n\t\t\tContainerProxy.cache.set(containerElement, cache);\n\t\t}\n\t\tconst [container, instances] = cache;\n\t\tinstances.add(this.sm);\n\t\tthis.container = container;\n\t\tthis.unsubscribers = [container.subscribe('resize', onUpdate), container.subscribe('scroll', onUpdate)];\n\t}\n\n\tpublic detach(): void {\n\t\tif (undefined === this.container) {\n\t\t\treturn;\n\t\t}\n\t\tconst { containerElement } = this.container;\n\t\tconst cache = ContainerProxy.cache.get(containerElement);\n\t\tif (undefined === cache) {\n\t\t\tthrow new ScrollMagicInternalError('No cache info for container');\n\t\t}\n\t\tconst [container, instances] = cache;\n\t\tinstances.delete(this.sm);\n\t\tthis.unsubscribers.forEach(unsubscribe => unsubscribe());\n\t\tthis.unsubscribers = [];\n\t\tif (instances.size === 0) {\n\t\t\t// no more attached instances\n\t\t\tcontainer.destroy();\n\t\t\tContainerProxy.cache.delete(containerElement);\n\t\t}\n\t\tthis.container = undefined;\n\t}\n\n\tpublic get size(): Container['size'] {\n\t\tif (undefined === this.container) {\n\t\t\tthrow new ScrollMagicInternalError(`Can't get size when not attached to a container`);\n\t\t}\n\t\treturn this.container.size;\n\t}\n\n\tpublic get position(): Container['position'] {\n\t\tif (undefined === this.container) {\n\t\t\tthrow new ScrollMagicInternalError(`Can't get position when not attached to a container`);\n\t\t}\n\t\treturn this.container.position;\n\t}\n\n\tpublic get scrollVelocity(): Velocity {\n\t\tif (undefined === this.container) {\n\t\t\treturn { x: 0, y: 0 };\n\t\t}\n\t\treturn this.container.scrollVelocity;\n\t}\n}\n"
  },
  {
    "path": "src/EventDispatcher.ts",
    "content": "const noop = () => {};\ntype EventType = string;\nexport interface DispatchableEvent {\n\treadonly target: unknown;\n\treadonly type: EventType;\n}\n\n/** Options for event listener registration. */\nexport type ListenerOptions = {\n\t/** If `true`, the listener is automatically removed after its first invocation. */\n\tonce?: boolean;\n\t/** An {@link AbortSignal} — when aborted, the listener is automatically removed. Matches the DOM `addEventListener` pattern. */\n\tsignal?: AbortSignal;\n};\ntype Callback<E extends DispatchableEvent> = (event: E) => void;\ntype ListenerEntry<E extends DispatchableEvent> = { cb: Callback<E>; options: ListenerOptions };\n\nexport class EventDispatcher<E extends DispatchableEvent = DispatchableEvent> {\n\tprivate listeners = new Map<string, Set<ListenerEntry<E>>>();\n\n\t// adds a listener to the dispatcher. returns a function to reverse the effect.\n\tpublic addEventListener(type: E['type'], cb: Callback<E>, options: ListenerOptions = {}): () => void {\n\t\t// Match DOM spec: if signal already aborted, don't register\n\t\tif (options.signal?.aborted) {\n\t\t\treturn noop;\n\t\t}\n\t\tlet set = this.listeners.get(type);\n\t\tif (!set) {\n\t\t\tset = new Set();\n\t\t\tthis.listeners.set(type, set);\n\t\t}\n\t\t// fresh object per registration — Set identity keeps duplicate registrations of the same callback distinct\n\t\tconst entry: ListenerEntry<E> = { cb, options };\n\t\tset.add(entry);\n\t\tconst remove = () => this.removeEntry(type, entry);\n\t\tif (options.signal) {\n\t\t\toptions.signal.addEventListener('abort', remove, { once: true });\n\t\t}\n\t\treturn remove;\n\t}\n\n\t// removes a listener from the dispatcher\n\tpublic removeEventListener(type: E['type'], cb: Callback<E>): void {\n\t\tconst set = this.listeners.get(type);\n\t\tif (!set) {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of set) {\n\t\t\tif (entry.cb === cb) {\n\t\t\t\tthis.removeEntry(type, entry);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t// dispatches an event\n\tpublic dispatchEvent(event: E): void {\n\t\tconst set = this.listeners.get(event.type);\n\t\tif (!set) {\n\t\t\treturn;\n\t\t}\n\t\t// iterate a copy so listeners added during dispatch don't fire in the same cycle\n\t\tfor (const entry of [...set]) {\n\t\t\tif (entry.options.once) {\n\t\t\t\tthis.removeEntry(event.type, entry);\n\t\t\t}\n\t\t\tentry.cb(event);\n\t\t}\n\t}\n\n\tprivate removeEntry(type: string, entry: ListenerEntry<E>): void {\n\t\tconst set = this.listeners.get(type);\n\t\tif (!set) {\n\t\t\treturn;\n\t\t}\n\t\tset.delete(entry);\n\t\tif (0 === set.size) {\n\t\t\tthis.listeners.delete(type);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/ExecutionQueue.ts",
    "content": "import { rafQueue } from './util/rafQueue';\nimport { transformObject } from './util/transformObject';\ntype Callback = () => void;\ntype ExecutionCondition = () => boolean;\nconst always: ExecutionCondition = () => true;\ntype CommandList<T extends string> = Record<T, Command>;\n\n/**\n * This class holds a list of callbacks allows them to be scheduled for execution on next animationFrame.\n * Every callback will only be executed once per animationFrame, even if scheduled multiple times.\n * The order of the queue superceeds the order of scheduling, this means that if the queue consists of callbacks a, b,\n * a will always execute first, even if b is scheduled first.\n *\n * usage example:\n * ```\n * const queue = new ExecutionQueue({\n * \t\ta: () => console.log('a');\n * \t\tb: () => console.log('b');\n * })\n * queue.commands.b.schedule();\n * queue.commands.a.schedule();\n * <expected output on animationFrame>\n * 'a'\n * 'b'\n * ```\n *\n * For details about conditional execution see Command class below.\n *\n * To invoke execution now (and purge scheduled), call queue.execute.\n * To cancel scheduled execution, call queue.cancel\n */\nexport class ExecutionQueue<C extends string> {\n\tpublic readonly commands: CommandList<C>;\n\n\tconstructor(queueItems: Record<C, Callback>) {\n\t\tthis.commands = transformObject(queueItems, ([key, command]) => [key, new Command(command, () => rafQueue.schedule(this))]);\n\t}\n\n\t// executes all commands in the list in order, depending on whether or not their conditions are met\n\tpublic execute(): void {\n\t\tObject.values<Command>(this.commands).forEach(item => {\n\t\t\tif (item.conditionsMet) {\n\t\t\t\titem.execute();\n\t\t\t}\n\t\t\titem.resetConditions();\n\t\t});\n\t}\n\tpublic cancel(): void {\n\t\trafQueue.unschedule(this);\n\t}\n}\n\n/**\n * Each command in the ExecutionQueue above can be scheduled for execution using command.schedule()\n * .schedule() also accepts an optional parameter, a condition callback\n * This is called when execution is due, to determine if the callback should still be called.\n *\n * usage example:\n * ```\n * let x = 1;\n * const queue = new ExecutionQueue({\n * \t\ta: () => {\n * \t\t\tx = 2;\n * \t\t\tconsole.log('a');\n * \t\t};\n * \t\tb: () => console.log('b');\n * })\n * // result of execution condition remains true\n * queue.commands.b.schedule(() => x === 1);\n * <expected output on animationFrame>\n * 'b'\n * // x is 1 now, but will be 2, once a has been called.\n * queue.commands.a.schedule();\n * queue.commands.b.schedule(() => x === 1);\n * <expected output on animationFrame>\n * 'a'\n * ```\n */\nclass Command {\n\tprotected conditions: ExecutionCondition[] = [];\n\tconstructor(\n\t\tpublic readonly execute: Callback,\n\t\tprotected readonly onSchedule: () => void\n\t) {}\n\tpublic schedule(condition?: ExecutionCondition) {\n\t\tif (undefined === condition) {\n\t\t\t// if no condition is provided, conditions are considered always met. Any conditions added after this won't even be run\n\t\t\tthis.conditions = [];\n\t\t\tcondition = always;\n\t\t}\n\t\tthis.conditions.push(condition);\n\t\tthis.onSchedule();\n\t}\n\tpublic resetConditions() {\n\t\tthis.conditions = [];\n\t}\n\tpublic get conditionsMet() {\n\t\treturn this.conditions.some(condition => condition());\n\t}\n}\n"
  },
  {
    "path": "src/Options.processors.ts",
    "content": "import {\n\tPixelConverter,\n\tPrivate,\n\tPrivateUninferred,\n\tPublic,\n\tinferredContainerDefaults,\n\tdefaults as optionDefaults,\n} from './Options';\nimport { ScrollMagicError } from './ScrollMagicError';\nimport { agnosticValues } from './util/agnosticValues';\nimport { getScrollContainerDimensions } from './util/getScrollContainerDimensions';\nimport { PropertyProcessors, processProperties } from './util/processProperties';\nimport { sanitizeProperties } from './util/sanitizeProperties';\nimport { skipNull, toPixelConverter, toSvgOrHtmlElement, toValidContainer } from './util/transformers';\nimport { isHTMLElement, isSVGElement, isWindow } from './util/typeguards';\n\nconst transformers: PropertyProcessors<Required<Public>, PrivateUninferred> = {\n\telement: skipNull(toSvgOrHtmlElement),\n\telementStart: toPixelConverter,\n\telementEnd: toPixelConverter,\n\tcontainer: skipNull(toValidContainer),\n\tcontainerStart: skipNull(toPixelConverter),\n\tcontainerEnd: skipNull(toPixelConverter),\n\tvertical: Boolean,\n};\n\n// removes unknown properties from supplied options\nexport const sanitizeOptions = <T extends Public>(options: T): T => sanitizeProperties(options, optionDefaults);\n\n// converts all public values to their corresponding private value, leaving null values untouched\nconst transform = (options: Public): Partial<PrivateUninferred> => processProperties(options, transformers);\n\n// processes remaining null values\nconst infer = (options: PrivateUninferred): Private => {\n\tconst inferContainer = (container: Window | HTMLElement | null): Window | HTMLElement => container ?? window;\n\n\tconst inferElement = (elem: Element | null): HTMLElement | SVGElement => {\n\t\tif (null !== elem) {\n\t\t\treturn elem as HTMLElement | SVGElement;\n\t\t}\n\t\tconst resolved = inferContainer(options.container);\n\t\tconst child = isWindow(resolved) ? document.body : resolved.firstElementChild;\n\t\tif (null === child || !(isHTMLElement(child) || isSVGElement(child))) {\n\t\t\tthrow new ScrollMagicError(`Could not autodetect element, as container has no valid children.`);\n\t\t}\n\t\treturn child;\n\t};\n\n\tconst inferContainerOffset = (val: PixelConverter | null): PixelConverter =>\n\t\tval ?? (null === options.element ? inferredContainerDefaults.fallback : inferredContainerDefaults.default);\n\n\treturn processProperties(options, {\n\t\tcontainer: inferContainer,\n\t\telement: inferElement,\n\t\tcontainerStart: inferContainerOffset,\n\t\tcontainerEnd: inferContainerOffset,\n\t});\n};\n\n// checks if the options the user entered actually make sense\nconst sanityCheck = (options: Private): void => {\n\tconst { containerStart, containerEnd, elementStart, elementEnd, vertical, container, element } = options;\n\n\tif (!isWindow(container) && !container.contains(element)) {\n\t\tconsole?.error(\n\t\t\t'ScrollMagic: element is not a descendant of container. The IntersectionObserver requires an ancestor relationship to function correctly.',\n\t\t\t{ element, container }\n\t\t);\n\t}\n\n\tconst { size: elementSize } = getElementSize(options);\n\tconst { clientSize: containerSize } = agnosticValues(vertical, getScrollContainerDimensions(container));\n\n\tconst elementDistance = elementSize - elementStart(elementSize) - elementEnd(elementSize);\n\tconst trackDistance = -(containerSize - containerStart(containerSize) - containerEnd(containerSize));\n\n\tconst total = elementDistance + trackDistance;\n\tif (total < 0) {\n\t\tconsole?.warn(\n\t\t\t'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).',\n\t\t\t{\n\t\t\t\t...options,\n\t\t\t\tcontainerStart: containerStart(containerSize),\n\t\t\t\tcontainerEnd: containerEnd(containerSize),\n\t\t\t\telementStart: elementStart(elementSize),\n\t\t\t\telementEnd: elementEnd(elementSize),\n\t\t\t}\n\t\t);\n\t}\n};\n\nexport const processOptions = <T extends Public>(\n\tnewOptions: T,\n\toldOptions?: Private\n): { sanitized: T; processed: Private } => {\n\tconst sanitized = sanitizeOptions(newOptions);\n\tconst normalized = transform(sanitized);\n\tconst processed = infer({ ...oldOptions, ...normalized } as PrivateUninferred);\n\tif (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {\n\t\tsanityCheck(processed);\n\t}\n\treturn { sanitized, processed };\n};\n\n// helpers\nconst getElementSize = ({ vertical, element }: Pick<Private, 'vertical' | 'element'>) =>\n\tagnosticValues(vertical, element.getBoundingClientRect());\n"
  },
  {
    "path": "src/Options.ts",
    "content": "type NullableProperties<T extends object, K extends keyof T> = Omit<T, K> & {\n\t[X in K]: T[X] | null;\n};\ntype UnitString = `${number}px` | `${number}%`;\ntype PositionShorthand = keyof typeof positionShorthands;\ntype CssSelector = string;\n\n/** Converts an element's or container's current size (in pixels) to a pixel offset used for position calculations. */\nexport type PixelConverter = (size: number) => number;\n\n/** Public configuration options accepted by the ScrollMagic constructor and `modify()`. */\nexport type Public = {\n\t/** The tracked element (or CSS selector). Defaults to the first child of `container`. Set to `null` to reset. */\n\telement?: Element | CssSelector | null;\n\t/** Start **inset** on the element. Positive values shrink the tracked region from the leading edge. @default 0 */\n\telementStart?: number | UnitString | PositionShorthand | PixelConverter;\n\t/** End **inset** on the element. Positive values shrink the tracked region from the trailing edge. @default 0 */\n\telementEnd?: number | UnitString | PositionShorthand | PixelConverter;\n\t/** The scroll container (or CSS selector). Defaults to `window`. Set to `null` to reset. */\n\tcontainer?: Window | Element | CssSelector | null;\n\t/** Start **inset** on the scroll container. Set to `null` to infer based on `element`. @default null (inferred) */\n\tcontainerStart?: number | UnitString | PositionShorthand | PixelConverter | null;\n\t/** End **inset** on the scroll container. Set to `null` to infer based on `element`. @default null (inferred) */\n\tcontainerEnd?: number | UnitString | PositionShorthand | PixelConverter | null;\n\t/** Scroll axis. `true` = vertical, `false` = horizontal. @default true */\n\tvertical?: boolean;\n};\n\n// basically a normalized version of the options\nexport type Private = {\n\telement: Element;\n\telementStart: PixelConverter;\n\telementEnd: PixelConverter;\n\tcontainer: Window | HTMLElement;\n\tcontainerStart: PixelConverter;\n\tcontainerEnd: PixelConverter;\n\tvertical: boolean;\n};\n\n// values that can be null after processing and need to be inferred, if still null\nexport type PrivateUninferred = NullableProperties<\n\tPrivate,\n\t'element' | 'container' | 'containerStart' | 'containerEnd'\n>;\n\n/** Named position shorthands that resolve to percentage strings for element and container offsets. */\nexport const positionShorthands = {\n\there: '0%',\n\tcenter: '50%',\n\topposite: '100%',\n} as const satisfies Record<string, UnitString>;\n\n/** Default values for all public options. Returned (and optionally overridden) by `ScrollMagic.defaultOptions()`. */\nexport const defaults: Required<Public> = {\n\telement: null,\n\telementStart: 0,\n\telementEnd: 0,\n\tcontainer: null,\n\tcontainerStart: null,\n\tcontainerEnd: null,\n\tvertical: true,\n};\n\n// applied during fallback inference. if containerStart or containerEnd is null this will apply default if element is present and fallback otherwise\nexport const inferredContainerDefaults: Record<string, PixelConverter> = {\n\tdefault: (containerSize: number) => containerSize, // default 100%, starts at bottom, ends at top\n\tfallback: () => 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\n};\n"
  },
  {
    "path": "src/ScrollMagic.ts",
    "content": "import type { ContainerEvent } from './Container';\nimport { ContainerProxy } from './ContainerProxy';\nimport { EventDispatcher, type ListenerOptions } from './EventDispatcher';\nimport { ExecutionQueue } from './ExecutionQueue';\nimport * as Options from './Options';\nimport { processOptions, sanitizeOptions } from './Options.processors';\nimport { EventLocation, EventType, ScrollDirection, ScrollMagicEvent } from './ScrollMagicEvent';\nimport { agnosticProps } from './util/agnosticValues';\nimport { getScrollPos } from './util/getScrollPos';\nimport { pickDifferencesFlat } from './util/pickDifferencesFlat';\nimport { observeResize } from './util/sharedResizeObserver';\nimport { numberToPercString } from './util/transformers';\nimport { isWindow } from './util/typeguards';\nimport { ViewportObserver } from './ViewportObserver';\n\nconst isBrowser = 'undefined' !== typeof window;\n\n/** Cached layout measurements for the tracked element along the scroll axis. */\nexport type ElementBounds = {\n\t/** Position relative to viewport. */\n\tstart: number;\n\t/** Outer visible size of element (excluding margins). */\n\tsize: number;\n\t/** Offset relative to top/left of element. */\n\toffsetStart: number;\n\t/** Offset relative to bottom/right of element. */\n\toffsetEnd: number;\n\t/** Effective track size including offsets. */\n\ttrackSize: number;\n};\n/** Cached layout measurements for the scroll container along the scroll axis. */\nexport type ContainerBounds = {\n\t/** Inner visible area of scroll container (excluding scrollbars). */\n\tclientSize: number;\n\t/** Offset relative to top/left of container. */\n\toffsetStart: number;\n\t/** Offset relative to bottom/right of container. */\n\toffsetEnd: number;\n\t/** Effective track size including offsets. */\n\ttrackSize: number;\n\t/** Total size of content of container. */\n\tscrollSize: number;\n};\n\n/** Combined cached bounds for both the tracked element and its scroll container. */\nexport type ResolvedBounds = {\n\t/** Cached bounds of the tracked element. */\n\telement: Readonly<ElementBounds>;\n\t/** Cached bounds of the scroll container. */\n\tcontainer: Readonly<ContainerBounds>;\n};\n\n/**\n * A ScrollMagic plugin. Plugins receive lifecycle callbacks bound to the ScrollMagic instance they are added to.\n *\n * All callbacks are optional. The `this` context inside each callback is the owning ScrollMagic instance.\n */\nexport interface Plugin {\n\t/** Unique name identifying this plugin. */\n\tname: string;\n\t/** Called when the plugin is added via {@link ScrollMagic.addPlugin}. */\n\tonAdd?(this: ScrollMagic): void;\n\t/** Called when the plugin is removed via {@link ScrollMagic.removePlugin}. */\n\tonRemove?(this: ScrollMagic): void;\n\t/** Called when the instance is enabled via {@link ScrollMagic.enable}. */\n\tonEnable?(this: ScrollMagic): void;\n\t/** Called when the instance is disabled via {@link ScrollMagic.disable}. */\n\tonDisable?(this: ScrollMagic): void;\n\t/** Called when the instance is destroyed via {@link ScrollMagic.destroy}. */\n\tonDestroy?(this: ScrollMagic): void;\n\t/** Called when options change via {@link ScrollMagic.modify}. Receives only the changed options. */\n\tonModify?(this: ScrollMagic, changesPublic: Options.Public): void;\n}\n\n/**\n * Core class for scroll-based animations. Each instance tracks a single DOM element\n * within a scroll container and reports scroll progress (0–1) through events.\n *\n * @example\n * ```js\n * const sm = new ScrollMagic({ element: '#hero' });\n * sm.on('progress', (e) => console.log(e.target.progress));\n * ```\n */\nexport class ScrollMagic {\n\tpublic readonly name = 'ScrollMagic';\n\n\tprivate resizeCleanup?: () => void;\n\tprivate readonly dispatcher = new EventDispatcher<ScrollMagicEvent>();\n\tprivate readonly containerProxy = new ContainerProxy(this);\n\tprivate readonly viewportObserver = new ViewportObserver(this.onIntersectionChange.bind(this));\n\tprivate readonly executionQueue = new ExecutionQueue({\n\t\t// The order is important here! They will always be executed in exactly this order when scheduled for the same animation frame\n\t\telementBounds: this.updateElementBoundsCache.bind(this),\n\t\tcontainerBounds: this.updateContainerBoundsCache.bind(this),\n\t\tviewportObserver: this.updateViewportObserver.bind(this),\n\t\tprogress: this.updateProgress.bind(this),\n\t});\n\tprivate readonly update = this.executionQueue.commands;\n\tprivate readonly plugins = new Set<Plugin>();\n\tprotected readonly elementBoundsCache: ElementBounds = {\n\t\t// see typedef for details\n\t\tstart: 0,\n\t\tsize: 0,\n\t\toffsetStart: 0,\n\t\toffsetEnd: 0,\n\t\ttrackSize: 0,\n\t};\n\tprotected readonly containerBoundsCache: ContainerBounds = {\n\t\t// see typedef for details\n\t\tclientSize: 0,\n\t\toffsetStart: 0,\n\t\toffsetEnd: 0,\n\t\ttrackSize: 0,\n\t\tscrollSize: 0,\n\t};\n\n\t// all below options should only ever be changed by a dedicated method\n\tprotected optionsPublic!: Required<Options.Public>; // set in modify in constructor\n\tprotected optionsPrivate!: Options.Private; // set in modify in constructor\n\tprotected currentProgress = 0;\n\tprotected intersecting?: boolean; // currently intersecting with the ViewportObserver?\n\tprivate destroyed = false; // instance is destroyed and cannot be used anymore, true if destroy() was called\n\tprivate enabled = true; // instance is enabled and can be used, false if disable() was called\n\n\t/**\n\t * Create a new ScrollMagic instance.\n\t *\n\t * @param options - Configuration for the tracked element, scroll container, offsets, and axis.\n\t *\n\t * @example\n\t * ```js\n\t * // Track vertical scroll progress for an element\n\t * const sm = new ScrollMagic({ element: '.section', container: '#scroller' });\n\t *\n\t * // Horizontal scroll with custom offsets\n\t * const sm = new ScrollMagic({\n\t *   element: '.panel',\n\t *   vertical: false,\n\t *   elementStart: '50%',\n\t *   containerStart: 'center',\n\t * });\n\t * ```\n\t */\n\tconstructor(options: Options.Public = {}) {\n\t\tScrollMagic.instances.add(this);\n\t\tconst initOptions: Required<Options.Public> = {\n\t\t\t...ScrollMagic.defaultOptionsPublic,\n\t\t\t...options,\n\t\t};\n\t\tthis.modify(initOptions);\n\t}\n\n\tprivate guardInert(): boolean {\n\t\tif (this.destroyed && (typeof process === 'undefined' || process.env.NODE_ENV !== 'production')) {\n\t\t\tconsole?.warn('ScrollMagic Warning: Method called on a destroyed instance.');\n\t\t}\n\t\treturn this.destroyed || !isBrowser;\n\t}\n\n\tprotected getViewportMargin(): { top: string; left: string; right: string; bottom: string } {\n\t\tconst { vertical } = this.optionsPrivate;\n\t\tconst axis = agnosticProps(vertical);\n\t\tconst cross = agnosticProps(!vertical);\n\t\tconst crossScrollSize = this.containerProxy.size[cross.scrollSize];\n\t\tconst crossClientSize = this.containerProxy.size[cross.clientSize];\n\t\tconst {\n\t\t\tclientSize: containerSize,\n\t\t\toffsetStart: containerOffsetStart,\n\t\t\toffsetEnd: containerOffsetEnd,\n\t\t} = this.containerBoundsCache;\n\t\tconst { offsetStart, offsetEnd } = this.elementBoundsCache; // from cache\n\n\t\tconst marginStart = containerSize - containerOffsetStart + offsetStart;\n\t\tconst marginEnd = containerSize - containerOffsetEnd + offsetEnd;\n\t\t/**\n\t\t ** confusingly IntersectionObserver (and thus ViewportObserver) treat margins in the opposite direction (negative means towards the center)\n\t\t ** so we'll have to flip the signs here.\n\t\t ** Additionally we convert it to percentages and round, as this means they are less likely to change, meaning less refreshes for the observer\n\t\t ** (as the observer internally compares old values to new ones)\n\t\t ** This way it won't have to internally create new IntersectionObservers, just because the container's size changes.\n\t\t */\n\t\tconst decimals = 10;\n\t\tconst noSize = containerSize <= 0;\n\t\tconst relMarginStart = noSize ? 0 : -marginStart / containerSize;\n\t\tconst relMarginEnd = noSize ? 0 : -marginEnd / containerSize;\n\n\t\t// adding available scrollspace in cross direction, so element never moves out of trackable area, even when scrolling horizontally on a vertically tracked element\n\t\tconst noCrossSize = crossClientSize <= 0;\n\t\tconst scrollableCross =\n\t\t\tnoCrossSize ? 0 : numberToPercString((crossScrollSize - crossClientSize) / crossClientSize, decimals);\n\t\treturn {\n\t\t\t// the start and end values are intentionally flipped here (start value defines end margin and vice versa)\n\t\t\t[axis.start]: numberToPercString(relMarginEnd, decimals),\n\t\t\t[axis.end]: numberToPercString(relMarginStart, decimals),\n\t\t\t[cross.start]: scrollableCross,\n\t\t\t[cross.end]: scrollableCross,\n\t\t} as Record<'top' | 'left' | 'bottom' | 'right', string>;\n\t}\n\n\tprotected getTrackSize(): number {\n\t\treturn this.elementBoundsCache.trackSize + this.containerBoundsCache.trackSize;\n\t}\n\n\t// !update functions MUST NOT call any other functions causing side effects, with the exceptions of modify and event triggers in progress\n\n\tprotected updateIntersectingState(nextIntersecting: boolean | undefined): void {\n\t\t// doesn't have to be a method, but I want to keep modifications obvious (only called from update... methods)\n\t\tthis.intersecting = nextIntersecting;\n\t}\n\n\tprotected updateElementBoundsCache(): void {\n\t\t// console.log(this.optionsPrivate.element.id, 'bounds', new Date().getMilliseconds());\n\t\t// this should be called cautiously, getBoundingClientRect costs...\n\t\tconst { elementStart, elementEnd, element, vertical } = this.optionsPrivate;\n\t\tconst props = agnosticProps(vertical);\n\t\tconst rect = element.getBoundingClientRect();\n\t\tconst start = rect[props.start];\n\t\tconst size = rect[props.size];\n\t\tthis.elementBoundsCache.start = start;\n\t\t// only update if size has changed, otherwise we're recalculating the offsetStart and offsetEnd for no reason\n\t\tif (size !== this.elementBoundsCache.size) {\n\t\t\tconst offsetStart = elementStart(size);\n\t\t\tconst offsetEnd = elementEnd(size);\n\t\t\tObject.assign(this.elementBoundsCache, {\n\t\t\t\tsize,\n\t\t\t\toffsetStart,\n\t\t\t\toffsetEnd,\n\t\t\t\ttrackSize: size - offsetStart - offsetEnd,\n\t\t\t});\n\t\t}\n\t}\n\n\tprotected updateContainerBoundsCache(): void {\n\t\t// console.log(this.optionsPrivate.element.id, 'container', new Date().getMilliseconds());\n\t\tconst { containerStart, containerEnd, vertical } = this.optionsPrivate;\n\t\tconst containerProps = agnosticProps(vertical);\n\t\tconst clientSize = this.containerProxy.size[containerProps.clientSize];\n\t\tconst scrollSize = this.containerProxy.size[containerProps.scrollSize];\n\t\tconst offsetStart = containerStart(clientSize);\n\t\tconst offsetEnd = containerEnd(clientSize);\n\t\tObject.assign(this.containerBoundsCache, {\n\t\t\tclientSize,\n\t\t\tscrollSize,\n\t\t\toffsetStart,\n\t\t\toffsetEnd,\n\t\t\ttrackSize: -(clientSize - offsetStart - offsetEnd), // container track is inverted (start is usually below end)\n\t\t});\n\t}\n\n\tprotected updateProgress(): void {\n\t\t// console.log(this.optionsPrivate.element.id, 'progress', new Date().getMilliseconds());\n\t\tif (this.containerBoundsCache.clientSize <= 0) {\n\t\t\treturn; // container has no visible area, progress is meaningless\n\t\t}\n\t\tconst { offsetStart: elementOffset, start: elementPosition } = this.elementBoundsCache;\n\t\tconst { offsetStart: containerOffset } = this.containerBoundsCache;\n\t\tconst containerPosition = this.containerProxy.position[agnosticProps(this.optionsPrivate.vertical).start];\n\n\t\tconst elementStart = elementPosition + elementOffset;\n\t\tconst containerStart = containerPosition + containerOffset;\n\t\tconst passed = containerStart - elementStart;\n\t\tconst total = this.getTrackSize();\n\n\t\tif (total < 0) {\n\t\t\treturn; // no overlap of track and scroll distance\n\t\t}\n\n\t\tconst previousProgress = this.currentProgress;\n\t\tconst nextProgress = Math.min(Math.max(passed / total, 0), 1); // when leaving, it will overshoot, this normalises to 0 / 1 (also when total is 0)\n\t\tconst deltaProgress = nextProgress - previousProgress;\n\n\t\tif (deltaProgress === 0) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.currentProgress = nextProgress;\n\t\tconst forward = deltaProgress > 0;\n\n\t\tif (previousProgress === 0 || previousProgress === 1) {\n\t\t\tthis.triggerEvent(EventType.Enter, forward);\n\t\t}\n\t\tthis.triggerEvent(EventType.Progress, forward);\n\t\tif (nextProgress === 0 || nextProgress === 1) {\n\t\t\tthis.triggerEvent(EventType.Leave, forward);\n\t\t}\n\t}\n\n\tprotected updateViewportObserver(): void {\n\t\tif (this.containerBoundsCache.clientSize <= 0) {\n\t\t\tthis.updateIntersectingState(undefined); // reset so intersection re-evaluates when container becomes visible\n\t\t\treturn;\n\t\t}\n\t\tconst { container, vertical } = this.optionsPrivate;\n\t\tconst observerOptions = {\n\t\t\tmargin: this.getViewportMargin(),\n\t\t\troot: isWindow(container) ? null : container,\n\t\t\tvertical,\n\t\t};\n\t\tthis.viewportObserver.modify(observerOptions);\n\t}\n\n\tprotected onOptionChanges(changedOptions: Options.Public): void {\n\t\tconst changes = Object.keys(changedOptions) as Array<keyof Options.Public>;\n\t\tif (changes.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst isChanged = changes.includes.bind(changes);\n\t\tconst directionChanged = isChanged('vertical');\n\t\tconst elementBoundsInvalidated = directionChanged || isChanged('elementStart') || isChanged('elementEnd');\n\n\t\tif (elementBoundsInvalidated) {\n\t\t\tthis.elementBoundsCache.size = NaN; // force converter recalculation (size guard in updateElementBoundsCache)\n\t\t}\n\t\tif (this.disabled) return;\n\n\t\tconst elementChanged = isChanged('element');\n\t\tconst containerChanged = isChanged('container');\n\t\tconst containerBoundsInvalidated =\n\t\t\tcontainerChanged || directionChanged || isChanged('containerStart') || isChanged('containerEnd');\n\n\t\tif (elementBoundsInvalidated || elementChanged) {\n\t\t\tthis.update.elementBounds.schedule();\n\t\t}\n\t\tif (elementChanged) {\n\t\t\tthis.updateIntersectingState(undefined);\n\t\t\tconst { element } = this.optionsPrivate;\n\t\t\tthis.viewportObserver.disconnect();\n\t\t\tthis.viewportObserver.observe(element);\n\t\t\tthis.resizeCleanup?.();\n\t\t\tthis.resizeCleanup = observeResize(element, this.onElementResize.bind(this));\n\t\t}\n\t\tif (containerBoundsInvalidated) {\n\t\t\tthis.update.containerBounds.schedule();\n\t\t\tthis.update.viewportObserver.schedule();\n\t\t}\n\t\tif (containerChanged) {\n\t\t\tthis.updateIntersectingState(undefined);\n\t\t\tthis.containerProxy.attach(this.optionsPrivate.container, this.onContainerUpdate.bind(this)); // container updates are already throttled\n\t\t}\n\t\t// if any options changes we always have to refresh the progress\n\t\tthis.update.progress.schedule();\n\t}\n\n\tprotected onElementResize(): void {\n\t\t/**\n\t\t * * element resized\n\t\t * updateContainerBounds => \tnever\n\t\t * updateElementBounds =>\t\tschedule always (obviously),\t\texecute regardless.\n\t\t * updateViewportObserver => \tschedule always, \t\t\t\t\texecute if start or end offset changed in trigger bounds update above\n\t\t * updateProgress => \t\t\tschedule if currently intersecting,\texecute if bounds changed (offsets or size, since size affects trackSize)\n\t\t */\n\t\tconst { update } = this;\n\t\t// Capture previous values for lazy condition checks.\n\t\t// IMPORTANT: closures must reference `this.elementBoundsCache` (not a destructured local)\n\t\t// because `updateElementBoundsCache()` replaces the entire object reference.\n\t\tconst { offsetStart: startPrevious, offsetEnd: endPrevious, size: sizePrevious } = this.elementBoundsCache;\n\t\tconst isOffsetChanged = () =>\n\t\t\tstartPrevious !== this.elementBoundsCache.offsetStart || endPrevious !== this.elementBoundsCache.offsetEnd;\n\t\tconst isBoundsChanged = () => isOffsetChanged() || sizePrevious !== this.elementBoundsCache.size;\n\t\tupdate.elementBounds.schedule();\n\t\tupdate.viewportObserver.schedule(isOffsetChanged);\n\t\tif (this.intersecting) {\n\t\t\tupdate.progress.schedule(isBoundsChanged);\n\t\t}\n\t}\n\n\tprotected onContainerUpdate(e: ContainerEvent): void {\n\t\t/**\n\t\t * * container resized\n\t\t * updateContainerBounds => \tschedule always\t\t\t\t\t\t\texecute regardless\n\t\t * updateElementBounds => \t\tschedule if currently intersecting, \texecute regardless (resizes are caught in onElementResize but position might change due to container resize, which wouldn't be)\n\t\t * updateViewportObserver => \tschedule always (to get new margins),\texecute regardless.\n\t\t * updateProgress => \t\t\tschedule if currently intersecting, \texecute if position changed in triggerBounds update\n\t\t */\n\t\tconst { update } = this;\n\t\tif ('resize' === e.type) {\n\t\t\tthis.update.containerBounds.schedule();\n\t\t\tif (this.intersecting) {\n\t\t\t\tupdate.elementBounds.schedule();\n\t\t\t}\n\t\t\tupdate.viewportObserver.schedule();\n\t\t\tconst { start: startPrevious } = this.elementBoundsCache;\n\t\t\tconst { clientSize: sizePrevious } = this.containerBoundsCache;\n\t\t\tconst isChanged = () =>\n\t\t\t\tstartPrevious !== this.elementBoundsCache.start || sizePrevious !== this.containerBoundsCache.clientSize;\n\t\t\tupdate.progress.schedule(isChanged);\n\t\t\treturn;\n\t\t}\n\t\t/**\n\t\t * * container scrolled\n\t\t * if relevant scrollDelta is 0, do nothing (scroll was in other direction)\n\t\t * updateContainerBounds => \tnever\n\t\t * updateElementBounds =>\t\tschedule if currently intersecting,\t\t\t\t\t\t\texecute regardless\n\t\t * updateViewportObserver => \tnever\n\t\t * updateProgress =>\t\t\tschedule if currently intersecting or potentially skipped, \texecute regardless (technically only execute if triggerBounds returned a new position, but that's implied, if there was a scoll move in the relevant direction)\n\t\t */\n\t\tconst scrollDelta = e.scrollDelta[agnosticProps(this.optionsPrivate.vertical).axis];\n\t\tif (0 === scrollDelta) {\n\t\t\treturn; // scroll was in other direction\n\t\t}\n\t\t// in case the scroll position changes by more than the total track distance, the viewport observer might miss it.\n\t\t// this means running the progress update more than we have to, but in this case we have no choice.\n\t\tconst potentiallySkipped = Math.abs(scrollDelta) > this.getTrackSize();\n\n\t\tif (!this.intersecting && !potentiallySkipped) {\n\t\t\t// if we're not intersecting and there's no danger we skipped the active range, we don't have to do anything...\n\t\t\treturn;\n\t\t}\n\t\tupdate.elementBounds.schedule();\n\t\tupdate.progress.schedule();\n\t}\n\n\tprotected onIntersectionChange(intersecting: boolean, target: Element): void {\n\t\t// the check below should always be true, as we only ever observe one element, but you can never be too sure, I guess...\n\t\tif (target === this.optionsPrivate.element) {\n\t\t\t/**\n\t\t\t * * intersection state changed\n\t\t\t * updateContainerBounds => \tnever\n\t\t\t * updateElementBounds =>\t\tschedule regardless, if intersection state changed, position likely did, too.\n\t\t\t * updateViewportObserver =>\tnever\n\t\t\t * updateProgress =>\t\t\tschedule regardless, execute regardless\n\t\t\t */\n\t\t\tthis.updateIntersectingState(intersecting);\n\n\t\t\tthis.update.elementBounds.schedule();\n\t\t\tthis.update.progress.schedule();\n\t\t}\n\t}\n\n\tprotected triggerEvent(type: EventType, forward: boolean): void {\n\t\tthis.dispatcher.dispatchEvent(new ScrollMagicEvent(this, type, forward));\n\t}\n\n\t/**\n\t * Update one or more options on this instance. Only changed values trigger internal recalculations.\n\t *\n\t * @param options - Partial set of public options to merge.\n\t * @returns The instance, for chaining.\n\t *\n\t * @example\n\t * ```js\n\t * sm.modify({ elementStart: '25%', containerEnd: 100 });\n\t * ```\n\t */\n\tpublic modify(options: Options.Public): ScrollMagic {\n\t\tif (this.guardInert()) {\n\t\t\treturn this;\n\t\t}\n\t\tconst { sanitized, processed } = processOptions(options, this.optionsPrivate);\n\n\t\tconst changedOptions =\n\t\t\t(\n\t\t\t\tundefined === this.optionsPublic // not set on first run, so all changed\n\t\t\t) ?\n\t\t\t\tsanitized\n\t\t\t:\tpickDifferencesFlat(sanitized, this.optionsPublic);\n\n\t\tthis.optionsPublic = { ...this.optionsPublic, ...changedOptions };\n\t\tthis.optionsPrivate = processed;\n\n\t\tthis.onOptionChanges(changedOptions);\n\t\tthis.plugins.forEach(plugin => plugin.onModify?.call(this, changedOptions));\n\t\treturn this;\n\t}\n\n\t/**\n\t * Register a plugin on this instance. The plugin's `onAdd` callback is invoked immediately.\n\t *\n\t * @param plugin - The plugin to add.\n\t * @returns The instance, for chaining.\n\t */\n\tpublic addPlugin(plugin: Plugin): ScrollMagic {\n\t\tif (this.guardInert()) {\n\t\t\treturn this;\n\t\t}\n\t\tthis.plugins.add(plugin);\n\t\tplugin.onAdd?.call(this);\n\t\treturn this;\n\t}\n\n\t/**\n\t * Unregister a plugin from this instance. The plugin's `onRemove` callback is invoked immediately.\n\t *\n\t * @param plugin - The plugin to remove.\n\t * @returns The instance, for chaining.\n\t */\n\tpublic removePlugin(plugin: Plugin): ScrollMagic {\n\t\tif (this.guardInert()) {\n\t\t\treturn this;\n\t\t}\n\t\tthis.plugins.delete(plugin);\n\t\tplugin.onRemove?.call(this);\n\t\treturn this;\n\t}\n\n\t// getter/setter public\n\n\t/** Set the tracked element. Accepts an `Element`, a CSS selector, or `null` to reset. */\n\tpublic set element(element: Required<Options.Public>['element']) {\n\t\tthis.modify({ element });\n\t}\n\t/** The resolved tracked DOM element. */\n\tpublic get element(): Options.Private['element'] {\n\t\treturn this.optionsPrivate.element;\n\t}\n\t/** Set the start inset on the tracked element. Positive values shrink the tracked region from the leading edge. */\n\tpublic set elementStart(elementStart: Required<Options.Public>['elementStart']) {\n\t\tthis.modify({ elementStart });\n\t}\n\t/** The current start inset value for the tracked element (as originally provided). */\n\tpublic get elementStart(): Required<Options.Public>['elementStart'] {\n\t\treturn this.optionsPublic.elementStart;\n\t}\n\t/** Set the end inset on the tracked element. Positive values shrink the tracked region from the trailing edge. */\n\tpublic set elementEnd(elementEnd: Required<Options.Public>['elementEnd']) {\n\t\tthis.modify({ elementEnd });\n\t}\n\t/** The current end inset value for the tracked element (as originally provided). */\n\tpublic get elementEnd(): Required<Options.Public>['elementEnd'] {\n\t\treturn this.optionsPublic.elementEnd;\n\t}\n\t/** Set the scroll container. Accepts a `Window`, `Element`, CSS selector, or `null` to reset. */\n\tpublic set container(container: Required<Options.Public>['container']) {\n\t\tthis.modify({ container });\n\t}\n\t/** The resolved scroll container (`Window` or `HTMLElement`). */\n\tpublic get container(): Options.Private['container'] {\n\t\treturn this.optionsPrivate.container;\n\t}\n\t/** Set the start inset on the scroll container. Set to `null` to infer based on `element`. */\n\tpublic set containerStart(containerStart: Required<Options.Public>['containerStart']) {\n\t\tthis.modify({ containerStart });\n\t}\n\t/** The current start inset value for the scroll container (as originally provided). */\n\tpublic get containerStart(): Required<Options.Public>['containerStart'] {\n\t\treturn this.optionsPublic.containerStart;\n\t}\n\t/** Set the end inset on the scroll container. Set to `null` to infer based on `element`. */\n\tpublic set containerEnd(containerEnd: Required<Options.Public>['containerEnd']) {\n\t\tthis.modify({ containerEnd });\n\t}\n\t/** The current end inset value for the scroll container (as originally provided). */\n\tpublic get containerEnd(): Required<Options.Public>['containerEnd'] {\n\t\treturn this.optionsPublic.containerEnd;\n\t}\n\t/** Set the scroll axis. `true` = vertical, `false` = horizontal. */\n\tpublic set vertical(vertical: Required<Options.Public>['vertical']) {\n\t\tthis.modify({ vertical });\n\t}\n\t/** Whether this instance tracks vertical (`true`) or horizontal (`false`) scroll. */\n\tpublic get vertical(): Options.Private['vertical'] {\n\t\treturn this.optionsPrivate.vertical;\n\t}\n\n\t/** Current scroll progress through the active zone, from 0 (before) to 1 (past). */\n\tpublic get progress(): number {\n\t\treturn this.currentProgress;\n\t}\n\t/** Raw scroll velocity in pixels per second along the tracked axis (no smoothing). Returns 0 when disabled or not scrolling. */\n\tpublic get scrollVelocity(): number {\n\t\tif (this.disabled) {\n\t\t\treturn 0;\n\t\t}\n\t\treturn this.containerProxy.scrollVelocity[agnosticProps(this.optionsPrivate.vertical).axis];\n\t}\n\t/** Returns the scroll container's scroll positions at which tracking starts and ends. Triggers a synchronous layout read (cached values when disabled). */\n\tpublic get activeRange(): { start: number; end: number } {\n\t\tif (this.guardInert()) {\n\t\t\treturn { start: 0, end: 0 };\n\t\t}\n\t\tif (!this.disabled) {\n\t\t\tthis.updateElementBoundsCache(); // need to get fresh position — skip when disabled to avoid mixing fresh element bounds with stale container bounds\n\t\t}\n\t\tconst { container, vertical } = this.optionsPrivate;\n\t\tconst { start: elementPosition, offsetStart, trackSize } = this.elementBoundsCache;\n\t\tconst {\n\t\t\tclientSize: containerSize,\n\t\t\toffsetStart: containerOffsetStart,\n\t\t\toffsetEnd: containerOffsetEnd,\n\t\t} = this.containerBoundsCache;\n\t\tconst scrollOffset = getScrollPos(container)[agnosticProps(vertical).start];\n\n\t\tconst absolutePosition = elementPosition + scrollOffset;\n\t\tconst start = absolutePosition + offsetStart;\n\t\tconst end = start + trackSize;\n\t\treturn {\n\t\t\tstart: Math.floor(start - containerOffsetStart),\n\t\t\tend: Math.ceil(end - containerSize + containerOffsetEnd),\n\t\t};\n\t}\n\t/** Resolved pixel offsets for container zone and element boundaries, based on current layout. */\n\tpublic get resolvedBounds(): Readonly<ResolvedBounds> {\n\t\treturn {\n\t\t\telement: { ...this.elementBoundsCache },\n\t\t\tcontainer: { ...this.containerBoundsCache },\n\t\t};\n\t}\n\t/** Snapshot of all currently registered plugins. */\n\tpublic get pluginList(): Array<Plugin> {\n\t\treturn [...this.plugins];\n\t}\n\t/** Whether tracking is currently paused (via `disable()`) or the instance has been destroyed. */\n\tpublic get disabled(): boolean {\n\t\treturn !this.enabled || this.destroyed;\n\t}\n\n\tpublic get [Symbol.toStringTag](): string {\n\t\treturn this.name;\n\t}\n\n\t/**\n\t * Add an event listener.\n\t *\n\t * @param type - The event type to listen for.\n\t * @param cb - Callback invoked with a {@link ScrollMagicEvent}.\n\t * @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.\n\t * @returns The instance, for chaining.\n\t *\n\t * @example\n\t * ```js\n\t * sm.on('progress', (e) => {\n\t *   console.log(e.target.progress); // 0–1\n\t * });\n\t *\n\t * // Fire only once\n\t * sm.on('enter', (e) => console.log('entered!'), { once: true });\n\t *\n\t * // Remove all listeners at once via AbortController\n\t * const ac = new AbortController();\n\t * sm.on('enter', onEnter, { signal: ac.signal });\n\t * sm.on('leave', onLeave, { signal: ac.signal });\n\t * // Later: ac.abort();\n\t * ```\n\t */\n\tpublic on(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, options?: ListenerOptions): ScrollMagic {\n\t\tif (this.guardInert()) {\n\t\t\treturn this;\n\t\t}\n\t\tthis.dispatcher.addEventListener(type, cb, options);\n\t\treturn this;\n\t}\n\t/**\n\t * Remove a previously registered event listener.\n\t *\n\t * @param type - The event type the listener was registered for.\n\t * @param cb - The exact callback reference passed to {@link on}.\n\t * @returns The instance, for chaining.\n\t */\n\tpublic off(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void): ScrollMagic {\n\t\tif (this.guardInert()) {\n\t\t\treturn this;\n\t\t}\n\t\tthis.dispatcher.removeEventListener(type, cb);\n\t\treturn this;\n\t}\n\t/**\n\t * Add an event listener and receive a disposer function to remove it.\n\t * Unlike {@link on}, this is not chainable — it returns the unsubscribe function instead.\n\t *\n\t * @param type - The event type to listen for.\n\t * @param cb - Callback invoked with a {@link ScrollMagicEvent}.\n\t * @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.\n\t * @returns A function that removes the listener when called.\n\t *\n\t * @example\n\t * ```js\n\t * const unsub = sm.subscribe('enter', (e) => console.log('entered!'));\n\t * // Later:\n\t * unsub();\n\t * ```\n\t */\n\tpublic subscribe(type: `${EventType}`, cb: (e: ScrollMagicEvent) => void, options?: ListenerOptions): () => void {\n\t\tif (this.guardInert()) {\n\t\t\treturn () => {};\n\t\t}\n\t\treturn this.dispatcher.addEventListener(type, cb, options);\n\t}\n\n\t/**\n\t * Schedule a full recalculation of element bounds, container bounds, viewport observer, and progress.\n\t *\n\t * @remarks Updates run asynchronously on the next animation frame.\n\t * @returns The instance, for chaining.\n\t */\n\tpublic refresh(): ScrollMagic {\n\t\tif (this.guardInert() || this.disabled) {\n\t\t\treturn this;\n\t\t}\n\t\tthis.update.elementBounds.schedule();\n\t\tthis.update.containerBounds.schedule();\n\t\tthis.update.viewportObserver.schedule();\n\t\tthis.update.progress.schedule();\n\t\treturn this;\n\t}\n\n\t/**\n\t * Pause tracking — disconnects all observers and freezes progress.\n\t * Options can still be modified while disabled.\n\t *\n\t * @returns The instance, for chaining.\n\t * @see {@link enable} to resume tracking.\n\t *\n\t * @example\n\t * ```js\n\t * sm.disable();\n\t * // progress and events are frozen\n\t * sm.enable(); // resume\n\t * ```\n\t */\n\tpublic disable(): ScrollMagic {\n\t\tif (this.guardInert() || this.disabled) return this;\n\t\tthis.enabled = false;\n\t\tthis.executionQueue.cancel();\n\t\tthis.resizeCleanup?.();\n\t\tthis.resizeCleanup = undefined;\n\t\tthis.viewportObserver.disconnect();\n\t\tthis.containerProxy.detach();\n\t\tthis.plugins.forEach(plugin => plugin.onDisable?.call(this));\n\t\treturn this;\n\t}\n\n\t/**\n\t * Resume tracking — reconnects all observers and recalculates from current state.\n\t *\n\t * @returns The instance, for chaining.\n\t * @see {@link disable} to pause tracking.\n\t *\n\t * @example\n\t * ```js\n\t * sm.enable();\n\t * ```\n\t */\n\tpublic enable(): ScrollMagic {\n\t\tif (this.guardInert() || this.enabled) return this;\n\t\tthis.enabled = true;\n\t\tconst { element, container } = this.optionsPrivate;\n\t\tthis.updateIntersectingState(undefined);\n\t\tthis.viewportObserver.observe(element);\n\t\tthis.resizeCleanup = observeResize(element, this.onElementResize.bind(this));\n\t\tthis.containerProxy.attach(container, this.onContainerUpdate.bind(this));\n\t\tthis.elementBoundsCache.size = NaN; // force converter recalculation\n\t\tthis.update.elementBounds.schedule();\n\t\tthis.update.containerBounds.schedule();\n\t\tthis.update.viewportObserver.schedule();\n\t\tthis.update.progress.schedule();\n\t\tthis.plugins.forEach(plugin => plugin.onEnable?.call(this));\n\t\treturn this;\n\t}\n\n\t/**\n\t * Permanently tear down this instance — disconnects all observers, removes all plugins,\n\t * and deregisters from {@link ScrollMagic.refreshAll} / {@link ScrollMagic.destroyAll}.\n\t * The instance cannot be used after calling this method.\n\t */\n\tpublic destroy(): void {\n\t\tif (this.destroyed || !isBrowser) {\n\t\t\treturn;\n\t\t}\n\t\tthis.disable(); // tear down observers (no-ops if already disabled), fires onDisable\n\t\tthis.destroyed = true;\n\t\tScrollMagic.instances.delete(this);\n\t\tthis.plugins.forEach(plugin => plugin.onDestroy?.call(this));\n\t\tthis.plugins.clear();\n\t}\n\n\t// static options/methods\n\n\tprivate static readonly instances = new Set<ScrollMagic>();\n\t/** Schedule a full recalculation on all active ScrollMagic instances. */\n\tpublic static refreshAll(): void {\n\t\tScrollMagic.instances.forEach(instance => instance.refresh());\n\t}\n\t/** Destroy all active ScrollMagic instances. */\n\tpublic static destroyAll(): void {\n\t\tScrollMagic.instances.forEach(instance => instance.destroy());\n\t}\n\n\tprotected static defaultOptionsPublic = Options.defaults;\n\t/**\n\t * Get or update the default options applied to all future ScrollMagic instances.\n\t *\n\t * @param options - Partial options to merge into the current defaults.\n\t * @returns The full set of current default options.\n\t *\n\t * @example\n\t * ```js\n\t * // Set a global default container\n\t * ScrollMagic.defaultOptions({ container: '#main-scroller' });\n\t *\n\t * // Read current defaults\n\t * const defaults = ScrollMagic.defaultOptions();\n\t * ```\n\t */\n\tpublic static defaultOptions(options: Options.Public = {}): Required<Options.Public> {\n\t\tthis.defaultOptionsPublic = {\n\t\t\t...this.defaultOptionsPublic,\n\t\t\t...sanitizeOptions(options),\n\t\t};\n\t\treturn this.defaultOptionsPublic;\n\t}\n\t/** Enum of event types: `Enter`, `Leave`, `Progress`. @see {@link EventType} */\n\tpublic static readonly EventType = EventType;\n\t/** Enum of event locations: `Start`, `Inside`, `End`. @see {@link EventLocation} */\n\tpublic static readonly EventLocation = EventLocation;\n\t/** Enum of scroll directions: `Forward`, `Reverse`. @see {@link ScrollDirection} */\n\tpublic static readonly EventScrollDirection = ScrollDirection;\n}\n"
  },
  {
    "path": "src/ScrollMagicError.ts",
    "content": "/** Base error class for all ScrollMagic errors. */\nexport class ScrollMagicError extends Error {\n\tpublic override readonly name: string = 'ScrollMagicError';\n\tpublic get [Symbol.toStringTag]() {\n\t\treturn this.name;\n\t}\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t}\n}\n/** Error class for unexpected internal failures — indicates a bug in ScrollMagic itself. */\nexport class ScrollMagicInternalError extends ScrollMagicError {\n\tpublic override readonly name = 'ScrollMagicInternalError';\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(`Internal Error: ${message}`, options);\n\t}\n}\n"
  },
  {
    "path": "src/ScrollMagicEvent.ts",
    "content": "import type { DispatchableEvent } from './EventDispatcher';\nimport type { ScrollMagic } from './ScrollMagic';\n\n/** Lifecycle event types dispatched by a ScrollMagic instance. */\nexport enum EventType {\n\t/** Fired when the element enters the active scroll zone. */\n\tEnter = 'enter',\n\t/** Fired when the element leaves the active scroll zone. */\n\tLeave = 'leave',\n\t/** Fired on every scroll update while the element is inside the active zone. */\n\tProgress = 'progress',\n}\n\n/** Where the event occurred relative to the active scroll zone. */\nexport enum EventLocation {\n\t/** At the leading edge of the zone (entering forward or leaving in reverse). */\n\tStart = 'start',\n\t/** Between the start and end edges. */\n\tInside = 'inside',\n\t/** At the trailing edge of the zone (entering in reverse or leaving forward). */\n\tEnd = 'end',\n}\n\n/** Scroll direction at the time the event was dispatched. */\nexport enum ScrollDirection {\n\t/** Scrolling toward higher scroll offsets (down or right). */\n\tForward = 'forward',\n\t/** Scrolling toward lower scroll offsets (up or left). */\n\tReverse = 'reverse',\n}\n\ntype EnumOrLiteral<T extends string> = T | `${T}`;\ntype ScrollMagicEventType = EnumOrLiteral<EventType>;\ntype ScrollMagicEventLocation = EnumOrLiteral<EventLocation>;\ntype ScrollMagicEventScrollDirection = EnumOrLiteral<ScrollDirection>;\n\n/**\n * Event object dispatched by ScrollMagic on lifecycle transitions and progress updates.\n *\n * Instances are created internally and passed to listeners registered via {@link ScrollMagic.on} or {@link ScrollMagic.subscribe}.\n */\nexport class ScrollMagicEvent implements DispatchableEvent {\n\t/** Where the event occurred relative to the active zone (`'start'`, `'inside'`, or `'end'`). */\n\tpublic readonly location: ScrollMagicEventLocation;\n\t/** Scroll direction at the time of the event (`'forward'` or `'reverse'`). */\n\tpublic readonly direction: ScrollMagicEventScrollDirection;\n\tconstructor(\n\t\t/** The ScrollMagic instance that dispatched this event. */\n\t\tpublic readonly target: ScrollMagic,\n\t\t/** The event type (`'enter'`, `'leave'`, or `'progress'`). */\n\t\tpublic readonly type: ScrollMagicEventType,\n\t\tmovingForward: boolean\n\t) {\n\t\tthis.location = (() => {\n\t\t\tif (EventType.Progress === type) {\n\t\t\t\treturn EventLocation.Inside;\n\t\t\t}\n\t\t\tif ((EventType.Enter === type && movingForward) || (EventType.Leave === type && !movingForward)) {\n\t\t\t\treturn EventLocation.Start;\n\t\t\t}\n\t\t\treturn EventLocation.End;\n\t\t})();\n\t\tthis.direction = movingForward ? ScrollDirection.Forward : ScrollDirection.Reverse;\n\t}\n}\n"
  },
  {
    "path": "src/ViewportObserver.ts",
    "content": "import { pickDifferencesFlat } from './util/pickDifferencesFlat';\n\ntype Margin = {\n\ttop: string;\n\tright: string;\n\tbottom: string;\n\tleft: string;\n};\n\ninterface Options {\n\troot?: Element | null; // null is window\n\tmargin?: Margin;\n\tvertical?: boolean;\n}\n\ntype ObserverCallback = (isIntersecting: boolean, target: Element) => void;\n\n// this ensures the order in the object doesn't matter\nconst marginObjToString = ({ top, right, bottom, left }: Margin) => [top, right, bottom, left].join(' ');\n\nconst none = '0px';\n\n// resolves the combined state of enter/leave observers into a single boolean or undefined (if not yet fully initialized)\nconst resolveState = (hitEnter: boolean | undefined, hitLeave: boolean | undefined): boolean | undefined => {\n\tif (hitEnter === undefined || hitLeave === undefined) return undefined;\n\treturn hitEnter && hitLeave;\n};\n\nexport class ViewportObserver {\n\tprivate observerEnter?: IntersectionObserver;\n\tprivate observerLeave?: IntersectionObserver;\n\tprivate options: Required<Options> = {\n\t\troot: null,\n\t\tmargin: { top: none, right: none, bottom: none, left: none },\n\t\tvertical: true,\n\t};\n\tprivate observedElements = new Map<Element, [boolean | undefined, boolean | undefined]>();\n\tconstructor(\n\t\tprivate callback: ObserverCallback,\n\t\toptions?: Options\n\t) {\n\t\tif (undefined === options) {\n\t\t\treturn; // nothing will happen, until modify is called.\n\t\t}\n\t\tthis.options = {\n\t\t\t...this.options,\n\t\t\t...options,\n\t\t};\n\t}\n\tprivate observerCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {\n\t\tentries.forEach(({ target, isIntersecting }) => {\n\t\t\tlet [hitEnter, hitLeave] = this.observedElements.get(target) ?? [];\n\t\t\tconst prevState = resolveState(hitEnter, hitLeave);\n\t\t\tif (observer === this.observerEnter) {\n\t\t\t\thitEnter = isIntersecting;\n\t\t\t} else {\n\t\t\t\thitLeave = isIntersecting;\n\t\t\t}\n\t\t\tthis.observedElements.set(target, [hitEnter, hitLeave]);\n\t\t\tconst newState = resolveState(hitEnter, hitLeave);\n\t\t\tif (undefined === newState || prevState === newState) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.callback(newState, target);\n\t\t});\n\t}\n\tprivate createObserver(rootMargin: string) {\n\t\tconst root = this.options.root;\n\t\tconst observer = new IntersectionObserver(this.observerCallback.bind(this), { root, rootMargin });\n\t\t[...this.observedElements.keys()].forEach(elem => observer.observe(elem));\n\t\treturn observer;\n\t}\n\tprivate rebuildObserver() {\n\t\tthis.observerEnter?.disconnect();\n\t\tthis.observerLeave?.disconnect();\n\t\tconst { margin, vertical } = this.options;\n\t\tconst clampPositive = (val: string) => `${Math.max(0, parseFloat(val))}%`;\n\n\t\t// The enter observer clips the \"leave side\" margin to >= 0 (so it doesn't shrink the viewport on that side).\n\t\t// The leave observer clips the \"enter side\" margin to >= 0.\n\t\t// For vertical: leave side = top, enter side = bottom.\n\t\t// For horizontal: leave side = left, enter side = right.\n\t\t// TODO: check what happens, if the opposite value still overlaps (due to offset / height ?)\n\t\t// TODO! I know now: if effective duration exceeds available observer height it fails... -> BUG! -> FIX...\n\t\tconst marginEnter =\n\t\t\tvertical ? { ...margin, top: clampPositive(margin.top) } : { ...margin, left: clampPositive(margin.left) };\n\t\tconst marginLeave =\n\t\t\tvertical ?\n\t\t\t\t{ ...margin, bottom: clampPositive(margin.bottom) }\n\t\t\t:\t{ ...margin, right: clampPositive(margin.right) };\n\n\t\tthis.observerEnter = this.createObserver(marginObjToString(marginEnter));\n\t\tthis.observerLeave = this.createObserver(marginObjToString(marginLeave));\n\t}\n\tprivate optionsChanged({ root, margin, vertical }: Options) {\n\t\tif (undefined !== root && root !== this.options.root) {\n\t\t\treturn true;\n\t\t}\n\t\tif (undefined !== vertical && vertical !== this.options.vertical) {\n\t\t\treturn true;\n\t\t}\n\t\tif (undefined !== margin) {\n\t\t\treturn Object.keys(pickDifferencesFlat(margin, this.options.margin)).length > 0;\n\t\t}\n\t\treturn false;\n\t}\n\n\tpublic modify(options: Options): ViewportObserver {\n\t\tif (!this.optionsChanged(options)) {\n\t\t\treturn this;\n\t\t}\n\t\tthis.options = {\n\t\t\t...this.options,\n\t\t\t...options,\n\t\t};\n\t\tthis.rebuildObserver();\n\t\treturn this;\n\t}\n\tpublic observe(elem: Element): ViewportObserver {\n\t\tif (!this.observedElements.has(elem)) {\n\t\t\tthis.observedElements.set(elem, [undefined, undefined]);\n\t\t\tthis.observerEnter?.observe(elem);\n\t\t\tthis.observerLeave?.observe(elem);\n\t\t}\n\t\treturn this;\n\t}\n\tpublic unobserve(elem: Element): ViewportObserver {\n\t\tif (this.observedElements.delete(elem)) {\n\t\t\tthis.observerEnter?.unobserve(elem);\n\t\t\tthis.observerLeave?.unobserve(elem);\n\t\t}\n\t\treturn this;\n\t}\n\tpublic disconnect(): void {\n\t\tthis.observedElements.clear();\n\t\tthis.observerEnter?.disconnect();\n\t\tthis.observerLeave?.disconnect();\n\t}\n}\n"
  },
  {
    "path": "src/env.d.ts",
    "content": "// Ambient type for process.env.NODE_ENV — used for dev-only warnings.\n// Bundlers replace process.env.NODE_ENV at build time; no Node.js dependency needed.\ndeclare const process: undefined | { env: { NODE_ENV?: string } };\n"
  },
  {
    "path": "src/index.ts",
    "content": "import type { Public as ScrollMagicOptions } from './Options';\nimport type { Plugin as ScrollMagicPlugin } from './ScrollMagic';\nimport { EventLocation, EventType, ScrollDirection } from './ScrollMagicEvent';\n\n// make literals from enums for export\ntype EventTypeLiteral = `${EventType}`;\ntype EventLocationLiteral = `${EventLocation}`;\ntype ScrollDirectionLiteral = `${ScrollDirection}`;\n\nexport { ScrollMagic as default } from './ScrollMagic';\n\n// relevant types\nexport type { ScrollMagicError } from './ScrollMagicError';\nexport type { ScrollMagicEvent } from './ScrollMagicEvent';\nexport type { ListenerOptions } from './EventDispatcher';\nexport type { ScrollMagicPlugin };\nexport type { ScrollMagicOptions };\nexport type { ElementBounds, ContainerBounds, ResolvedBounds } from './ScrollMagic';\n\n// less relevant enum types as literals\nexport type {\n\tEventTypeLiteral as EventType,\n\tEventLocationLiteral as EventLocation,\n\tScrollDirectionLiteral as ScrollDirection,\n};\n"
  },
  {
    "path": "src/util/agnosticValues.ts",
    "content": "import { transformObject } from './transformObject';\n\n// { agnosticProp: [verticalProp, horizontalProp] }\nconst translationMap = {\n\tstart: ['top', 'left'],\n\tend: ['bottom', 'right'],\n\tsize: ['height', 'width'],\n\tclientSize: ['clientHeight', 'clientWidth'],\n\tscrollSize: ['scrollHeight', 'scrollWidth'],\n\taxis: ['y', 'x'],\n} as const;\n\ntype TranslationMap = typeof translationMap;\ntype AgnosticProps = keyof TranslationMap;\ntype TranslateProp<K extends AgnosticProps, V extends boolean> = TranslationMap[K][V extends true ? 0 : 1];\ntype Vertical = { [K in AgnosticProps]: TranslateProp<K, true> };\ntype Horizontal = { [K in AgnosticProps]: TranslateProp<K, false> };\n\n// cache props\nconst flat = (index: number) => transformObject(translationMap, ([key, value]) => [key, value[index]]);\nconst propsV = flat(0) as Vertical;\nconst propsH = flat(1) as Horizontal;\n\n/**\n * Returns a map of direction-agnostic property names to their orientation-specific counterparts.\n * @param vertical - Scroll direction (`true` = vertical, `false` = horizontal).\n * @returns A mapping like `{ start: 'top', size: 'height', ... }` for vertical, or `{ start: 'left', size: 'width', ... }` for horizontal.\n */\nexport function agnosticProps(vertical: true): Vertical;\nexport function agnosticProps(vertical: false): Horizontal;\nexport function agnosticProps(vertical: boolean): Vertical | Horizontal;\nexport function agnosticProps(vertical: boolean): Vertical | Horizontal {\n\treturn vertical ? propsV : propsH;\n}\n\ntype MatchProp<K extends string, T extends Record<string, unknown>> = K extends keyof T ? T[K] : never;\ntype GetType<V extends boolean, T extends Record<string, unknown>> = {\n\t[K in AgnosticProps]: MatchProp<TranslateProp<K, V>, T>;\n};\n/**\n * Extracts direction-relevant values from an object using orientation-aware property names.\n *\n * For vertical: `top` → `start`, `height` → `size`, `y` → `axis`, etc.\n * For horizontal: `left` → `start`, `width` → `size`, `x` → `axis`, etc.\n *\n * @param vertical - Scroll direction (`true` = vertical, `false` = horizontal).\n * @param obj - Source object to extract values from (e.g. a `DOMRect` or scroll delta).\n * @returns An object with agnostic keys (`start`, `end`, `size`, `clientSize`, `scrollSize`, `axis`) mapped to the corresponding values.\n */\nexport const agnosticValues = <V extends boolean, T extends { [key: string]: any }>(\n\tvertical: V,\n\tobj: T\n): GetType<V, T> => transformObject(agnosticProps(vertical), ([key, value]) => [key, obj[value]]);\n"
  },
  {
    "path": "src/util/getScrollContainerDimensions.ts",
    "content": "import { isWindow } from './typeguards';\n\ninterface Dimensions {\n\treadonly clientWidth: number;\n\treadonly clientHeight: number;\n\treadonly scrollWidth: number;\n\treadonly scrollHeight: number;\n}\n\n// info limited to what we need...\nexport const getScrollContainerDimensions = (element: Window | Element): Dimensions => {\n\tconst elem = isWindow(element) ? document.documentElement : element;\n\tconst { clientWidth, scrollHeight, scrollWidth } = elem;\n\tlet { clientHeight } = elem;\n\tif (isWindow(element) && null != window.visualViewport) {\n\t\t// visualViewport.height accounts for mobile browser chrome (address bar show/hide)\n\t\t// multiplying by scale compensates for pinch-zoom, giving us the layout viewport height\n\t\tclientHeight = window.visualViewport.height * window.visualViewport.scale;\n\t}\n\treturn {\n\t\tclientWidth,\n\t\tclientHeight,\n\t\tscrollHeight,\n\t\tscrollWidth,\n\t};\n};\n"
  },
  {
    "path": "src/util/getScrollPos.ts",
    "content": "import { isWindow } from './typeguards';\n\nconst scrollTop = (container: Window | Element): number =>\n\tisWindow(container) ? (window.scrollY ?? window.pageYOffset) : container.scrollTop;\n\nconst scrollLeft = (container: Window | Element): number =>\n\tisWindow(container) ? (window.scrollX ?? window.pageXOffset) : container.scrollLeft;\n\nexport const getScrollPos = (container: Window | Element): { left: number; top: number } => ({\n\tleft: scrollLeft(container),\n\ttop: scrollTop(container),\n});\n"
  },
  {
    "path": "src/util/pickDifferencesFlat.ts",
    "content": "// checks an object against a reference object and returns a new object containing only differences in direct descendents (one way!)\nexport const pickDifferencesFlat = <T extends Record<string, any>>(part: Partial<T>, full: T): Partial<T> =>\n\tObject.fromEntries(Object.entries(part).filter(([key, value]) => value !== full[key])) as Partial<T>;\n"
  },
  {
    "path": "src/util/processProperties.ts",
    "content": "import { ScrollMagicError } from '../ScrollMagicError';\n\n// type to ensure there's an output processor for every input\nexport type PropertyProcessors<I extends { [X in keyof I]: unknown }, O extends { [X in keyof I]: unknown }> = {\n\t[X in keyof I]: (value: Required<I>[X]) => O[X];\n};\n\n/**\n * A function that can be used to validate the properties of an object based on predefined rules.\n * @param obj the object that should be processed\n * @param processors an object with matching keys, which defines how to normalize and or validate a property\n * @param getErrorMessage A function that returns the format for the error message, should normalize or check fail.\n * @returns the normalized and checked object\n */\n\nexport const processProperties = <\n\tI extends { [X in keyof I]: any },\n\tP extends { [X in K]?: (value: Required<I>[X]) => any },\n\tO extends { [X in K]: P[X] extends (...args: any[]) => infer R ? R : I[X] },\n\tK extends keyof I,\n>(\n\tobj: I,\n\tprocessors: P,\n\tgetErrorMessage: (value: unknown, prop: keyof I) => string = (value, prop) =>\n\t\t`Invalid value ${String(value)} for ${String(prop)}.`\n): O => {\n\treturn Object.keys(obj).reduce((result, key) => {\n\t\tconst prop = key as K;\n\t\tconst value = obj[prop];\n\t\tconst processor = processors[prop];\n\t\tlet processedValue: O[K];\n\t\ttry {\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- generic processor output is intentionally any\n\t\t\tprocessedValue = processor?.(value) ?? value;\n\t\t} catch (e: unknown) {\n\t\t\tconst reason = e instanceof ScrollMagicError ? ` ${e.message}` : '';\n\t\t\tthrow new ScrollMagicError(getErrorMessage(value, prop) + reason, { cause: e });\n\t\t}\n\t\tresult[prop] = processedValue;\n\t\treturn result;\n\t}, {} as O);\n};\n"
  },
  {
    "path": "src/util/rafQueue.ts",
    "content": "type Flushable = { execute(): void };\n\n/**\n * Batches execution of multiple Flushable items into a single requestAnimationFrame.\n *\n * Items marked dirty via `schedule()` are collected in a Set (deduped) and executed\n * together — either when the rAF fires or when `flush()` is called explicitly.\n *\n * In the hot path (scroll/resize), callers invoke `flush()` directly after dispatching\n * events, so all downstream work executes in the same frame. The rAF serves as a\n * fallback for work scheduled outside of event dispatch (e.g. initial setup, programmatic\n * option changes).\n */\nclass RafQueue {\n\tprivate dirty = new Set<Flushable>();\n\tprivate rafId = 0;\n\n\t/** Mark an item for execution. Requests a rAF if none is pending. */\n\tschedule(item: Flushable): void {\n\t\tthis.dirty.add(item);\n\t\tif (0 === this.rafId) {\n\t\t\tthis.rafId = requestAnimationFrame(() => {\n\t\t\t\tthis.rafId = 0;\n\t\t\t\tthis.flush();\n\t\t\t});\n\t\t}\n\t}\n\n\t/** Remove an item from the dirty set, preventing its execution. */\n\tunschedule(item: Flushable): void {\n\t\tthis.dirty.delete(item);\n\t}\n\n\t/** Execute all dirty items immediately and cancel any pending rAF. */\n\tflush(): void {\n\t\tif (0 !== this.rafId) {\n\t\t\tcancelAnimationFrame(this.rafId);\n\t\t\tthis.rafId = 0;\n\t\t}\n\t\tconst items = [...this.dirty];\n\t\tthis.dirty.clear();\n\t\titems.forEach(item => item.execute());\n\t}\n}\n\nexport const rafQueue = new RafQueue();\n"
  },
  {
    "path": "src/util/registerEvent.ts",
    "content": "/**\n * Adds the passed listener as an event listener to the passed event target, and returns a function which reverses the\n * effect of this function.\n * @param {*} target object the listener should be attached to\n * @param {*} type type of listener\n * @param {*} listener callback\n * @param {*} options Event listener options\n */\nexport const registerEvent = (\n\ttarget: GlobalEventHandlers,\n\ttype: keyof (GlobalEventHandlersEventMap & WindowEventMap), // this does not catch if the wrong event is used on the wrong target, but should be stricter than 'string'\n\tlistener: EventListenerOrEventListenerObject,\n\toptions?: boolean | AddEventListenerOptions\n): (() => void) => {\n\ttarget.addEventListener(type, listener, options);\n\treturn target.removeEventListener.bind(target, type, listener, options);\n};\n"
  },
  {
    "path": "src/util/sanitizeProperties.ts",
    "content": "export const sanitizeProperties = <T extends Record<string, any>>(obj: T, defaults: Record<string, any>): T =>\n\tObject.entries(obj).reduce((res, [key, value]) => {\n\t\tif (key in defaults === false) {\n\t\t\tif (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {\n\t\t\t\tconsole?.warn(`ScrollMagic Warning: Unknown property ${key} will be disregarded`);\n\t\t\t}\n\t\t\treturn res;\n\t\t}\n\t\t// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- value from Object.entries of generic Record\n\t\tres[key as keyof T] = value;\n\t\treturn res;\n\t}, {} as T);\n"
  },
  {
    "path": "src/util/sharedResizeObserver.ts",
    "content": "/**\n * Shared ResizeObserver — uses a single observer instance for all elements,\n * routing entries to per-element callback sets via a WeakMap.\n *\n * After all callbacks for a batch of entries have fired, the RafQueue is\n * flushed so downstream work (ExecutionQueues) executes in the same frame.\n *\n * Safe to call in non-browser environments — returns a no-op cleanup if\n * ResizeObserver is unavailable.\n */\nimport { rafQueue } from './rafQueue';\n\ntype ResizeCallback = () => void;\n\nconst callbacks = new WeakMap<Element, Set<ResizeCallback>>();\nlet observer: ResizeObserver | undefined; // undefined = not yet created, vs null from getObserver = unavailable\n\nconst handleResize: ResizeObserverCallback = entries => {\n\t// Collect all affected callbacks first, then fire — avoids issues if a\n\t// callback modifies the callback set (e.g. by calling observeResize/cleanup).\n\tconst affected = new Set<ResizeCallback>();\n\tfor (const entry of entries) {\n\t\tcallbacks.get(entry.target)?.forEach(cb => affected.add(cb));\n\t}\n\taffected.forEach(cb => cb());\n\trafQueue.flush();\n};\n\nconst noop = () => {};\n\n/** Returns the shared observer, or null if ResizeObserver is unavailable (SSR). */\nconst getObserver = (): ResizeObserver | null => {\n\tif ('undefined' === typeof ResizeObserver) return null;\n\tif (undefined === observer) {\n\t\tobserver = new ResizeObserver(handleResize);\n\t}\n\treturn observer;\n};\n\n/**\n * Observe an element for resize. Returns a cleanup function that removes the\n * callback and unobserves the element when no callbacks remain.\n */\nexport const observeResize = (element: Element, callback: ResizeCallback): (() => void) => {\n\tconst obs = getObserver();\n\tif (null === obs) return noop;\n\tlet cbs = callbacks.get(element);\n\tif (undefined === cbs) {\n\t\tcbs = new Set();\n\t\tcallbacks.set(element, cbs);\n\t\tobs.observe(element);\n\t}\n\tcbs.add(callback);\n\treturn () => {\n\t\tconst cbs = callbacks.get(element);\n\t\tif (undefined === cbs) return;\n\t\tcbs.delete(callback);\n\t\tif (0 === cbs.size) {\n\t\t\tcallbacks.delete(element);\n\t\t\tobserver?.unobserve(element);\n\t\t}\n\t};\n};\n"
  },
  {
    "path": "src/util/throttleRaf.ts",
    "content": "export const throttleRaf = <F extends (...a: unknown[]) => any>(\n\tfunc: F\n): ((this: ThisParameterType<F>, ...args: Parameters<F>) => void) & {\n\tcancel: () => void;\n} => {\n\tlet requestId = 0; // rAF returns non-zero values, so 0 represents no request pending\n\n\tconst scheduled = function (this: ThisParameterType<F>, ...args: Parameters<F>) {\n\t\tif (0 !== requestId) {\n\t\t\treturn;\n\t\t}\n\t\trequestId = requestAnimationFrame(() => {\n\t\t\trequestId = 0;\n\t\t\tfunc.apply(this, args);\n\t\t});\n\t};\n\n\tscheduled.cancel = () => {\n\t\tcancelAnimationFrame(requestId);\n\t\trequestId = 0;\n\t};\n\treturn scheduled;\n};\n"
  },
  {
    "path": "src/util/transformObject.ts",
    "content": "/**\n * Type-safe `Object.fromEntries(Object.entries(obj).map(fn))`.\n * The generics preserve key/value types through the transformation, avoiding manual casts at call sites.\n */\nexport function transformObject<\n\tT extends Record<string | number | symbol, unknown>,\n\tR extends [key: string | number | symbol, value: unknown],\n>(object: T, transform: (entry: [key: keyof T, value: T[keyof T]]) => R) {\n\treturn Object.fromEntries(\n\t\tObject.entries(object as Record<keyof T, T[keyof T]>).map(transform)\n\t) as Record<R[0], R[1]>;\n}\n"
  },
  {
    "path": "src/util/transformers.ts",
    "content": "import { positionShorthands } from '../Options';\nimport { ScrollMagicError } from '../ScrollMagicError';\nimport { isHTMLElement, isSVGElement, isWindow } from './typeguards';\n\ntype PixelConverter = (size: number) => number;\ntype UnitTuple = [number, 'px' | '%'];\n\nexport const numberToPercString = (val: number, decimals: number): string => `${(val * 100).toFixed(decimals)}%`;\n\nconst unitTupleToPixelConverter = ([value, unit]: UnitTuple): PixelConverter => {\n\treturn 'px' === unit || 0 === value ? () => value : (size: number) => (value / 100) * size;\n};\n\nexport const unitStringToPixelConverter = (val: string): PixelConverter => {\n\tconst match = val.match(/^([+-])?(\\d+|\\d*[.]\\d+)(%|px)$/);\n\tif (null === match) {\n\t\tconst names = Object.keys(positionShorthands).join(', ');\n\t\tthrow new ScrollMagicError(\n\t\t\t`String value must be a number with unit (e.g. 20px, 80%) or a named position (${names})`\n\t\t);\n\t}\n\tconst [, sign, digits, unit] = match as [string, '+' | '-' | null, string, 'px' | '%'];\n\treturn unitTupleToPixelConverter([parseFloat(`${sign ?? ''}${digits}`), unit]);\n};\n\nexport const toPixelConverter = (val: number | string | PixelConverter): PixelConverter => {\n\tif ('number' === typeof val) {\n\t\treturn () => val;\n\t}\n\tif ('string' === typeof val) {\n\t\tconst unitString = val in positionShorthands ? positionShorthands[val as keyof typeof positionShorthands] : val;\n\t\treturn unitStringToPixelConverter(unitString);\n\t}\n\t// ok, user passed in a function, let's see if it works.\n\tlet returnsNumber: boolean;\n\ttry {\n\t\treturnsNumber = 'number' === typeof val(1);\n\t} catch {\n\t\tthrow new ScrollMagicError('Unsupported value type');\n\t}\n\tif (!returnsNumber) {\n\t\tthrow new ScrollMagicError('Function must return a number');\n\t}\n\treturn val;\n};\n\nexport const selectorToSingleElement = (selector: string): Element => {\n\tconst elem = document.querySelector(selector);\n\tif (null === elem) {\n\t\tthrow new ScrollMagicError(`No element found for selector ${selector}`);\n\t}\n\tif (typeof process === 'undefined' || process.env.NODE_ENV !== 'production') {\n\t\tconst all = document.querySelectorAll(selector);\n\t\tif (all.length > 1) {\n\t\t\tconsole?.warn(\n\t\t\t\t`ScrollMagic Warning: Selector \"${selector}\" matched ${all.length} elements, using only the first. Create one ScrollMagic instance per element to track all of them.`\n\t\t\t);\n\t\t}\n\t}\n\treturn elem;\n};\n\nexport const toSvgOrHtmlElement = (reference: Element | string): HTMLElement | SVGElement => {\n\tconst elem = 'string' === typeof reference ? selectorToSingleElement(reference) : reference;\n\tconst { body } = document;\n\tif (!(isHTMLElement(elem) || isSVGElement(elem)) || !body.contains(elem)) {\n\t\tthrow new ScrollMagicError('Invalid element supplied');\n\t}\n\treturn elem;\n};\n\nexport const toValidContainer = (container: Window | Element | string): HTMLElement | Window => {\n\tif (isWindow(container)) {\n\t\treturn container;\n\t}\n\tconst elem = toSvgOrHtmlElement(container);\n\tif (isSVGElement(elem)) {\n\t\tthrow new ScrollMagicError(`Can't use SVG as container`);\n\t}\n\treturn elem;\n};\n\n/** Wraps a function to pass `null` through without calling it. */\nexport const skipNull =\n\t<T, R>(fn: (val: T) => R) =>\n\t(val: T | null): R | null =>\n\t\tnull === val ? null : fn(val);\n"
  },
  {
    "path": "src/util/typeguards.ts",
    "content": "export const isWindow = (val: unknown): val is Window => val instanceof Window;\nexport const isHTMLElement = (val: unknown): val is HTMLElement => val instanceof HTMLElement;\nexport const isSVGElement = (val: unknown): val is SVGElement => val instanceof SVGElement;\n"
  },
  {
    "path": "src/util.ts",
    "content": "export { agnosticProps, agnosticValues } from './util/agnosticValues';\n"
  },
  {
    "path": "tests/e2e/UNTESTED-KNOWN-BUGS.md",
    "content": "# Untested Known Bugs\n\nDocumentation for potential v3 issues derived from v2 bug reports that cannot be covered by automated e2e tests.\nTestable cases are covered in `reported-issues.test.ts`.\n\n## Require Real Mobile Devices\n\n| Issue | Concern                                                                                 | Why untestable                                                                     |\n| ----- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |\n| #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 |\n| #479  | iOS momentum scrolling + toolbar resize compounding viewport measurement issues         | Same as #789 — real iOS Safari + physical momentum scrolling required              |\n| #381  | Android virtual keyboard popup resizes viewport, may cause unexpected IO recalculations | Can't simulate keyboard appearance in headless/automated browsers                  |\n\n## Require External Libraries or Specific Browser Features\n\n| Issue | Concern                                                                                                      | Why untestable                                                                                                                                        |\n| ----- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| #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. |\n| #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.            |\n\n## Not Assertable in Automated Tests\n\n| Issue | Concern                                                                                                                 | Why untestable                                                                                                                                  |\n| ----- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |\n| #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`. |\n| #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.                                                      |\n"
  },
  {
    "path": "tests/e2e/caching.test.ts",
    "content": "/**\n * PixelConverter caching and bounds invalidation.\n * Tests for: converters not re-called on scroll (only on resize), modify() forcing recalculation,\n * direction changes invalidating caches, stale containerBoundsCache after container option changes.\n */\nimport { describe, test, expect, afterEach } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport { cleanup, setupWindow, wait, waitForFrames } from './helpers';\n\ndescribe('PixelConverter caching', () => {\n\tafterEach(cleanup);\n\n\ttest('elementStart/elementEnd are not called on scroll when element size is unchanged', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// elementTop=300, height=200 — element is visible initially, stays intersecting as we scroll\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\tlet elementStartCalls = 0;\n\t\tlet elementEndCalls = 0;\n\t\tconst sm = new ScrollMagic({\n\t\t\telement: target,\n\t\t\telementStart: () => {\n\t\t\t\telementStartCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t\telementEnd: () => {\n\t\t\t\telementEndCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t});\n\n\t\tawait waitForFrames();\n\t\tconst callsAfterInit = elementStartCalls + elementEndCalls;\n\n\t\t// scroll while element remains intersecting (no resize)\n\t\twindow.scrollTo(0, 100);\n\t\tawait waitForFrames();\n\t\twindow.scrollTo(0, 200);\n\t\tawait waitForFrames();\n\t\twindow.scrollTo(0, 250);\n\t\tawait waitForFrames();\n\n\t\texpect(elementStartCalls + elementEndCalls).toBe(callsAfterInit);\n\t\tsm.destroy();\n\t});\n\n\ttest('elementStart/elementEnd are called when element resizes', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\tlet elementStartCalls = 0;\n\t\tconst sm = new ScrollMagic({\n\t\t\telement: target,\n\t\t\telementStart: () => {\n\t\t\t\telementStartCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t});\n\n\t\tawait waitForFrames();\n\t\tconst callsAfterInit = elementStartCalls;\n\n\t\ttarget.style.height = '400px';\n\t\tawait waitForFrames();\n\n\t\texpect(elementStartCalls).toBeGreaterThan(callsAfterInit);\n\t\tsm.destroy();\n\t});\n\n\ttest('containerStart/containerEnd are not called on scroll', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// element taller than viewport so containerStart/End returning 0 doesn't cause a no-overlap warning\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 900 });\n\n\t\tlet containerStartCalls = 0;\n\t\tlet containerEndCalls = 0;\n\t\tconst sm = new ScrollMagic({\n\t\t\telement: target,\n\t\t\tcontainerStart: () => {\n\t\t\t\tcontainerStartCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t\tcontainerEnd: () => {\n\t\t\t\tcontainerEndCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t});\n\n\t\tawait waitForFrames();\n\t\tconst callsAfterInit = containerStartCalls + containerEndCalls;\n\n\t\twindow.scrollTo(0, 100);\n\t\tawait waitForFrames();\n\t\twindow.scrollTo(0, 200);\n\t\tawait waitForFrames();\n\t\twindow.scrollTo(0, 300);\n\t\tawait waitForFrames();\n\n\t\texpect(containerStartCalls + containerEndCalls).toBe(callsAfterInit);\n\t\tsm.destroy();\n\t});\n\n\ttest('containerStart/containerEnd are called when container resizes', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300 });\n\n\t\tlet containerStartCalls = 0;\n\t\tconst sm = new ScrollMagic({\n\t\t\telement: target,\n\t\t\tcontainerStart: () => {\n\t\t\t\tcontainerStartCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t});\n\n\t\tawait waitForFrames();\n\t\tconst callsAfterInit = containerStartCalls;\n\n\t\tawait page.viewport(1024, 500);\n\t\tawait wait(50); // give ResizeObserver a moment to fire\n\t\tawait waitForFrames();\n\n\t\texpect(containerStartCalls).toBeGreaterThan(callsAfterInit);\n\t\tsm.destroy();\n\t});\n\n\ttest('elementStart/elementEnd are re-called after modify() even if element size is unchanged', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames();\n\n\t\tlet newConverterCalls = 0;\n\t\tsm.modify({\n\t\t\telementStart: size => {\n\t\t\t\tnewConverterCalls++;\n\t\t\t\treturn size * 0.1;\n\t\t\t},\n\t\t});\n\t\tawait waitForFrames();\n\n\t\texpect(newConverterCalls).toBeGreaterThan(0);\n\t\tsm.destroy();\n\t});\n\n\ttest('elementBounds are recalculated when direction changes via modify()', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\tlet elementStartCalls = 0;\n\t\tconst sm = new ScrollMagic({\n\t\t\telement: target,\n\t\t\telementStart: () => {\n\t\t\t\telementStartCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t});\n\n\t\tawait waitForFrames();\n\t\tconst callsAfterInit = elementStartCalls;\n\n\t\tsm.modify({ vertical: false });\n\t\tawait waitForFrames();\n\n\t\texpect(elementStartCalls).toBeGreaterThan(callsAfterInit);\n\t\tsm.destroy();\n\t});\n\n\ttest('containerBounds are recalculated when direction changes via modify()', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\tlet containerStartCalls = 0;\n\t\tconst sm = new ScrollMagic({\n\t\t\telement: target,\n\t\t\tcontainerStart: () => {\n\t\t\t\tcontainerStartCalls++;\n\t\t\t\treturn 0;\n\t\t\t},\n\t\t});\n\n\t\tawait waitForFrames();\n\t\tconst callsAfterInit = containerStartCalls;\n\n\t\tsm.modify({ vertical: false });\n\t\tawait waitForFrames();\n\n\t\texpect(containerStartCalls).toBeGreaterThan(callsAfterInit);\n\t\tsm.destroy();\n\t});\n\n\ttest('containerStart/containerEnd take effect after modify() — stale containerBoundsCache', async () => {\n\t\t// Bug: containerBounds was not rescheduled when container options changed via modify(),\n\t\t// leaving stale offsetStart/offsetEnd in the cache.\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\tconst sm = new ScrollMagic({ element: target, containerStart: '0%' });\n\t\twindow.scrollTo(0, 200);\n\t\tawait waitForFrames();\n\t\tconst progressBefore = sm.progress;\n\n\t\tsm.modify({ containerStart: '50%' });\n\t\tawait waitForFrames();\n\t\tconst progressAfter = sm.progress;\n\n\t\texpect(progressAfter).not.toBe(progressBefore);\n\t\tsm.destroy();\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/containers.test.ts",
    "content": "/**\n * Scroll container behavior: non-window scroll parents, container edge cases, viewport resize.\n * Tests for: div containers, position:fixed containers, container position initialization,\n * zero-size containers, viewport resize (mobile address bar).\n */\nimport { describe, test, expect, afterEach } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport type { ScrollMagicEvent } from '../../src/index';\nimport { cleanup, setupContainer, setupWindow, wait, waitForFrames } from './helpers';\n\n// #905, #1004: Non-window scroll containers with overflow:scroll.\ndescribe('non-window scroll containers', () => {\n\tafterEach(cleanup);\n\n\ttest('enter/leave/progress events fire in scrollable div container', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { container, target } = setupContainer();\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target, container });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\tcontainer.scrollTop = 700;\n\t\tawait waitForFrames(3);\n\t\texpect(events).toContain('enter');\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\tcontainer.scrollTop = 1500;\n\t\tawait waitForFrames(3);\n\t\texpect(events).toContain('leave');\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('scroll direction is correct in non-window container', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { container, target } = setupContainer();\n\n\t\tconst directions: Array<{ type: string; direction: string }> = [];\n\t\tconst sm = new ScrollMagic({ element: target, container });\n\t\tsm.on('enter', (e: ScrollMagicEvent) => directions.push({ type: 'enter', direction: e.direction }));\n\t\tsm.on('leave', (e: ScrollMagicEvent) => directions.push({ type: 'leave', direction: e.direction }));\n\n\t\t// Scroll forward past element\n\t\tcontainer.scrollTop = 1500;\n\t\tawait waitForFrames(3);\n\n\t\tconst forwardEnter = directions.find(d => 'enter' === d.type && 'forward' === d.direction);\n\t\tconst forwardLeave = directions.find(d => 'leave' === d.type && 'forward' === d.direction);\n\t\texpect(forwardEnter).toBeDefined();\n\t\texpect(forwardLeave).toBeDefined();\n\n\t\tdirections.length = 0;\n\n\t\t// Scroll backward past element\n\t\tcontainer.scrollTop = 0;\n\t\tawait waitForFrames(3);\n\n\t\tconst reverseEnter = directions.find(d => 'enter' === d.type && 'reverse' === d.direction);\n\t\tconst reverseLeave = directions.find(d => 'leave' === d.type && 'reverse' === d.direction);\n\t\texpect(reverseEnter).toBeDefined();\n\t\texpect(reverseLeave).toBeDefined();\n\n\t\tsm.destroy();\n\t});\n\n\t// #905: position:fixed containers as IO root.\n\ttest('works with position:fixed scroll container', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { container, target } = setupContainer({\n\t\t\tcontainerCss: {\n\t\t\t\tposition: 'fixed',\n\t\t\t\ttop: '0',\n\t\t\t\tleft: '0',\n\t\t\t\twidth: '100%',\n\t\t\t},\n\t\t});\n\n\t\tconst sm = new ScrollMagic({ element: target, container });\n\n\t\tcontainer.scrollTop = 700;\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\tcontainer.scrollTop = 1500;\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tcontainer.scrollTop = 0;\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0);\n\n\t\tsm.destroy();\n\t});\n});\n\n// positionCache for non-window containers was initialized to {top:0,left:0} and only updated on\n// window scroll/resize events (via subscribeMove). For a container offset from the viewport top,\n// the initial progress calculation used containerPosition=0 instead of the actual position.\ndescribe('container position initialization', () => {\n\tafterEach(cleanup);\n\n\ttest('correct initial progress when container is offset from viewport top', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tdocument.body.style.margin = '0';\n\t\tdocument.body.style.padding = '0';\n\n\t\t// Push the container down so it's not at y=0\n\t\tconst spacer = document.createElement('div');\n\t\tspacer.style.height = '300px';\n\t\tdocument.body.appendChild(spacer);\n\n\t\t// Container is now at y=300, height=400; element at contentTop=800, height=100\n\t\tconst { container, target } = setupContainer({ elementTop: 800, elementHeight: 100 });\n\n\t\tconst sm = new ScrollMagic({ element: target, container });\n\t\tawait waitForFrames(3); // let initialization settle\n\n\t\t// Scroll without triggering any window scroll/resize (which would fix positionCache via subscribeMove)\n\t\tcontainer.scrollTop = 600;\n\t\tawait waitForFrames(5);\n\n\t\t// With fix: containerPosition=300, containerStart=700, elementStart=500, passed=200, progress=0.4\n\t\t// Without fix: containerPosition=0, containerStart=400, elementStart=500, passed=-100, progress=0\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\tsm.destroy();\n\t});\n});\n\n// When container clientSize is 0 (hidden/collapsed), updateProgress() would compute wrong values:\n// containerOffset collapsed to 0 and the calculation produced a different (incorrect) progress,\n// firing spurious events. updateViewportObserver() also passed broken 0% margins to the observer.\ndescribe('zero-size scroll container', () => {\n\tafterEach(cleanup);\n\n\ttest('no events fire and progress stays frozen when container height becomes zero', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { container, target } = setupContainer({ elementTop: 800, elementHeight: 100 });\n\n\t\tconst sm = new ScrollMagic({ element: target, container });\n\t\tawait waitForFrames(3);\n\n\t\tcontainer.scrollTop = 850;\n\t\tawait waitForFrames(5);\n\n\t\tconst progressBefore = sm.progress;\n\t\texpect(progressBefore).toBeGreaterThan(0); // sanity check\n\n\t\tconst events: string[] = [];\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('leave', () => events.push('leave'));\n\t\tsm.on('progress', () => events.push('progress'));\n\n\t\t// Collapse the container\n\t\tcontainer.style.height = '0px';\n\t\tawait wait(50); // allow ResizeObserver to fire\n\t\tawait waitForFrames(5);\n\n\t\t// Without fix: updateProgress() ran with containerSize=0, computed a different value\n\t\t// and fired a spurious 'progress' event.\n\t\texpect(Number.isNaN(sm.progress)).toBe(false);\n\t\texpect(isFinite(sm.progress)).toBe(true);\n\t\texpect(events).toHaveLength(0);\n\t\texpect(sm.progress).toBe(progressBefore);\n\n\t\tsm.destroy();\n\t});\n});\n\n// #883, #372: Mobile address bar show/hide changes viewport dimensions.\n// Tested by shrinking the viewport programmatically (simulates address bar appearing).\ndescribe('viewport resize', () => {\n\tafterEach(cleanup);\n\n\ttest('progress updates after viewport height change', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 600, elementHeight: 200 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Scroll so element is partially in view\n\t\twindow.scrollTo(0, 500);\n\t\tawait waitForFrames(3);\n\t\tconst progressBefore = sm.progress;\n\t\texpect(progressBefore).toBeGreaterThan(0);\n\t\texpect(progressBefore).toBeLessThan(1);\n\n\t\t// Shrink viewport (simulates mobile address bar appearing)\n\t\tawait page.viewport(1024, 400);\n\t\tawait wait(50); // give ResizeObserver / window resize event a moment to fire\n\t\tawait waitForFrames(5);\n\n\t\t// Progress should have changed since viewport size affects the tracking calculation\n\t\texpect(sm.progress).not.toBe(progressBefore);\n\n\t\tsm.destroy();\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/destroy.test.ts",
    "content": "import { describe, test, expect, afterEach, vi } from 'vitest';\nimport ScrollMagic from '../../src/index';\nimport { cleanup, setupWindow } from './helpers';\n\nafterEach(() => {\n\tcleanup();\n\tvi.restoreAllMocks();\n});\n\ndescribe('destroy: idempotency', () => {\n\ttest('calling destroy twice does not warn', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tsm.destroy();\n\t\tsm.destroy();\n\n\t\texpect(warnSpy).not.toHaveBeenCalled();\n\t});\n});\n\ndescribe('destroy: post-destroy dev warnings', () => {\n\tconst makeScene = () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\treturn { sm, target };\n\t};\n\n\ttest('modify() warns', () => {\n\t\tconst { sm, target } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.modify({ element: target });\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('refresh() warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.refresh();\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('addPlugin() warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.addPlugin({ name: 'test' });\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('removePlugin() warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.removePlugin({ name: 'test' });\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('on() warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.on('progress', () => {});\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('off() warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.off('progress', () => {});\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('subscribe() warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.subscribe('progress', () => {});\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n\n\ttest('activeRange warns', () => {\n\t\tconst { sm } = makeScene();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tvoid sm.activeRange;\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n});\n\ndescribe('destroy: plugin cleanup', () => {\n\ttest('destroy() calls onDestroy (not onRemove) on all plugins', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst plugin1 = { name: 'p1', onRemove: vi.fn(), onDestroy: vi.fn() };\n\t\tconst plugin2 = { name: 'p2', onRemove: vi.fn(), onDestroy: vi.fn() };\n\t\tsm.addPlugin(plugin1);\n\t\tsm.addPlugin(plugin2);\n\n\t\tsm.destroy();\n\n\t\texpect(plugin1.onDestroy).toHaveBeenCalledOnce();\n\t\texpect(plugin2.onDestroy).toHaveBeenCalledOnce();\n\t\texpect(plugin1.onRemove).not.toHaveBeenCalled();\n\t\texpect(plugin2.onRemove).not.toHaveBeenCalled();\n\t\texpect(sm.pluginList).toHaveLength(0);\n\t});\n\n\ttest('destroy() calls onDisable before onDestroy (when enabled)', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst order: string[] = [];\n\t\tconst plugin = {\n\t\t\tname: 'order-test',\n\t\t\tonDisable: vi.fn(() => order.push('disable')),\n\t\t\tonDestroy: vi.fn(() => order.push('destroy')),\n\t\t};\n\t\tsm.addPlugin(plugin);\n\n\t\tsm.destroy();\n\n\t\texpect(order).toEqual(['disable', 'destroy']);\n\t});\n\n\ttest('destroy() skips onDisable when already disabled', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst plugin = {\n\t\t\tname: 'test',\n\t\t\tonDisable: vi.fn(),\n\t\t\tonDestroy: vi.fn(),\n\t\t};\n\t\tsm.addPlugin(plugin);\n\n\t\tsm.disable();\n\t\tplugin.onDisable.mockClear(); // reset from the explicit disable() call\n\n\t\tsm.destroy();\n\n\t\texpect(plugin.onDisable).not.toHaveBeenCalled();\n\t\texpect(plugin.onDestroy).toHaveBeenCalledOnce();\n\t});\n});\n\ndescribe('destroy: post-destroy no-op behaviour', () => {\n\ttest('modify() does not change options', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst otherElement = document.createElement('div');\n\t\tdocument.body.appendChild(otherElement);\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tsm.modify({ element: otherElement });\n\t\texpect(sm.element).toBe(target);\n\t});\n\n\ttest('addPlugin() does not register the plugin', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tconst plugin = { name: 'test', onAdd: vi.fn() };\n\t\tsm.addPlugin(plugin);\n\n\t\texpect(plugin.onAdd).not.toHaveBeenCalled();\n\t\texpect(sm.pluginList).toHaveLength(0);\n\t});\n\n\ttest('subscribe() returns a no-op cleanup function', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tconst unsub = sm.subscribe('progress', () => {});\n\t\texpect(unsub).toBeTypeOf('function');\n\t\texpect(() => unsub()).not.toThrow();\n\t});\n\n\ttest('activeRange returns { start: 0, end: 0 }', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\texpect(sm.activeRange).toEqual({ start: 0, end: 0 });\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/dev-warnings.test.ts",
    "content": "import { describe, test, expect, afterEach, vi } from 'vitest';\nimport ScrollMagic from '../../src/index';\nimport { cleanup } from './helpers';\n\nafterEach(() => {\n\tcleanup();\n\tvi.restoreAllMocks();\n});\n\ndescribe('Dev warnings: element / container relationship', () => {\n\ttest('logs error when element is not a descendant of container', () => {\n\t\tconst container = document.createElement('div');\n\t\tcontainer.style.height = '400px';\n\t\tcontainer.style.overflow = 'auto';\n\t\tdocument.body.appendChild(container);\n\n\t\tconst outsideElement = document.createElement('div');\n\t\toutsideElement.style.height = '100px';\n\t\tdocument.body.appendChild(outsideElement);\n\n\t\tconst errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tconst sm = new ScrollMagic({ element: outsideElement, container });\n\t\tsm.destroy(); // destroy before rAF fires to prevent IntersectionObserver errors from invalid config\n\n\t\texpect(errorSpy).toHaveBeenCalledOnce();\n\t\texpect(errorSpy.mock.calls[0][0]).toContain('not a descendant');\n\t});\n\n\ttest('does not log error when element is a descendant of container', () => {\n\t\tconst container = document.createElement('div');\n\t\tcontainer.style.height = '400px';\n\t\tcontainer.style.overflow = 'auto';\n\n\t\tconst innerElement = document.createElement('div');\n\t\tinnerElement.style.height = '100px';\n\t\tcontainer.appendChild(innerElement);\n\t\tdocument.body.appendChild(container);\n\n\t\tconst errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tconst sm = new ScrollMagic({ element: innerElement, container });\n\t\tsm.destroy();\n\n\t\texpect(errorSpy).not.toHaveBeenCalled();\n\t});\n\n\ttest('does not log error when container is window (default)', () => {\n\t\tconst element = document.createElement('div');\n\t\telement.style.height = '100px';\n\t\tdocument.body.appendChild(element);\n\n\t\tconst errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tconst sm = new ScrollMagic({ element });\n\t\tsm.destroy();\n\n\t\texpect(errorSpy).not.toHaveBeenCalled();\n\t});\n\n\ttest('logs error when container is updated via modify and element is not a descendant', () => {\n\t\tconst container = document.createElement('div');\n\t\tcontainer.style.height = '400px';\n\t\tcontainer.style.overflow = 'auto';\n\t\tdocument.body.appendChild(container);\n\n\t\tconst outsideElement = document.createElement('div');\n\t\toutsideElement.style.height = '100px';\n\t\tdocument.body.appendChild(outsideElement);\n\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\n\t\tconst sm = new ScrollMagic({ element: outsideElement }); // window — ok\n\n\t\tconst errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\t\tsm.modify({ container }); // now non-descendant\n\t\tsm.destroy();\n\n\t\texpect(errorSpy).toHaveBeenCalledOnce();\n\t\texpect(errorSpy.mock.calls[0][0]).toContain('not a descendant');\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/element-tracking.test.ts",
    "content": "/**\n * Element tracking edge cases: resize, DOM mutations, SVG elements.\n * Tests for: layout shifts from element resize, content removal above element, SVG as tracked element.\n */\nimport { describe, test, expect, afterEach } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport { cleanup, setupWindow, wait, waitForFrames } from './helpers';\n\n// #986: Lazy-loaded images changing layout may not trigger ResizeObserver or bounds recalculation in time.\n// Tested by resizing the tracked element after initial scroll (simulates lazy image load).\ndescribe('element resize / layout shifts', () => {\n\tafterEach(cleanup);\n\n\ttest('progress recalculates when tracked element changes size', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Scroll and wait for IO to fire and set intersecting=true\n\t\twindow.scrollTo(0, 200);\n\t\tawait waitForFrames(3);\n\t\tconst progressBefore = sm.progress;\n\t\texpect(progressBefore).toBeGreaterThan(0);\n\n\t\t// Simulate lazy image loading — element grows taller\n\t\ttarget.style.height = '600px';\n\t\t// ResizeObserver fires before next paint; wait generously for it + scheduler flush\n\t\tawait wait(500);\n\t\tawait waitForFrames(5);\n\n\t\t// Track size changed, so progress should differ\n\t\texpect(sm.progress).not.toBe(progressBefore);\n\n\t\tsm.destroy();\n\t});\n});\n\n// #911: Major DOM mutations (removing large nodes) while scrolled could cause scroll position drift.\n// Tested by removing a large block above the tracked element mid-scroll.\ndescribe('DOM mutation', () => {\n\tafterEach(cleanup);\n\n\ttest('handles removal of content above tracked element', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tdocument.body.style.margin = '0';\n\t\tdocument.body.style.padding = '0';\n\n\t\tconst wrapper = document.createElement('div');\n\t\twrapper.style.position = 'relative';\n\n\t\tconst aboveBlock = document.createElement('div');\n\t\taboveBlock.style.height = '800px';\n\n\t\tconst target = document.createElement('div');\n\t\ttarget.style.height = '200px';\n\t\ttarget.style.width = '100%';\n\t\ttarget.style.background = 'red';\n\n\t\tconst belowBlock = document.createElement('div');\n\t\tbelowBlock.style.height = '2000px';\n\n\t\twrapper.appendChild(aboveBlock);\n\t\twrapper.appendChild(target);\n\t\twrapper.appendChild(belowBlock);\n\t\tdocument.body.appendChild(wrapper);\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Scroll to see the target\n\t\twindow.scrollTo(0, 500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\t// Remove the 800px block above — target shifts up in layout\n\t\taboveBlock.remove();\n\t\tawait waitForFrames(5);\n\n\t\t// Should not crash; progress should be a valid number\n\t\texpect(sm.progress).toBeGreaterThanOrEqual(0);\n\t\texpect(sm.progress).toBeLessThanOrEqual(1);\n\t\texpect(Number.isNaN(sm.progress)).toBe(false);\n\n\t\tsm.destroy();\n\t});\n});\n\n// #618, #460: SVG elements may report incorrect bounding boxes in Firefox.\n// SVG child elements (<path>, <g>, <use>) as IO targets may behave unexpectedly.\ndescribe('SVG elements', () => {\n\tafterEach(cleanup);\n\n\ttest('tracks an SVG element', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tdocument.body.style.margin = '0';\n\t\tdocument.body.style.padding = '0';\n\n\t\tconst spacer = document.createElement('div');\n\t\tspacer.style.height = '3000px';\n\t\tspacer.style.position = 'relative';\n\n\t\tconst svgNS = 'http://www.w3.org/2000/svg';\n\t\tconst svg = document.createElementNS(svgNS, 'svg');\n\t\tsvg.setAttribute('width', '200');\n\t\tsvg.setAttribute('height', '200');\n\t\tsvg.style.position = 'absolute';\n\t\tsvg.style.top = '1000px';\n\n\t\tconst rect = document.createElementNS(svgNS, 'rect');\n\t\trect.setAttribute('width', '200');\n\t\trect.setAttribute('height', '200');\n\t\trect.setAttribute('fill', 'blue');\n\t\tsvg.appendChild(rect);\n\n\t\tspacer.appendChild(svg);\n\t\tdocument.body.appendChild(spacer);\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: svg });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\t// Scroll into view\n\t\twindow.scrollTo(0, 800);\n\t\tawait waitForFrames(3);\n\t\texpect(events).toContain('enter');\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\t// Scroll past\n\t\twindow.scrollTo(0, 1500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/enable-disable.test.ts",
    "content": "/**\n * Enable/disable: pause and resume tracking without destroying.\n * Tests for: state & getters, method guards, idempotency, chaining,\n * tracking pause/resume, event suppression, modify-while-disabled.\n */\nimport { describe, test, expect, afterEach, vi } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport { cleanup, setupWindow, waitForFrames } from './helpers';\n\ndescribe('enable/disable: state & guards', () => {\n\tafterEach(() => {\n\t\tcleanup();\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('disabled is false by default', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\texpect(sm.disabled).toBe(false);\n\t\tsm.destroy();\n\t});\n\n\ttest('disabled is true after disable(), false after enable()', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.disable();\n\t\texpect(sm.disabled).toBe(true);\n\t\tsm.enable();\n\t\texpect(sm.disabled).toBe(false);\n\t\tsm.destroy();\n\t});\n\n\ttest('disable() and enable() return the instance (chaining)', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\texpect(sm.disable()).toBe(sm);\n\t\texpect(sm.enable()).toBe(sm);\n\t\tsm.destroy();\n\t});\n\n\ttest('double disable() does not throw', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.disable();\n\t\texpect(() => sm.disable()).not.toThrow();\n\t\tsm.destroy();\n\t});\n\n\ttest('double enable() does not throw', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\texpect(() => sm.enable()).not.toThrow();\n\t\tsm.destroy();\n\t});\n\n\ttest('modify() works when disabled (updates options)', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.disable();\n\t\tsm.modify({ containerStart: 0.5 });\n\t\texpect(sm.containerStart).toBe(0.5);\n\t\tsm.destroy();\n\t});\n\n\ttest('on()/off()/subscribe() work when disabled', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.disable();\n\t\tconst handler = () => {};\n\t\texpect(() => sm.on('progress', handler)).not.toThrow();\n\t\texpect(() => sm.off('progress', handler)).not.toThrow();\n\t\tconst unsub = sm.subscribe('progress', handler);\n\t\texpect(typeof unsub).toBe('function');\n\t\tsm.destroy();\n\t});\n\n\ttest('addPlugin()/removePlugin() work when disabled', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.disable();\n\t\tconst plugin = { name: 'test', onAdd: vi.fn(), onRemove: vi.fn() };\n\t\tsm.addPlugin(plugin);\n\t\texpect(plugin.onAdd).toHaveBeenCalledOnce();\n\t\texpect(sm.pluginList).toHaveLength(1);\n\t\tsm.removePlugin(plugin);\n\t\texpect(plugin.onRemove).toHaveBeenCalledOnce();\n\t\texpect(sm.pluginList).toHaveLength(0);\n\t\tsm.destroy();\n\t});\n\n\ttest('disable() calls onDisable on all plugins', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst plugin = { name: 'test', onDisable: vi.fn() };\n\t\tsm.addPlugin(plugin);\n\n\t\tsm.disable();\n\t\texpect(plugin.onDisable).toHaveBeenCalledOnce();\n\t\tsm.destroy();\n\t});\n\n\ttest('enable() calls onEnable on all plugins', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst plugin = { name: 'test', onEnable: vi.fn() };\n\t\tsm.addPlugin(plugin);\n\n\t\tsm.disable();\n\t\tsm.enable();\n\t\texpect(plugin.onEnable).toHaveBeenCalledOnce();\n\t\tsm.destroy();\n\t});\n\n\ttest('onDisable/onEnable are not called on idempotent calls', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst plugin = { name: 'test', onEnable: vi.fn(), onDisable: vi.fn() };\n\t\tsm.addPlugin(plugin);\n\n\t\t// already enabled — enable() should no-op\n\t\tsm.enable();\n\t\texpect(plugin.onEnable).not.toHaveBeenCalled();\n\n\t\tsm.disable();\n\t\texpect(plugin.onDisable).toHaveBeenCalledOnce();\n\n\t\t// already disabled — disable() should no-op\n\t\tsm.disable();\n\t\texpect(plugin.onDisable).toHaveBeenCalledOnce(); // still just once\n\n\t\tsm.destroy();\n\t});\n\n\ttest('progress getter returns last known value when disabled', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.disable();\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('activeRange still works when disabled', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\tawait waitForFrames(3);\n\t\tconst offsetBefore = sm.activeRange;\n\n\t\tsm.disable();\n\t\tconst offsetWhileDisabled = sm.activeRange;\n\n\t\texpect(offsetWhileDisabled).toEqual(offsetBefore);\n\t\tsm.destroy();\n\t});\n\n\ttest('refresh() is a no-op when disabled (no errors)', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.disable();\n\t\texpect(() => sm.refresh()).not.toThrow();\n\t\tsm.destroy();\n\t});\n\n\ttest('destroy() fully tears down a disabled instance', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\n\t\tsm.disable();\n\t\tsm.destroy();\n\n\t\t// re-enable should be impossible (destroyed)\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.enable();\n\t\texpect(sm.disabled).toBe(true); // still disabled — enable was a no-op\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toHaveLength(0);\n\t});\n\n\ttest('disabled is true after destroy() (without prior disable())', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\texpect(sm.disabled).toBe(true);\n\t});\n\n\ttest('enable() after destroy() warns and no-ops', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.enable();\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t\texpect(sm.disabled).toBe(true);\n\t});\n\n\ttest('disable() after destroy() warns and no-ops', () => {\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.destroy();\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsm.disable();\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy.mock.calls[0][0]).toContain('destroyed');\n\t});\n});\n\ndescribe('enable/disable: tracking behavior', () => {\n\tafterEach(cleanup);\n\n\ttest('disable() stops events — scroll after disable fires nothing', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\tsm.disable();\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toHaveLength(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('disable() freezes progress at pre-disable value', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.disable();\n\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('enable() resumes tracking — progress updates after re-enable', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\tsm.disable();\n\n\t\t// Scroll past while disabled\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0); // frozen at initial value\n\n\t\tsm.enable();\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('enable() fires events after resuming', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\tsm.disable();\n\n\t\t// Scroll past while disabled\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\tconst events: string[] = [];\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\tsm.enable();\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toContain('enter');\n\t\texpect(events).toContain('progress');\n\n\t\tsm.destroy();\n\t});\n\n\ttest('rapid toggle (disable → enable → disable) in one frame cancels scheduled work', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\n\t\t// Rapid toggle before rAF fires — enable() schedules work, disable() cancels it\n\t\tsm.disable();\n\t\tsm.enable();\n\t\tsm.disable();\n\n\t\texpect(sm.disabled).toBe(true);\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toHaveLength(0);\n\t\texpect(sm.progress).toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('modify({ element }) while disabled takes effect on enable()', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Create a second element further down\n\t\tconst newTarget = document.createElement('div');\n\t\tnewTarget.style.position = 'absolute';\n\t\tnewTarget.style.top = '1500px';\n\t\tnewTarget.style.height = '100px';\n\t\tnewTarget.style.width = '100%';\n\t\ttarget.parentElement!.appendChild(newTarget);\n\n\t\tsm.disable();\n\t\tsm.modify({ element: newTarget });\n\t\texpect(sm.element).toBe(newTarget);\n\n\t\t// Scroll past the NEW element position\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0); // still frozen\n\n\t\tsm.enable();\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(1); // tracking the new element\n\n\t\tsm.destroy();\n\t});\n\n\ttest('modify({ container }) while disabled takes effect on enable()', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\tsm.disable();\n\t\tsm.modify({ container: window }); // same container in this case, but exercises the code path\n\t\texpect(sm.container).toBe(window);\n\n\t\tsm.enable();\n\t\tawait waitForFrames(3);\n\n\t\t// Should be tracking normally after re-enable with new container\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('modify() while disabled takes effect on enable()', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\tsm.disable();\n\t\tsm.modify({ containerStart: 0.5 });\n\n\t\tsm.enable();\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.containerStart).toBe(0.5);\n\t\t// Verify the new containerStart is actually in effect by checking resolved offsets\n\t\texpect(sm.resolvedBounds.container.offsetStart).toBeGreaterThan(0);\n\n\t\tsm.destroy();\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/helpers.ts",
    "content": "export const waitForFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()));\nexport const waitForFrames = async (n = 3) => {\n\tfor (let i = 0; i < n; i++) await waitForFrame();\n};\nexport const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));\n\nexport const cleanup = () => {\n\tdocument.body.innerHTML = '';\n\twindow.scrollTo(0, 0);\n};\n\n/** Standard setup: scrollable page with a positioned target element (window scroll) */\nexport const setupWindow = (opts: { contentHeight?: number; elementTop?: number; elementHeight?: number } = {}) => {\n\tconst { contentHeight = 3000, elementTop = 1000, elementHeight = 200 } = opts;\n\tdocument.body.style.margin = '0';\n\tdocument.body.style.padding = '0';\n\n\tconst spacer = document.createElement('div');\n\tspacer.style.height = `${contentHeight}px`;\n\tspacer.style.position = 'relative';\n\n\tconst target = document.createElement('div');\n\ttarget.style.position = 'absolute';\n\ttarget.style.top = `${elementTop}px`;\n\ttarget.style.height = `${elementHeight}px`;\n\ttarget.style.width = '100%';\n\n\tspacer.appendChild(target);\n\tdocument.body.appendChild(spacer);\n\treturn { spacer, target };\n};\n\n/** Setup: scrollable container div (non-window scroll parent) */\nexport const setupContainer = (\n\topts: {\n\t\tcontainerHeight?: number;\n\t\tcontentHeight?: number;\n\t\telementTop?: number;\n\t\telementHeight?: number;\n\t\tcontainerCss?: Partial<CSSStyleDeclaration>;\n\t} = {}\n) => {\n\tconst { containerHeight = 400, contentHeight = 2000, elementTop = 800, elementHeight = 100, containerCss = {} } = opts;\n\tdocument.body.style.margin = '0';\n\tdocument.body.style.padding = '0';\n\n\tconst container = document.createElement('div');\n\tcontainer.style.height = `${containerHeight}px`;\n\tcontainer.style.overflow = 'auto';\n\tcontainer.style.position = 'relative';\n\tObject.assign(container.style, containerCss);\n\n\tconst content = document.createElement('div');\n\tcontent.style.height = `${contentHeight}px`;\n\tcontent.style.position = 'relative';\n\n\tconst target = document.createElement('div');\n\ttarget.style.position = 'absolute';\n\ttarget.style.top = `${elementTop}px`;\n\ttarget.style.height = `${elementHeight}px`;\n\ttarget.style.width = '100%';\n\n\tcontent.appendChild(target);\n\tcontainer.appendChild(content);\n\tdocument.body.appendChild(container);\n\treturn { container, content, target };\n};\n"
  },
  {
    "path": "tests/e2e/refresh.test.ts",
    "content": "/**\n * Manual refresh API: refresh(), refreshAll(), destroyAll().\n * Tests for: position change detection via refresh, class-based changes,\n * chaining, multi-instance refreshAll, destroyed instance exclusion, destroyAll.\n */\nimport { describe, test, expect, afterEach } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport { cleanup, setupWindow, waitForFrames } from './helpers';\n\ndescribe('refresh', () => {\n\tafterEach(cleanup);\n\n\t// Note on shift magnitude: position shifts must be small enough that the element stays\n\t// well within the IntersectionObserver's margin region. If the shift moves the element\n\t// outside the IO boundary, IO fires automatically and progress updates without refresh(),\n\t// defeating the test's purpose. With default options (IO margins ≈ 0% on a 768px viewport),\n\t// the element must stay clearly visible in the viewport after the shift.\n\ttest('refresh() picks up position changes from inline style', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// element at visual ~150px after scroll — centered in viewport\n\t\tconst { target } = setupWindow({ elementTop: 550, elementHeight: 100 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\twindow.scrollTo(0, 400);\n\t\tawait waitForFrames();\n\t\tconst progressBefore = sm.progress;\n\t\texpect(progressBefore).toBeGreaterThan(0);\n\n\t\t// small shift (50px) keeps element well within viewport (visual 150px → 100px)\n\t\t// ResizeObserver won't fire (size unchanged), IO margins aren't crossed\n\t\ttarget.style.top = '500px';\n\t\tawait waitForFrames();\n\t\tconst progressWithoutRefresh = sm.progress;\n\n\t\tsm.refresh();\n\t\tawait waitForFrames();\n\t\tconst progressAfterRefresh = sm.progress;\n\n\t\t// element moved closer to top → progress should increase\n\t\texpect(progressAfterRefresh).toBeGreaterThan(progressBefore);\n\t\texpect(progressAfterRefresh).not.toBe(progressWithoutRefresh);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('refresh() picks up class-based position changes', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// element at visual ~150px after scroll\n\t\tconst { target } = setupWindow({ elementTop: 550, elementHeight: 100 });\n\n\t\t// CSS class that shifts position by 50px (keeps element in viewport)\n\t\tconst style = document.createElement('style');\n\t\tstyle.textContent = '.shifted { top: 500px !important; }';\n\t\tdocument.head.appendChild(style);\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\twindow.scrollTo(0, 400);\n\t\tawait waitForFrames();\n\t\tconst progressBefore = sm.progress;\n\t\texpect(progressBefore).toBeGreaterThan(0);\n\n\t\ttarget.classList.add('shifted');\n\t\tsm.refresh();\n\t\tawait waitForFrames();\n\n\t\texpect(sm.progress).not.toBe(progressBefore);\n\n\t\tsm.destroy();\n\t\tstyle.remove();\n\t});\n\n\ttest('refresh() is chainable', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst result = sm.refresh();\n\t\texpect(result).toBe(sm);\n\t\tsm.destroy();\n\t});\n\n\ttest('refreshAll() updates all active instances', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// elements well within viewport after scroll: visual ~150px and ~250px\n\t\tconst { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });\n\n\t\tconst target2 = document.createElement('div');\n\t\ttarget2.style.position = 'absolute';\n\t\ttarget2.style.top = '650px';\n\t\ttarget2.style.height = '100px';\n\t\ttarget2.style.width = '100%';\n\t\tspacer.appendChild(target2);\n\n\t\tconst scene1 = new ScrollMagic({ element: target });\n\t\tconst scene2 = new ScrollMagic({ element: target2 });\n\t\twindow.scrollTo(0, 400);\n\t\tawait waitForFrames();\n\t\tconst p1Before = scene1.progress;\n\t\tconst p2Before = scene2.progress;\n\n\t\t// small shifts (50px each) — stay within viewport\n\t\ttarget.style.top = '500px';\n\t\ttarget2.style.top = '600px';\n\n\t\tScrollMagic.refreshAll();\n\t\tawait waitForFrames();\n\n\t\texpect(scene1.progress).not.toBe(p1Before);\n\t\texpect(scene2.progress).not.toBe(p2Before);\n\n\t\tscene1.destroy();\n\t\tscene2.destroy();\n\t});\n\n\ttest('refreshAll() skips disabled instances while refreshing enabled ones', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });\n\n\t\tconst target2 = document.createElement('div');\n\t\ttarget2.style.position = 'absolute';\n\t\ttarget2.style.top = '650px';\n\t\ttarget2.style.height = '100px';\n\t\ttarget2.style.width = '100%';\n\t\tspacer.appendChild(target2);\n\n\t\tconst scene1 = new ScrollMagic({ element: target });\n\t\tconst scene2 = new ScrollMagic({ element: target2 });\n\t\twindow.scrollTo(0, 400);\n\t\tawait waitForFrames();\n\t\tconst p1Before = scene1.progress;\n\t\tconst p2Before = scene2.progress;\n\n\t\tscene1.disable();\n\n\t\t// small shifts — stay within viewport\n\t\ttarget.style.top = '500px';\n\t\ttarget2.style.top = '600px';\n\n\t\tScrollMagic.refreshAll();\n\t\tawait waitForFrames();\n\n\t\t// disabled instance: progress frozen\n\t\texpect(scene1.progress).toBe(p1Before);\n\t\t// enabled instance: progress updated\n\t\texpect(scene2.progress).not.toBe(p2Before);\n\n\t\tscene1.destroy();\n\t\tscene2.destroy();\n\t});\n\n\ttest('destroyed instances are excluded from refreshAll()', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 550, elementHeight: 100 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\twindow.scrollTo(0, 400);\n\t\tawait waitForFrames();\n\t\tconst progressBefore = sm.progress;\n\n\t\tsm.destroy();\n\n\t\ttarget.style.top = '500px';\n\t\tScrollMagic.refreshAll();\n\t\tawait waitForFrames();\n\n\t\t// progress is frozen at whatever it was before destroy\n\t\texpect(sm.progress).toBe(progressBefore);\n\t});\n\n\ttest('destroyAll() destroys all active instances', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { spacer, target } = setupWindow({ elementTop: 550, elementHeight: 100 });\n\n\t\tconst target2 = document.createElement('div');\n\t\ttarget2.style.position = 'absolute';\n\t\ttarget2.style.top = '650px';\n\t\ttarget2.style.height = '100px';\n\t\ttarget2.style.width = '100%';\n\t\tspacer.appendChild(target2);\n\n\t\tconst scene1 = new ScrollMagic({ element: target });\n\t\tconst scene2 = new ScrollMagic({ element: target2 });\n\t\twindow.scrollTo(0, 400);\n\t\tawait waitForFrames();\n\t\tconst p1Before = scene1.progress;\n\t\tconst p2Before = scene2.progress;\n\n\t\tScrollMagic.destroyAll();\n\n\t\t// scroll should not update progress on destroyed instances\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames();\n\n\t\texpect(scene1.progress).toBe(p1Before);\n\t\texpect(scene2.progress).toBe(p2Before);\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/scroll-progress.test.ts",
    "content": "/**\n * Core scroll progress tracking and event behavior.\n * Tests for: progress 0→1 lifecycle, enter/leave/progress events, event direction,\n * fast scrolling, programmatic scroll jumps, scroll state initialization, destroy,\n * on() with { once: true }.\n */\nimport { describe, test, expect, afterEach } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport type { ScrollMagicEvent } from '../../src/index';\nimport { cleanup, setupWindow, waitForFrames } from './helpers';\n\ndescribe('progress lifecycle', () => {\n\tafterEach(cleanup);\n\n\ttest('fires enter and progress events on scroll', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow();\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\t// Scroll to a position where the element should be intersecting\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toContain('enter');\n\t\texpect(events).toContain('progress');\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('progress reaches 1 when fully scrolled past', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Scroll well past the element\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('progress is 0 before element enters viewport', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 2000 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('fires leave event when scrolling past', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toContain('leave');\n\n\t\tsm.destroy();\n\t});\n\n\ttest('destroy stops event processing', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('progress', () => events.push('progress'));\n\n\t\tsm.destroy();\n\n\t\twindow.scrollTo(0, 1000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toHaveLength(0);\n\t});\n});\n\n// #633: Fast scrolling could skip intermediate IO callbacks — elements scrolled past entirely in one frame.\ndescribe('fast scrolling', () => {\n\tafterEach(cleanup);\n\n\ttest('progress is correct after instant scroll past element and back', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// Element at 1500px — well below 768px viewport when scrolled to 0\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Instant scroll well past element\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\t// Instant scroll back to top — element now below viewport\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('all enter/leave events fire during instant scroll through', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\t// Single scroll that jumps completely past the element\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toContain('enter');\n\t\texpect(events).toContain('leave');\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n});\n\n// #630, #596: Browser scroll restoration — instances created at non-zero scroll positions.\ndescribe('scroll state initialization', () => {\n\tafterEach(cleanup);\n\n\ttest('correct initial progress when instance created at non-zero scroll', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\t// Scroll past element BEFORE creating instance\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\t// Now create instance — should detect current position\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('correct initial progress when element is partially visible on creation', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\t// Scroll to a position where element is partially visible\n\t\twindow.scrollTo(0, 500);\n\t\tawait waitForFrames(3);\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\t\texpect(sm.progress).toBeLessThan(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('fires enter event when created at position where element is visible', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 300, elementHeight: 200 });\n\n\t\t// Scroll so element is in view\n\t\twindow.scrollTo(0, 200);\n\t\tawait waitForFrames(3);\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('progress', () => events.push('progress'));\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toContain('enter');\n\t\texpect(events).toContain('progress');\n\n\t\tsm.destroy();\n\t});\n});\n\n// #948: scrollDirection may be incorrect if no scroll has occurred yet.\ndescribe('event direction', () => {\n\tafterEach(cleanup);\n\n\ttest('direction is forward when element scrolled past from above', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tconst enterDirections: string[] = [];\n\t\tconst leaveDirections: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', (e: ScrollMagicEvent) => enterDirections.push(e.direction));\n\t\tsm.on('leave', (e: ScrollMagicEvent) => leaveDirections.push(e.direction));\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(enterDirections).toContain('forward');\n\t\texpect(leaveDirections).toContain('forward');\n\n\t\tsm.destroy();\n\t});\n\n\ttest('direction is reverse when scrolling back up past element', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// Element below viewport when scrolled to 0, so reverse scroll exits fully\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// First scroll past\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tconst enterDirections: string[] = [];\n\t\tconst leaveDirections: string[] = [];\n\t\tsm.on('enter', (e: ScrollMagicEvent) => enterDirections.push(e.direction));\n\t\tsm.on('leave', (e: ScrollMagicEvent) => leaveDirections.push(e.direction));\n\n\t\t// Scroll back to top — element now below viewport\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\n\t\texpect(enterDirections).toContain('reverse');\n\t\texpect(leaveDirections).toContain('reverse');\n\n\t\tsm.destroy();\n\t});\n});\n\ndescribe('on with { once: true }', () => {\n\tafterEach(cleanup);\n\n\ttest('once listener fires exactly once then auto-removes', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tlet enterCount = 0;\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => enterCount++, { once: true });\n\n\t\t// Scroll forward past element → enter fires\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(enterCount).toBe(1);\n\n\t\t// Scroll back to top → element leaves, then scroll past again\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\t// Still 1 — listener was auto-removed after first fire\n\t\texpect(enterCount).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('off() cancels a once listener before it fires', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tlet enterCount = 0;\n\t\tconst handler = () => enterCount++;\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', handler, { once: true });\n\t\tsm.off('enter', handler);\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(enterCount).toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('on with { once: true } is chainable', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow();\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst result = sm.on('enter', () => {}, { once: true });\n\t\texpect(result).toBe(sm);\n\t\tsm.destroy();\n\t});\n\n\ttest('once on different event types works independently', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tlet enterCount = 0;\n\t\tlet leaveCount = 0;\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => enterCount++, { once: true });\n\t\tsm.on('leave', () => leaveCount++, { once: true });\n\n\t\t// Scroll forward past → enter + leave fire\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(enterCount).toBe(1);\n\t\texpect(leaveCount).toBe(1);\n\n\t\t// Scroll back and forward again → neither fires again\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\n\t\texpect(enterCount).toBe(1);\n\t\texpect(leaveCount).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('subscribe with { once: true } fires once and returns working unsubscribe', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tlet enterCount = 0;\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst unsub = sm.subscribe('enter', () => enterCount++, { once: true });\n\t\texpect(typeof unsub).toBe('function');\n\n\t\t// Scroll forward past element → enter fires\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(enterCount).toBe(1);\n\n\t\t// Scroll back and forward again → auto-removed, doesn't fire\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(enterCount).toBe(1);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('subscribe with { once: true } can be cancelled via unsubscribe before firing', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 100 });\n\n\t\tlet enterCount = 0;\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tconst unsub = sm.subscribe('enter', () => enterCount++, { once: true });\n\t\tunsub(); // cancel before it fires\n\n\t\twindow.scrollTo(0, 2000);\n\t\tawait waitForFrames(3);\n\t\texpect(enterCount).toBe(0);\n\n\t\tsm.destroy();\n\t});\n});\n\n// Anchor links / scrollTo can skip the active range entirely in a single frame.\n// The element may never have intersected — progress must still settle at 0 or 1.\ndescribe('anchor-link style jumps', () => {\n\tafterEach(cleanup);\n\n\ttest('progress reaches 1 when scrollTo jumps past a never-intersected element', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// Element well below viewport — not visible at scroll=0\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Element has never been intersecting — jump straight past it\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0);\n\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(1);\n\t\tsm.destroy();\n\t});\n\n\ttest('enter and leave both fire when jumping over a never-intersected element', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// Element well below viewport — not visible at scroll=0\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });\n\n\t\tconst events: string[] = [];\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tsm.on('enter', () => events.push('enter'));\n\t\tsm.on('leave', () => events.push('leave'));\n\n\t\tawait waitForFrames(3);\n\t\texpect(events).toHaveLength(0);\n\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\n\t\texpect(events).toContain('enter');\n\t\texpect(events).toContain('leave');\n\t\tsm.destroy();\n\t});\n\n\ttest('progress reaches 0 when jumping back before a previously-passed element', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 100 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// First jump past\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\t// Jump all the way back — element now entirely below viewport\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0);\n\n\t\tsm.destroy();\n\t});\n});\n\n// #397: Browser find (Cmd+F) triggers scroll-to-element — verify progress after programmatic scrolls.\ndescribe('programmatic scroll jumps', () => {\n\tafterEach(cleanup);\n\n\ttest('progress correct after multiple programmatic scrollTo jumps', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\t// Element below viewport when at scroll=0\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 200 });\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// Jump to where element is partially visible\n\t\twindow.scrollTo(0, 1200);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\t\texpect(sm.progress).toBeLessThan(1);\n\n\t\t// Jump far past\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\t// Jump back to before element (element below viewport)\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0);\n\n\t\t// Jump directly into element again\n\t\twindow.scrollTo(0, 1300);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\tsm.destroy();\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/scroll-velocity.test.ts",
    "content": "/**\n * Scroll velocity: per-container px/s computation exposed via ScrollMagic getter.\n * Tests for: non-zero during scroll, sign (forward/backward), staleness decay,\n * disabled/destroyed state, callback access via e.target, horizontal axis.\n */\nimport { describe, test, expect, afterEach, vi } from 'vitest';\nimport { page } from 'vitest/browser';\nimport ScrollMagic from '../../src/index';\nimport { cleanup, setupWindow, wait, waitForFrames } from './helpers';\n\ndescribe('scrollVelocity', () => {\n\tafterEach(cleanup);\n\n\ttest('non-zero during scroll', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames(3); // let initial setup complete\n\n\t\tlet velocityDuringScroll = 0;\n\t\tsm.on('progress', () => {\n\t\t\tvelocityDuringScroll = sm.scrollVelocity;\n\t\t});\n\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\texpect(velocityDuringScroll).not.toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('positive when scrolling forward', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames(3);\n\n\t\tlet velocityDuringScroll = 0;\n\t\tsm.on('progress', () => {\n\t\t\tvelocityDuringScroll = sm.scrollVelocity;\n\t\t});\n\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\texpect(velocityDuringScroll).toBeGreaterThan(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('negative when scrolling backward', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 1500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\t// First scroll past the element and let it settle\n\t\twindow.scrollTo(0, 2500);\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(1);\n\n\t\tlet velocityOnReturn = 0;\n\t\tsm.on('progress', () => {\n\t\t\tvelocityOnReturn = sm.scrollVelocity;\n\t\t});\n\n\t\t// Scroll back to top — element now below viewport, progress returns to 0\n\t\twindow.scrollTo(0, 0);\n\t\tawait waitForFrames(3);\n\n\t\texpect(sm.progress).toBe(0);\n\t\texpect(velocityOnReturn).toBeLessThan(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('returns 0 after scrolling stops (staleness decay)', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\t// Wait past the 100ms staleness threshold\n\t\tawait wait(200);\n\n\t\texpect(sm.scrollVelocity).toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('returns 0 when disabled', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\tsm.disable();\n\t\texpect(sm.scrollVelocity).toBe(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('returns 0 after destroy (no warning)', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\tsm.destroy();\n\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\texpect(sm.scrollVelocity).toBe(0);\n\t\texpect(warnSpy).not.toHaveBeenCalled();\n\t\twarnSpy.mockRestore();\n\t});\n\n\ttest('positive for horizontal scroll with vertical: false', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tdocument.body.style.margin = '0';\n\t\tdocument.body.style.padding = '0';\n\t\tconst spacer = document.createElement('div');\n\t\tspacer.style.width = '5000px';\n\t\tspacer.style.height = '768px';\n\t\tspacer.style.position = 'relative';\n\t\tconst target = document.createElement('div');\n\t\ttarget.style.position = 'absolute';\n\t\ttarget.style.left = '1500px';\n\t\ttarget.style.width = '400px';\n\t\ttarget.style.height = '100%';\n\t\tspacer.appendChild(target);\n\t\tdocument.body.appendChild(spacer);\n\n\t\tconst sm = new ScrollMagic({ element: target, vertical: false });\n\t\tawait waitForFrames(3);\n\t\texpect(sm.progress).toBe(0);\n\n\t\twindow.scrollTo(600, 0);\n\t\tawait waitForFrames(3);\n\n\t\t// Check velocity directly — axis projection picks x, not y\n\t\texpect(sm.scrollVelocity).toBeGreaterThan(0);\n\t\texpect(sm.progress).toBeGreaterThan(0);\n\n\t\tsm.destroy();\n\t});\n\n\ttest('accessible via e.target.scrollVelocity in callbacks', async () => {\n\t\tawait page.viewport(1024, 768);\n\t\tconst { target } = setupWindow({ elementTop: 500, elementHeight: 400 });\n\n\t\tconst sm = new ScrollMagic({ element: target });\n\t\tawait waitForFrames(3);\n\n\t\tlet eventTargetVelocity = 0;\n\t\tlet directVelocity = 0;\n\t\tsm.on('progress', e => {\n\t\t\teventTargetVelocity = e.target.scrollVelocity;\n\t\t\tdirectVelocity = sm.scrollVelocity;\n\t\t});\n\n\t\twindow.scrollTo(0, 600);\n\t\tawait waitForFrames(3);\n\n\t\texpect(eventTargetVelocity).toBe(directVelocity);\n\t\texpect(eventTargetVelocity).not.toBe(0);\n\n\t\tsm.destroy();\n\t});\n});\n"
  },
  {
    "path": "tests/unit/ContainerProxy.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach } from 'vitest';\nimport type { ScrollMagic } from '../../src/ScrollMagic';\n\nconst destroyMock = vi.fn();\nconst subscribeMock = vi.fn(() => vi.fn());\n\nvi.mock('../../src/Container', () => ({\n\tContainer: class MockContainer {\n\t\tcontainerElement: unknown;\n\t\tsubscribe = subscribeMock;\n\t\tdestroy = destroyMock;\n\t\tsize = Object.freeze({ clientWidth: 100, clientHeight: 200, scrollWidth: 300, scrollHeight: 400 });\n\t\tposition = Object.freeze({ top: 10, left: 20 });\n\t\tscrollVelocity = Object.freeze({ x: 1, y: 2 });\n\t\tconstructor(containerElement: unknown) {\n\t\t\tthis.containerElement = containerElement;\n\t\t}\n\t},\n}));\n\nimport { ContainerProxy } from '../../src/ContainerProxy';\n\nconst fakeSm = (id = 1) => ({ id }) as unknown as ScrollMagic;\n\ndescribe('ContainerProxy', () => {\n\tbeforeEach(() => {\n\t\tvi.clearAllMocks();\n\t});\n\n\ttest('attach subscribes to resize and scroll events', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\tconst cb = vi.fn();\n\t\tproxy.attach(document.createElement('div'), cb);\n\n\t\texpect(subscribeMock).toHaveBeenCalledWith('resize', cb);\n\t\texpect(subscribeMock).toHaveBeenCalledWith('scroll', cb);\n\n\t\tproxy.detach();\n\t});\n\n\ttest('two proxies sharing the same container element share size/position', () => {\n\t\tconst el = document.createElement('div');\n\t\tconst proxy1 = new ContainerProxy(fakeSm(1));\n\t\tconst proxy2 = new ContainerProxy(fakeSm(2));\n\n\t\tproxy1.attach(el, vi.fn());\n\t\tproxy2.attach(el, vi.fn());\n\n\t\t// Both see the same underlying Container state\n\t\texpect(proxy1.size).toEqual(proxy2.size);\n\t\texpect(proxy1.position).toEqual(proxy2.position);\n\n\t\tproxy1.detach();\n\t\tproxy2.detach();\n\t});\n\n\ttest('Container is destroyed only when the last proxy detaches', () => {\n\t\tconst el = document.createElement('div');\n\t\tconst proxy1 = new ContainerProxy(fakeSm(1));\n\t\tconst proxy2 = new ContainerProxy(fakeSm(2));\n\n\t\tproxy1.attach(el, vi.fn());\n\t\tproxy2.attach(el, vi.fn());\n\n\t\tproxy1.detach();\n\t\texpect(destroyMock).not.toHaveBeenCalled();\n\n\t\tproxy2.detach();\n\t\texpect(destroyMock).toHaveBeenCalledOnce();\n\t});\n\n\ttest('detach unsubscribes from container events', () => {\n\t\tconst unsubscribe1 = vi.fn();\n\t\tconst unsubscribe2 = vi.fn();\n\t\tsubscribeMock.mockReturnValueOnce(unsubscribe1).mockReturnValueOnce(unsubscribe2);\n\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\tproxy.attach(document.createElement('div'), vi.fn());\n\t\tproxy.detach();\n\n\t\texpect(unsubscribe1).toHaveBeenCalledOnce();\n\t\texpect(unsubscribe2).toHaveBeenCalledOnce();\n\t});\n\n\ttest('detach on unattached proxy is a no-op', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\texpect(() => proxy.detach()).not.toThrow();\n\t});\n\n\ttest('attach to a new element detaches from the previous one', () => {\n\t\tconst el1 = document.createElement('div');\n\t\tconst el2 = document.createElement('div');\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\n\t\tproxy.attach(el1, vi.fn());\n\t\tproxy.attach(el2, vi.fn());\n\n\t\t// First container destroyed (sole user detached)\n\t\texpect(destroyMock).toHaveBeenCalledOnce();\n\t\t// Still functional on new container\n\t\texpect(() => proxy.size).not.toThrow();\n\n\t\tproxy.detach();\n\t});\n\n\ttest('size delegates to the underlying Container', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\tproxy.attach(document.createElement('div'), vi.fn());\n\t\texpect(proxy.size).toEqual({ clientWidth: 100, clientHeight: 200, scrollWidth: 300, scrollHeight: 400 });\n\t\tproxy.detach();\n\t});\n\n\ttest('position delegates to the underlying Container', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\tproxy.attach(document.createElement('div'), vi.fn());\n\t\texpect(proxy.position).toEqual({ top: 10, left: 20 });\n\t\tproxy.detach();\n\t});\n\n\ttest('scrollVelocity delegates to the underlying Container', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\tproxy.attach(document.createElement('div'), vi.fn());\n\t\texpect(proxy.scrollVelocity).toEqual({ x: 1, y: 2 });\n\t\tproxy.detach();\n\t});\n\n\ttest('scrollVelocity returns zero when not attached', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\texpect(proxy.scrollVelocity).toEqual({ x: 0, y: 0 });\n\t});\n\n\ttest('size throws when not attached', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\texpect(() => proxy.size).toThrow(\"Can't get size when not attached\");\n\t});\n\n\ttest('position throws when not attached', () => {\n\t\tconst proxy = new ContainerProxy(fakeSm());\n\t\texpect(() => proxy.position).toThrow(\"Can't get position when not attached\");\n\t});\n});\n"
  },
  {
    "path": "tests/unit/EventDispatcher.test.ts",
    "content": "import { describe, test, expect, vi } from 'vitest';\nimport { EventDispatcher, type DispatchableEvent } from '../../src/EventDispatcher';\n\ninterface TestEvent extends DispatchableEvent {\n\treadonly target: unknown;\n\treadonly type: 'foo' | 'bar';\n\treadonly value?: number;\n}\n\nconst event = (type: TestEvent['type'], value?: number): TestEvent => ({ target: null, type, value });\n\ndescribe('EventDispatcher', () => {\n\ttest('calls listener on dispatch', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb);\n\t\td.dispatchEvent(event('foo', 1));\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t\texpect(cb).toHaveBeenCalledWith(expect.objectContaining({ type: 'foo', value: 1 }));\n\t});\n\n\ttest('does not call listener for different event type', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb);\n\t\td.dispatchEvent(event('bar'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('supports multiple listeners for same type', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb1 = vi.fn();\n\t\tconst cb2 = vi.fn();\n\t\td.addEventListener('foo', cb1);\n\t\td.addEventListener('foo', cb2);\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb1).toHaveBeenCalledOnce();\n\t\texpect(cb2).toHaveBeenCalledOnce();\n\t});\n\n\ttest('allows duplicate registrations (both fire)', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb);\n\t\td.addEventListener('foo', cb);\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledTimes(2);\n\t});\n\n\ttest('removeEventListener stops future calls', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb);\n\t\td.removeEventListener('foo', cb);\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('addEventListener returns unsubscribe function', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\tconst unsub = d.addEventListener('foo', cb);\n\t\tunsub();\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('removing non-existent listener is a no-op', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\texpect(() => d.removeEventListener('foo', vi.fn())).not.toThrow();\n\t});\n\n\ttest('dispatch with no listeners is a no-op', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\texpect(() => d.dispatchEvent(event('foo'))).not.toThrow();\n\t});\n\n\ttest('once listener fires once then auto-removes', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { once: true });\n\t\td.dispatchEvent(event('foo'));\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t});\n\n\ttest('once listener is removable via removeEventListener before firing', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { once: true });\n\t\td.removeEventListener('foo', cb);\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('once listener is removable via returned unsubscribe function', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\tconst unsub = d.addEventListener('foo', cb, { once: true });\n\t\tunsub();\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('removeEventListener after once listener already fired is a safe no-op', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { once: true });\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(() => d.removeEventListener('foo', cb)).not.toThrow();\n\t});\n\n\ttest('unsubscribe after once listener already fired is a safe no-op', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\tconst unsub = d.addEventListener('foo', cb, { once: true });\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(() => unsub()).not.toThrow();\n\t});\n\n\ttest('once does not affect other listeners for the same type', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst onceCb = vi.fn();\n\t\tconst regularCb = vi.fn();\n\t\td.addEventListener('foo', onceCb, { once: true });\n\t\td.addEventListener('foo', regularCb);\n\t\td.dispatchEvent(event('foo'));\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(onceCb).toHaveBeenCalledOnce();\n\t\texpect(regularCb).toHaveBeenCalledTimes(2);\n\t});\n\n\ttest('same callback registered as once and regular — only the once registration auto-removes', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb); // regular\n\t\td.addEventListener('foo', cb, { once: true }); // once\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledTimes(2); // both fire on first dispatch\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledTimes(3); // only regular fires on second dispatch\n\t});\n\n\ttest('listener added during dispatch does not fire in the same cycle', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst laterCb = vi.fn();\n\t\td.addEventListener('foo', () => {\n\t\t\td.addEventListener('foo', laterCb);\n\t\t});\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(laterCb).not.toHaveBeenCalled(); // not fired in same dispatch\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(laterCb).toHaveBeenCalledOnce(); // fires on next dispatch\n\t});\n\n\ttest('same callback registered as once and regular — removeEventListener removes first match', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb); // regular (first)\n\t\td.addEventListener('foo', cb, { once: true }); // once (second)\n\t\td.removeEventListener('foo', cb); // removes the regular one (first match)\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledOnce(); // once registration fires\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledOnce(); // then auto-removed\n\t});\n\n\ttest('signal: listener is removed when signal aborts', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { signal: ac.signal });\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t\tac.abort();\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledOnce(); // not called again\n\t});\n\n\ttest('signal: listener not added if signal already aborted', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tac.abort();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { signal: ac.signal });\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('signal: multiple listeners removed by single abort', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tconst cb1 = vi.fn();\n\t\tconst cb2 = vi.fn();\n\t\td.addEventListener('foo', cb1, { signal: ac.signal });\n\t\td.addEventListener('bar', cb2, { signal: ac.signal });\n\t\tac.abort();\n\t\td.dispatchEvent(event('foo'));\n\t\td.dispatchEvent(event('bar'));\n\t\texpect(cb1).not.toHaveBeenCalled();\n\t\texpect(cb2).not.toHaveBeenCalled();\n\t});\n\n\ttest('signal + once: both mechanisms coexist', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { once: true, signal: ac.signal });\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t\t// once already removed it — abort is a safe no-op\n\t\texpect(() => ac.abort()).not.toThrow();\n\t});\n\n\ttest('signal: abort before dispatch, once listener never fires', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tconst cb = vi.fn();\n\t\td.addEventListener('foo', cb, { once: true, signal: ac.signal });\n\t\tac.abort();\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('signal: abort during dispatch still fires remaining listeners in snapshot', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tconst cb1 = vi.fn(() => ac.abort());\n\t\tconst cb2 = vi.fn();\n\t\td.addEventListener('foo', cb1, { signal: ac.signal });\n\t\td.addEventListener('foo', cb2, { signal: ac.signal });\n\t\td.dispatchEvent(event('foo'));\n\t\t// both fire in the current cycle (snapshot iteration)\n\t\texpect(cb1).toHaveBeenCalledOnce();\n\t\texpect(cb2).toHaveBeenCalledOnce();\n\t\t// but neither fires again — abort removed them\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb1).toHaveBeenCalledOnce();\n\t\texpect(cb2).toHaveBeenCalledOnce();\n\t});\n\n\ttest('signal: manual removal then abort is safe', () => {\n\t\tconst d = new EventDispatcher<TestEvent>();\n\t\tconst ac = new AbortController();\n\t\tconst cb = vi.fn();\n\t\tconst unsub = d.addEventListener('foo', cb, { signal: ac.signal });\n\t\tunsub();\n\t\texpect(() => ac.abort()).not.toThrow();\n\t\td.dispatchEvent(event('foo'));\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n});\n"
  },
  {
    "path": "tests/unit/ExecutionQueue.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { ExecutionQueue } from '../../src/ExecutionQueue';\nimport { rafQueue } from '../../src/util/rafQueue';\n\ndescribe('ExecutionQueue', () => {\n\tbeforeEach(() => {\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\tvi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {});\n\t});\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('commands execute in insertion order regardless of scheduling order', () => {\n\t\tconst order: string[] = [];\n\t\tconst queue = new ExecutionQueue({\n\t\t\ta: () => order.push('a'),\n\t\t\tb: () => order.push('b'),\n\t\t\tc: () => order.push('c'),\n\t\t});\n\t\tqueue.commands.c.schedule();\n\t\tqueue.commands.a.schedule();\n\t\tqueue.commands.b.schedule();\n\t\trafQueue.flush();\n\t\texpect(order).toEqual(['a', 'b', 'c']);\n\t});\n\n\ttest('unscheduled commands are not executed', () => {\n\t\tconst a = vi.fn();\n\t\tconst b = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a, b });\n\t\tqueue.commands.a.schedule();\n\t\t// b is not scheduled\n\t\trafQueue.flush();\n\t\texpect(a).toHaveBeenCalledOnce();\n\t\texpect(b).not.toHaveBeenCalled();\n\t});\n\n\ttest('conditional execution: condition met executes, condition not met skips', () => {\n\t\tconst cb = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a: cb });\n\t\tqueue.commands.a.schedule(() => false);\n\t\trafQueue.flush();\n\t\texpect(cb).not.toHaveBeenCalled();\n\n\t\tqueue.commands.a.schedule(() => true);\n\t\trafQueue.flush();\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t});\n\n\ttest('unconditional schedule clears previous conditions', () => {\n\t\tconst cb = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a: cb });\n\t\t// first schedule with a condition that would fail\n\t\tqueue.commands.a.schedule(() => false);\n\t\t// second schedule without condition (unconditional) — should override\n\t\tqueue.commands.a.schedule();\n\t\trafQueue.flush();\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t});\n\n\ttest('multiple conditions: any true condition causes execution', () => {\n\t\tconst cb = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a: cb });\n\t\tqueue.commands.a.schedule(() => false);\n\t\tqueue.commands.a.schedule(() => true);\n\t\trafQueue.flush();\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t});\n\n\ttest('cancel prevents execution', () => {\n\t\tconst cb = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a: cb });\n\t\tqueue.commands.a.schedule();\n\t\tqueue.cancel();\n\t\trafQueue.flush();\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n\n\ttest('scheduling triggers the rafQueue', () => {\n\t\tconst scheduleSpy = vi.spyOn(rafQueue, 'schedule');\n\t\tconst queue = new ExecutionQueue({ a: vi.fn() });\n\t\tqueue.commands.a.schedule();\n\t\texpect(scheduleSpy).toHaveBeenCalledWith(queue);\n\t});\n\n\ttest('command scheduled multiple times executes only once per flush', () => {\n\t\tconst cb = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a: cb });\n\t\tqueue.commands.a.schedule();\n\t\tqueue.commands.a.schedule();\n\t\tqueue.commands.a.schedule();\n\t\trafQueue.flush();\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t});\n\n\ttest('conditions are reset after execution', () => {\n\t\tconst cb = vi.fn();\n\t\tconst queue = new ExecutionQueue({ a: cb });\n\t\tqueue.commands.a.schedule();\n\t\trafQueue.flush();\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t\t// second flush without re-scheduling — should not execute\n\t\tcb.mockClear();\n\t\trafQueue.flush();\n\t\texpect(cb).not.toHaveBeenCalled();\n\t});\n});\n"
  },
  {
    "path": "tests/unit/Options.processors.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { sanitizeOptions, processOptions } from '../../src/Options.processors';\nimport { defaults } from '../../src/Options';\nimport type { Public } from '../../src/Options';\n\n// NOTE: jsdom's window doesn't pass `instanceof Window`, so tests that rely on\n// window as container (the default) are limited. Window-container behavior is\n// covered by e2e tests. Here we focus on explicit HTMLElement containers.\n\n// Mirrors what the ScrollMagic constructor does: spreads defaults before processing.\nconst fullOptions = (overrides: Public = {}): Required<Public> => ({\n\t...defaults,\n\t...overrides,\n});\n\ndescribe('sanitizeOptions', () => {\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('keeps known ScrollMagic options', () => {\n\t\tconst result = sanitizeOptions({ element: null, vertical: false });\n\t\texpect(result).toEqual({ element: null, vertical: false });\n\t});\n\n\ttest('strips unknown options', () => {\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tconst result = sanitizeOptions({ element: null, bogus: 42 } as never);\n\t\texpect('bogus' in result).toBe(false);\n\t});\n\n\ttest('warns about unknown options', () => {\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsanitizeOptions({ nonsense: true } as never);\n\t\texpect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonsense'));\n\t});\n});\n\ndescribe('processOptions', () => {\n\tlet container: HTMLElement;\n\tlet element: HTMLElement;\n\n\tbeforeEach(() => {\n\t\tdocument.body.innerHTML = '';\n\t\tcontainer = document.createElement('div');\n\t\telement = document.createElement('div');\n\t\tcontainer.appendChild(element);\n\t\tdocument.body.appendChild(container);\n\t});\n\n\tafterEach(() => {\n\t\tdocument.body.innerHTML = '';\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('transforms valid options into processed form', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element, container, vertical: true }));\n\t\texpect(processed.element).toBe(element);\n\t\texpect(processed.container).toBe(container);\n\t\texpect(processed.vertical).toBe(true);\n\t\texpect(typeof processed.elementStart).toBe('function');\n\t\texpect(typeof processed.elementEnd).toBe('function');\n\t\texpect(typeof processed.containerStart).toBe('function');\n\t\texpect(typeof processed.containerEnd).toBe('function');\n\t});\n\n\ttest('default elementStart and elementEnd return 0', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element, container }));\n\t\texpect(processed.elementStart(100)).toBe(0);\n\t\texpect(processed.elementEnd(100)).toBe(0);\n\t});\n\n\ttest('resolves CSS selector for element', () => {\n\t\telement.id = 'tracked';\n\t\tconst { processed } = processOptions(fullOptions({ element: '#tracked', container }));\n\t\texpect(processed.element).toBe(element);\n\t});\n\n\ttest('resolves CSS selector for container', () => {\n\t\tcontainer.id = 'scroll-parent';\n\t\tconst { processed } = processOptions(fullOptions({ element, container: '#scroll-parent' }));\n\t\texpect(processed.container).toBe(container);\n\t});\n\n\ttest('null element defaults to first child of container', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element: null, container }));\n\t\texpect(processed.element).toBe(element);\n\t});\n\n\ttest('throws when container has no valid children for element inference', () => {\n\t\tconst empty = document.createElement('div');\n\t\tdocument.body.appendChild(empty);\n\t\texpect(() => processOptions(fullOptions({ element: null, container: empty }))).toThrow(\n\t\t\t'Could not autodetect element, as container has no valid children'\n\t\t);\n\t});\n\n\ttest('containerStart/End default to 100% when element is explicit', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element, container }));\n\t\texpect(processed.containerStart(800)).toBe(800);\n\t\texpect(processed.containerEnd(800)).toBe(800);\n\t});\n\n\ttest('containerStart/End default to 0 when element is null (first-child fallback)', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element: null, container }));\n\t\texpect(processed.containerStart(800)).toBe(0);\n\t\texpect(processed.containerEnd(800)).toBe(0);\n\t});\n\n\ttest('explicit containerStart/End values are preserved', () => {\n\t\tconst { processed } = processOptions(\n\t\t\tfullOptions({\n\t\t\t\telement,\n\t\t\t\tcontainer,\n\t\t\t\tcontainerStart: '50%',\n\t\t\t\tcontainerEnd: 100,\n\t\t\t})\n\t\t);\n\t\texpect(processed.containerStart(400)).toBe(200);\n\t\texpect(processed.containerEnd(400)).toBe(100);\n\t});\n\n\ttest('preserves oldOptions for unspecified keys (modify scenario)', () => {\n\t\tconst { processed: initial } = processOptions(fullOptions({ element, container, vertical: false }));\n\t\t// modify: only changing vertical, the rest comes from oldOptions\n\t\tconst { processed: modified } = processOptions({ vertical: true }, initial);\n\t\texpect(modified.element).toBe(element);\n\t\texpect(modified.container).toBe(container);\n\t\texpect(modified.vertical).toBe(true);\n\t});\n\n\ttest('returns sanitized options alongside processed ones', () => {\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tconst opts = { ...fullOptions({ element, container }), bogus: 1 };\n\t\tconst { sanitized } = processOptions(opts as unknown as Required<Public>);\n\t\texpect('bogus' in sanitized).toBe(false);\n\t\texpect(sanitized.element).toBe(element);\n\t});\n\n\ttest('vertical defaults to true', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element, container }));\n\t\texpect(processed.vertical).toBe(true);\n\t});\n\n\ttest('vertical false is preserved', () => {\n\t\tconst { processed } = processOptions(fullOptions({ element, container, vertical: false }));\n\t\texpect(processed.vertical).toBe(false);\n\t});\n\n\tdescribe('sanity checks', () => {\n\t\ttest('warns when element is not a descendant of container', () => {\n\t\t\tconst errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\t\t\tconst orphan = document.createElement('div');\n\t\t\tdocument.body.appendChild(orphan);\n\t\t\tconst separate = document.createElement('div');\n\t\t\tdocument.body.appendChild(separate);\n\n\t\t\tprocessOptions(fullOptions({ element: orphan, container: separate }));\n\t\t\texpect(errorSpy).toHaveBeenCalledWith(\n\t\t\t\texpect.stringContaining('element is not a descendant of container'),\n\t\t\t\texpect.any(Object)\n\t\t\t);\n\t\t});\n\n\t\ttest('does not warn when element is inside container', () => {\n\t\t\tconst errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\t\t\tprocessOptions(fullOptions({ element, container }));\n\t\t\texpect(errorSpy).not.toHaveBeenCalled();\n\t\t});\n\n\t\ttest('warns when configured offsets produce no overlap', () => {\n\t\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\t\tprocessOptions(\n\t\t\t\tfullOptions({\n\t\t\t\t\telement,\n\t\t\t\t\tcontainer,\n\t\t\t\t\telementStart: 99999,\n\t\t\t\t\telementEnd: 99999,\n\t\t\t\t})\n\t\t\t);\n\t\t\texpect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no overlap'), expect.any(Object));\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/ScrollMagicError.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { ScrollMagicError, ScrollMagicInternalError } from '../../src/ScrollMagicError';\n\ndescribe('ScrollMagicError', () => {\n\ttest('has correct name', () => {\n\t\tconst err = new ScrollMagicError('test');\n\t\texpect(err.name).toBe('ScrollMagicError');\n\t});\n\n\ttest('is instanceof Error', () => {\n\t\tconst err = new ScrollMagicError('test');\n\t\texpect(err).toBeInstanceOf(Error);\n\t\texpect(err).toBeInstanceOf(ScrollMagicError);\n\t});\n\n\ttest('has correct message', () => {\n\t\tconst err = new ScrollMagicError('something broke');\n\t\texpect(err.message).toBe('something broke');\n\t});\n\n\ttest('supports cause option', () => {\n\t\tconst cause = new Error('root');\n\t\tconst err = new ScrollMagicError('wrapped', { cause });\n\t\texpect(err.cause).toBe(cause);\n\t});\n\n\ttest('has Symbol.toStringTag', () => {\n\t\tconst err = new ScrollMagicError('test');\n\t\texpect(Object.prototype.toString.call(err)).toBe('[object ScrollMagicError]');\n\t});\n});\n\ndescribe('ScrollMagicInternalError', () => {\n\ttest('has correct name', () => {\n\t\tconst err = new ScrollMagicInternalError('oops');\n\t\texpect(err.name).toBe('ScrollMagicInternalError');\n\t});\n\n\ttest('prepends Internal Error to message', () => {\n\t\tconst err = new ScrollMagicInternalError('oops');\n\t\texpect(err.message).toBe('Internal Error: oops');\n\t});\n\n\ttest('is instanceof both error classes', () => {\n\t\tconst err = new ScrollMagicInternalError('oops');\n\t\texpect(err).toBeInstanceOf(Error);\n\t\texpect(err).toBeInstanceOf(ScrollMagicError);\n\t\texpect(err).toBeInstanceOf(ScrollMagicInternalError);\n\t});\n\n\ttest('distinguishable from ScrollMagicError via instanceof', () => {\n\t\tconst regular = new ScrollMagicError('a');\n\t\tconst internal = new ScrollMagicInternalError('b');\n\t\texpect(regular).not.toBeInstanceOf(ScrollMagicInternalError);\n\t\texpect(internal).toBeInstanceOf(ScrollMagicError);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/ScrollMagicEvent.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { ScrollMagicEvent, EventType, EventLocation, ScrollDirection } from '../../src/ScrollMagicEvent';\nimport type { ScrollMagic } from '../../src/ScrollMagic';\n\n// minimal stub — only used as the `target` reference\nconst fakeTarget = {} as ScrollMagic;\n\ndescribe('ScrollMagicEvent', () => {\n\tdescribe('location', () => {\n\t\ttest('enter while scrolling forward → start', () => {\n\t\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Enter, true);\n\t\t\texpect(event.location).toBe('start');\n\t\t});\n\n\t\ttest('enter while scrolling reverse → end', () => {\n\t\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Enter, false);\n\t\t\texpect(event.location).toBe('end');\n\t\t});\n\n\t\ttest('leave while scrolling forward → end', () => {\n\t\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Leave, true);\n\t\t\texpect(event.location).toBe('end');\n\t\t});\n\n\t\ttest('leave while scrolling reverse → start', () => {\n\t\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Leave, false);\n\t\t\texpect(event.location).toBe('start');\n\t\t});\n\n\t\ttest('progress is always inside', () => {\n\t\t\texpect(new ScrollMagicEvent(fakeTarget, EventType.Progress, true).location).toBe('inside');\n\t\t\texpect(new ScrollMagicEvent(fakeTarget, EventType.Progress, false).location).toBe('inside');\n\t\t});\n\t});\n\n\tdescribe('direction', () => {\n\t\ttest('forward when scrolling forward', () => {\n\t\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Enter, true);\n\t\t\texpect(event.direction).toBe('forward');\n\t\t});\n\n\t\ttest('reverse when scrolling reverse', () => {\n\t\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Enter, false);\n\t\t\texpect(event.direction).toBe('reverse');\n\t\t});\n\t});\n\n\ttest('preserves target and type', () => {\n\t\tconst event = new ScrollMagicEvent(fakeTarget, EventType.Progress, true);\n\t\texpect(event.target).toBe(fakeTarget);\n\t\texpect(event.type).toBe('progress');\n\t});\n\n\ttest('enum values match their string literals', () => {\n\t\texpect(EventType.Enter).toBe('enter');\n\t\texpect(EventType.Leave).toBe('leave');\n\t\texpect(EventType.Progress).toBe('progress');\n\t\texpect(EventLocation.Start).toBe('start');\n\t\texpect(EventLocation.Inside).toBe('inside');\n\t\texpect(EventLocation.End).toBe('end');\n\t\texpect(ScrollDirection.Forward).toBe('forward');\n\t\texpect(ScrollDirection.Reverse).toBe('reverse');\n\t});\n});\n"
  },
  {
    "path": "tests/unit/agnosticValues.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { agnosticProps, agnosticValues } from '../../src/util/agnosticValues';\n\ndescribe('agnosticProps', () => {\n\ttest('returns vertical props when vertical=true', () => {\n\t\tconst props = agnosticProps(true);\n\t\texpect(props.start).toBe('top');\n\t\texpect(props.end).toBe('bottom');\n\t\texpect(props.size).toBe('height');\n\t\texpect(props.clientSize).toBe('clientHeight');\n\t\texpect(props.scrollSize).toBe('scrollHeight');\n\t\texpect(props.axis).toBe('y');\n\t});\n\n\ttest('returns horizontal props when vertical=false', () => {\n\t\tconst props = agnosticProps(false);\n\t\texpect(props.start).toBe('left');\n\t\texpect(props.end).toBe('right');\n\t\texpect(props.size).toBe('width');\n\t\texpect(props.clientSize).toBe('clientWidth');\n\t\texpect(props.scrollSize).toBe('scrollWidth');\n\t\texpect(props.axis).toBe('x');\n\t});\n});\n\ndescribe('agnosticValues', () => {\n\tconst rect = { top: 10, left: 20, bottom: 30, right: 40, height: 100, width: 200 };\n\n\ttest('extracts vertical values', () => {\n\t\tconst vals = agnosticValues(true, rect);\n\t\texpect(vals.start).toBe(10); // top\n\t\texpect(vals.end).toBe(30); // bottom\n\t\texpect(vals.size).toBe(100); // height\n\t});\n\n\ttest('extracts horizontal values', () => {\n\t\tconst vals = agnosticValues(false, rect);\n\t\texpect(vals.start).toBe(20); // left\n\t\texpect(vals.end).toBe(40); // right\n\t\texpect(vals.size).toBe(200); // width\n\t});\n\n\ttest('handles scroll container dimensions', () => {\n\t\tconst dims = { clientHeight: 500, clientWidth: 300, scrollHeight: 2000, scrollWidth: 600 };\n\t\tconst vVals = agnosticValues(true, dims);\n\t\texpect(vVals.clientSize).toBe(500);\n\t\texpect(vVals.scrollSize).toBe(2000);\n\n\t\tconst hVals = agnosticValues(false, dims);\n\t\texpect(hVals.clientSize).toBe(300);\n\t\texpect(hVals.scrollSize).toBe(600);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/getScrollContainerDimensions.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { getScrollContainerDimensions } from '../../src/util/getScrollContainerDimensions';\n\n// NOTE: jsdom's window doesn't pass `instanceof Window`, so window-branch behavior\n// (documentElement fallback, visualViewport) is covered by e2e tests.\n\ndescribe('getScrollContainerDimensions', () => {\n\ttest('returns all four dimension properties for an element', () => {\n\t\tconst el = document.createElement('div');\n\t\tObject.defineProperty(el, 'clientWidth', { value: 400 });\n\t\tObject.defineProperty(el, 'clientHeight', { value: 300 });\n\t\tObject.defineProperty(el, 'scrollWidth', { value: 800 });\n\t\tObject.defineProperty(el, 'scrollHeight', { value: 1200 });\n\t\tconst dims = getScrollContainerDimensions(el);\n\t\texpect(dims).toEqual({\n\t\t\tclientWidth: 400,\n\t\t\tclientHeight: 300,\n\t\t\tscrollWidth: 800,\n\t\t\tscrollHeight: 1200,\n\t\t});\n\t});\n\n\ttest('does not use visualViewport for element containers', () => {\n\t\tconst el = document.createElement('div');\n\t\tObject.defineProperty(el, 'clientHeight', { value: 300 });\n\t\tconst dims = getScrollContainerDimensions(el);\n\t\texpect(dims.clientHeight).toBe(300);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/getScrollPos.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { getScrollPos } from '../../src/util/getScrollPos';\n\n// NOTE: jsdom's window doesn't pass `instanceof Window`, so the window branch\n// of getScrollPos is covered by e2e tests. Here we test the element branch.\n\ndescribe('getScrollPos', () => {\n\ttest('returns scroll position for element', () => {\n\t\tconst el = document.createElement('div');\n\t\tObject.defineProperty(el, 'scrollTop', { value: 150, writable: true });\n\t\tObject.defineProperty(el, 'scrollLeft', { value: 75, writable: true });\n\t\tconst pos = getScrollPos(el);\n\t\texpect(pos).toEqual({ left: 75, top: 150 });\n\t});\n\n\ttest('returns { left: 0, top: 0 } for element with no scroll', () => {\n\t\tconst el = document.createElement('div');\n\t\tconst pos = getScrollPos(el);\n\t\texpect(pos).toEqual({ left: 0, top: 0 });\n\t});\n});\n"
  },
  {
    "path": "tests/unit/pickDifferencesFlat.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { pickDifferencesFlat } from '../../src/util/pickDifferencesFlat';\n\ndescribe('pickDifferencesFlat', () => {\n\ttest('returns only changed properties', () => {\n\t\tconst full = { a: 1, b: 2, c: 3 };\n\t\tconst part = { a: 1, b: 99 };\n\t\texpect(pickDifferencesFlat(part, full)).toEqual({ b: 99 });\n\t});\n\n\ttest('returns empty object when nothing changed', () => {\n\t\tconst full = { a: 1, b: 2 };\n\t\tconst part = { a: 1, b: 2 };\n\t\texpect(pickDifferencesFlat(part, full)).toEqual({});\n\t});\n\n\ttest('returns all properties when everything changed', () => {\n\t\tconst full = { a: 1, b: 2 };\n\t\tconst part = { a: 10, b: 20 };\n\t\texpect(pickDifferencesFlat(part, full)).toEqual({ a: 10, b: 20 });\n\t});\n\n\ttest('uses strict equality (not deep)', () => {\n\t\tconst obj = { x: 1 };\n\t\tconst full = { a: obj };\n\t\tconst part = { a: { x: 1 } }; // different reference, same content\n\t\texpect(pickDifferencesFlat(part, full)).toEqual({ a: { x: 1 } });\n\t});\n\n\ttest('handles empty partial', () => {\n\t\texpect(pickDifferencesFlat({}, { a: 1 })).toEqual({});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/processProperties.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { processProperties } from '../../src/util/processProperties';\nimport { ScrollMagicError } from '../../src/ScrollMagicError';\n\ndescribe('processProperties', () => {\n\ttest('applies processor to matching property', () => {\n\t\tconst result = processProperties({ count: '5' }, { count: (v: string) => parseInt(v, 10) });\n\t\texpect(result).toEqual({ count: 5 });\n\t});\n\n\ttest('passes through properties without a processor', () => {\n\t\tconst result = processProperties({ a: 1, b: 2 }, { a: (v: number) => v * 10 });\n\t\texpect(result).toEqual({ a: 10, b: 2 });\n\t});\n\n\ttest('throws ScrollMagicError when processor fails', () => {\n\t\tconst processors = {\n\t\t\tval: () => {\n\t\t\t\tthrow new Error('nope');\n\t\t\t},\n\t\t};\n\t\texpect(() => processProperties({ val: 'x' }, processors)).toThrow(ScrollMagicError);\n\t});\n\n\ttest('error message includes property name and value', () => {\n\t\tconst processors = {\n\t\t\tmyProp: () => {\n\t\t\t\tthrow new Error('nope');\n\t\t\t},\n\t\t};\n\t\texpect(() => processProperties({ myProp: 'bad' }, processors)).toThrow(/Invalid value bad for myProp/);\n\t});\n\n\ttest('appends original message when processor throws ScrollMagicError', () => {\n\t\tconst processors = {\n\t\t\tval: () => {\n\t\t\t\tthrow new ScrollMagicError('must be positive');\n\t\t\t},\n\t\t};\n\t\texpect(() => processProperties({ val: -1 }, processors)).toThrow(/must be positive/);\n\t});\n\n\ttest('uses custom error message formatter when provided', () => {\n\t\tconst processors = {\n\t\t\tx: () => {\n\t\t\t\tthrow new Error('boom');\n\t\t\t},\n\t\t};\n\t\tconst formatter = (value: unknown, prop: unknown) => `Broken: ${String(prop)}=${String(value)}.`;\n\t\texpect(() => processProperties({ x: 42 }, processors, formatter)).toThrow('Broken: x=42.');\n\t});\n\n\ttest('chains original error as cause', () => {\n\t\tconst original = new Error('root cause');\n\t\tconst processors = {\n\t\t\tx: () => {\n\t\t\t\tthrow original;\n\t\t\t},\n\t\t};\n\t\ttry {\n\t\t\tprocessProperties({ x: 1 }, processors);\n\t\t\texpect.unreachable();\n\t\t} catch (e) {\n\t\t\texpect(e).toBeInstanceOf(ScrollMagicError);\n\t\t\texpect((e as ScrollMagicError).cause).toBe(original);\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "tests/unit/rafQueue.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach } from 'vitest';\nimport { rafQueue } from '../../src/util/rafQueue';\n\nconst flushable = (fn = vi.fn()) => ({ execute: fn });\n\ndescribe('Scheduler', () => {\n\tbeforeEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('schedule requests a single rAF regardless of item count', () => {\n\t\tconst spy = vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\tconst a = flushable();\n\t\tconst b = flushable();\n\t\trafQueue.schedule(a);\n\t\trafQueue.schedule(b);\n\t\texpect(spy).toHaveBeenCalledTimes(1);\n\t\t// cleanup\n\t\trafQueue.flush();\n\t});\n\n\ttest('flush executes all dirty items', () => {\n\t\tconst a = flushable();\n\t\tconst b = flushable();\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\trafQueue.schedule(a);\n\t\trafQueue.schedule(b);\n\t\trafQueue.flush();\n\t\texpect(a.execute).toHaveBeenCalledOnce();\n\t\texpect(b.execute).toHaveBeenCalledOnce();\n\t});\n\n\ttest('flush cancels pending rAF', () => {\n\t\tconst cancelSpy = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {});\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(42);\n\t\trafQueue.schedule(flushable());\n\t\trafQueue.flush();\n\t\texpect(cancelSpy).toHaveBeenCalledWith(42);\n\t});\n\n\ttest('after flush, dirty set is empty — subsequent rAF is a no-op', () => {\n\t\tconst a = flushable();\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\trafQueue.schedule(a);\n\t\trafQueue.flush();\n\t\ta.execute.mockClear();\n\t\t// simulate rAF callback (would have been cancelled, but let's verify no-op)\n\t\trafQueue.flush();\n\t\texpect(a.execute).not.toHaveBeenCalled();\n\t});\n\n\ttest('unschedule prevents execution', () => {\n\t\tconst a = flushable();\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\trafQueue.schedule(a);\n\t\trafQueue.unschedule(a);\n\t\trafQueue.flush();\n\t\texpect(a.execute).not.toHaveBeenCalled();\n\t});\n\n\ttest('multiple schedule calls for same item execute it only once', () => {\n\t\tconst a = flushable();\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\trafQueue.schedule(a);\n\t\trafQueue.schedule(a);\n\t\trafQueue.schedule(a);\n\t\trafQueue.flush();\n\t\texpect(a.execute).toHaveBeenCalledOnce();\n\t});\n\n\ttest('items scheduled during flush get a new rAF', () => {\n\t\tconst rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\tconst b = flushable();\n\t\tconst a = flushable(vi.fn(() => rafQueue.schedule(b)));\n\t\trafQueue.schedule(a);\n\t\trafSpy.mockClear();\n\t\trafQueue.flush();\n\t\texpect(a.execute).toHaveBeenCalledOnce();\n\t\t// b was scheduled during flush — should NOT have been executed in the same flush\n\t\texpect(b.execute).not.toHaveBeenCalled();\n\t\t// but a new rAF should have been requested\n\t\texpect(rafSpy).toHaveBeenCalledTimes(1);\n\t\t// cleanup\n\t\trafQueue.flush();\n\t});\n\n\ttest('rAF callback triggers flush', () => {\n\t\tlet rafCallback: FrameRequestCallback | undefined;\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {\n\t\t\trafCallback = cb;\n\t\t\treturn 1;\n\t\t});\n\t\tconst a = flushable();\n\t\trafQueue.schedule(a);\n\t\texpect(a.execute).not.toHaveBeenCalled();\n\t\trafCallback!(0);\n\t\texpect(a.execute).toHaveBeenCalledOnce();\n\t});\n});\n"
  },
  {
    "path": "tests/unit/registerEvent.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { registerEvent } from '../../src/util/registerEvent';\n\ndescribe('registerEvent', () => {\n\ttest('listener receives dispatched events', () => {\n\t\tconst target = document.createElement('div');\n\t\tlet received = false;\n\t\tregisterEvent(target, 'click', () => {\n\t\t\treceived = true;\n\t\t});\n\t\ttarget.dispatchEvent(new Event('click'));\n\t\texpect(received).toBe(true);\n\t});\n\n\ttest('returned function removes the listener', () => {\n\t\tconst target = document.createElement('div');\n\t\tlet callCount = 0;\n\t\tconst remove = registerEvent(target, 'click', () => {\n\t\t\tcallCount++;\n\t\t});\n\t\ttarget.dispatchEvent(new Event('click'));\n\t\tremove();\n\t\ttarget.dispatchEvent(new Event('click'));\n\t\texpect(callCount).toBe(1);\n\t});\n\n\ttest('respects listener options', () => {\n\t\tconst target = document.createElement('div');\n\t\tlet callCount = 0;\n\t\tregisterEvent(\n\t\t\ttarget,\n\t\t\t'click',\n\t\t\t() => {\n\t\t\t\tcallCount++;\n\t\t\t},\n\t\t\t{ once: true }\n\t\t);\n\t\ttarget.dispatchEvent(new Event('click'));\n\t\ttarget.dispatchEvent(new Event('click'));\n\t\texpect(callCount).toBe(1);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/sanitizeProperties.test.ts",
    "content": "import { describe, test, expect, vi, afterEach } from 'vitest';\nimport { sanitizeProperties } from '../../src/util/sanitizeProperties';\n\ndescribe('sanitizeProperties', () => {\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\tconst defaults = { name: '', age: 0, active: false };\n\n\ttest('keeps properties that exist in defaults', () => {\n\t\tconst result = sanitizeProperties({ name: 'Alice', age: 30 }, defaults);\n\t\texpect(result).toEqual({ name: 'Alice', age: 30 });\n\t});\n\n\ttest('removes properties not in defaults', () => {\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tconst result = sanitizeProperties({ name: 'Alice', unknown: 'value' } as never, defaults);\n\t\texpect(result).toEqual({ name: 'Alice' });\n\t\texpect('unknown' in result).toBe(false);\n\t});\n\n\ttest('warns about unknown properties in dev mode', () => {\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tsanitizeProperties({ name: 'Alice', foo: 1, bar: 2 } as never, defaults);\n\t\texpect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('foo'));\n\t\texpect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('bar'));\n\t});\n\n\ttest('returns empty object when all properties are unknown', () => {\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tconst result = sanitizeProperties({ x: 1 } as never, defaults);\n\t\texpect(result).toEqual({});\n\t});\n\n\ttest('returns empty object for empty input', () => {\n\t\tconst result = sanitizeProperties({} as never, defaults);\n\t\texpect(result).toEqual({});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/sharedResizeObserver.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { rafQueue } from '../../src/util/rafQueue';\nimport { observeResize } from '../../src/util/sharedResizeObserver';\n\n// Mock ResizeObserver — the shared observer creates it lazily, so this runs before first use.\nconst observeMock = vi.fn();\nconst unobserveMock = vi.fn();\nlet roCallback: ResizeObserverCallback;\n\nclass MockResizeObserver {\n\tconstructor(cb: ResizeObserverCallback) {\n\t\troCallback = cb;\n\t}\n\tobserve = observeMock;\n\tunobserve = unobserveMock;\n\tdisconnect = vi.fn();\n}\n\nvi.stubGlobal('ResizeObserver', MockResizeObserver);\n\nconst makeElement = () => document.createElement('div');\n\n// Helper to simulate a resize entry for an element\nconst simulateResize = (...elements: Element[]) => {\n\tconst entries = elements.map(target => ({ target }) as ResizeObserverEntry);\n\troCallback(entries, {} as ResizeObserver);\n};\n\ndescribe('sharedResizeObserver', () => {\n\tbeforeEach(() => {\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockReturnValue(1);\n\t\tvi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {});\n\t\tobserveMock.mockClear();\n\t\tunobserveMock.mockClear();\n\t});\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('observeResize starts observing the element', () => {\n\t\tconst el = makeElement();\n\t\tconst cleanup = observeResize(el, vi.fn());\n\t\texpect(observeMock).toHaveBeenCalledWith(el);\n\t\tcleanup();\n\t});\n\n\ttest('multiple callbacks for same element: element observed only once', () => {\n\t\tconst el = makeElement();\n\t\tconst c1 = observeResize(el, vi.fn());\n\t\tconst c2 = observeResize(el, vi.fn());\n\t\texpect(observeMock).toHaveBeenCalledTimes(1);\n\t\tc1();\n\t\tc2();\n\t});\n\n\ttest('all callbacks for an element fire on resize', () => {\n\t\tconst el = makeElement();\n\t\tconst cbA = vi.fn();\n\t\tconst cbB = vi.fn();\n\t\tconst c1 = observeResize(el, cbA);\n\t\tconst c2 = observeResize(el, cbB);\n\t\tsimulateResize(el);\n\t\texpect(cbA).toHaveBeenCalledOnce();\n\t\texpect(cbB).toHaveBeenCalledOnce();\n\t\tc1();\n\t\tc2();\n\t});\n\n\ttest('cleanup removes callback; element unobserved when last callback removed', () => {\n\t\tconst el = makeElement();\n\t\tconst cbA = vi.fn();\n\t\tconst cbB = vi.fn();\n\t\tconst cleanupA = observeResize(el, cbA);\n\t\tconst cleanupB = observeResize(el, cbB);\n\n\t\tcleanupA();\n\t\texpect(unobserveMock).not.toHaveBeenCalled(); // still has cbB\n\n\t\tsimulateResize(el);\n\t\texpect(cbA).not.toHaveBeenCalled(); // removed\n\t\texpect(cbB).toHaveBeenCalledOnce(); // still active\n\n\t\tcleanupB();\n\t\texpect(unobserveMock).toHaveBeenCalledWith(el); // last callback removed\n\t});\n\n\ttest('cleanup is idempotent', () => {\n\t\tconst el = makeElement();\n\t\tconst cleanup = observeResize(el, vi.fn());\n\t\tcleanup();\n\t\texpect(unobserveMock).toHaveBeenCalledTimes(1);\n\t\tcleanup(); // second call should be no-op\n\t\texpect(unobserveMock).toHaveBeenCalledTimes(1);\n\t});\n\n\ttest('rafQueue.flush is called after all callbacks', () => {\n\t\tconst flushSpy = vi.spyOn(rafQueue, 'flush');\n\t\tconst el = makeElement();\n\t\tconst cb = vi.fn();\n\t\tconst cleanup = observeResize(el, cb);\n\t\tsimulateResize(el);\n\t\texpect(cb).toHaveBeenCalledOnce();\n\t\texpect(flushSpy).toHaveBeenCalledOnce();\n\t\tcleanup();\n\t});\n\n\ttest('resize entries for multiple elements route correctly', () => {\n\t\tconst el1 = makeElement();\n\t\tconst el2 = makeElement();\n\t\tconst cb1 = vi.fn();\n\t\tconst cb2 = vi.fn();\n\t\tconst c1 = observeResize(el1, cb1);\n\t\tconst c2 = observeResize(el2, cb2);\n\n\t\tsimulateResize(el1);\n\t\texpect(cb1).toHaveBeenCalledOnce();\n\t\texpect(cb2).not.toHaveBeenCalled();\n\n\t\tcb1.mockClear();\n\t\tsimulateResize(el1, el2);\n\t\texpect(cb1).toHaveBeenCalledOnce();\n\t\texpect(cb2).toHaveBeenCalledOnce();\n\n\t\tc1();\n\t\tc2();\n\t});\n});\n"
  },
  {
    "path": "tests/unit/throttleRaf.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { throttleRaf } from '../../src/util/throttleRaf';\n\ndescribe('throttleRaf', () => {\n\tlet rafCallbacks: Map<number, FrameRequestCallback>;\n\tlet nextId: number;\n\n\tbeforeEach(() => {\n\t\trafCallbacks = new Map();\n\t\tnextId = 1;\n\t\tvi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {\n\t\t\tconst id = nextId++;\n\t\t\trafCallbacks.set(id, cb);\n\t\t\treturn id;\n\t\t});\n\t\tvi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {\n\t\t\trafCallbacks.delete(id);\n\t\t});\n\t});\n\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\tconst flushRaf = () => {\n\t\tconst pending = [...rafCallbacks.values()];\n\t\trafCallbacks.clear();\n\t\tfor (const cb of pending) {\n\t\t\tcb(performance.now());\n\t\t}\n\t};\n\n\ttest('does not call function synchronously', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled();\n\t\texpect(fn).not.toHaveBeenCalled();\n\t});\n\n\ttest('calls function on next animation frame', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled();\n\t\tflushRaf();\n\t\texpect(fn).toHaveBeenCalledOnce();\n\t});\n\n\ttest('collapses multiple calls into a single execution per frame', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled();\n\t\tthrottled();\n\t\tthrottled();\n\t\tflushRaf();\n\t\texpect(fn).toHaveBeenCalledOnce();\n\t});\n\n\ttest('can schedule again after frame fires', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled();\n\t\tflushRaf();\n\t\tthrottled();\n\t\tflushRaf();\n\t\texpect(fn).toHaveBeenCalledTimes(2);\n\t});\n\n\ttest('cancel prevents pending execution', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled();\n\t\tthrottled.cancel();\n\t\tflushRaf();\n\t\texpect(fn).not.toHaveBeenCalled();\n\t});\n\n\ttest('can schedule again after cancel', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled();\n\t\tthrottled.cancel();\n\t\tthrottled();\n\t\tflushRaf();\n\t\texpect(fn).toHaveBeenCalledOnce();\n\t});\n\n\ttest('passes arguments from the first call in the batch', () => {\n\t\tconst fn = vi.fn();\n\t\tconst throttled = throttleRaf(fn);\n\t\tthrottled('first');\n\t\tthrottled('second'); // dropped — already scheduled\n\t\tflushRaf();\n\t\texpect(fn).toHaveBeenCalledWith('first');\n\t});\n\n\ttest('preserves this context', () => {\n\t\tconst context = { name: 'ctx', called: false };\n\t\tconst throttled = throttleRaf(function (this: typeof context) {\n\t\t\tthis.called = true;\n\t\t});\n\t\tthrottled.call(context);\n\t\tflushRaf();\n\t\texpect(context.called).toBe(true);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/transformObject.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { transformObject } from '../../src/util/transformObject';\n\ndescribe('transformObject', () => {\n\ttest('transforms values using the provided function', () => {\n\t\tconst input = { a: 1, b: 2, c: 3 };\n\t\tconst result = transformObject(input, ([key, value]) => [key, value * 10]);\n\t\texpect(result).toEqual({ a: 10, b: 20, c: 30 });\n\t});\n\n\ttest('transforms keys using the provided function', () => {\n\t\tconst input = { a: 1, b: 2 };\n\t\tconst result = transformObject(input, ([key, value]) => [`${String(key)}_new`, value]);\n\t\texpect(result).toEqual({ a_new: 1, b_new: 2 });\n\t});\n\n\ttest('returns empty object for empty input', () => {\n\t\tconst result = transformObject({}, ([key, value]) => [key, value]);\n\t\texpect(result).toEqual({});\n\t});\n\n\ttest('can transform both keys and values simultaneously', () => {\n\t\tconst input = { x: 'hello', y: 'world' };\n\t\tconst result = transformObject(input, ([key, value]) => [\n\t\t\tString(key).toUpperCase(),\n\t\t\tString(value).toUpperCase(),\n\t\t]);\n\t\texpect(result).toEqual({ X: 'HELLO', Y: 'WORLD' });\n\t});\n});\n"
  },
  {
    "path": "tests/unit/transformers.test.ts",
    "content": "import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';\nimport {\n\tnumberToPercString,\n\tunitStringToPixelConverter,\n\ttoPixelConverter,\n\tselectorToSingleElement,\n\ttoSvgOrHtmlElement,\n\ttoValidContainer,\n\tskipNull,\n} from '../../src/util/transformers';\n\ndescribe('numberToPercString', () => {\n\ttest('converts decimal to percentage string', () => {\n\t\texpect(numberToPercString(0.5, 2)).toBe('50.00%');\n\t\texpect(numberToPercString(1, 0)).toBe('100%');\n\t\texpect(numberToPercString(0, 2)).toBe('0.00%');\n\t});\n\n\ttest('handles negative values', () => {\n\t\texpect(numberToPercString(-0.25, 1)).toBe('-25.0%');\n\t});\n});\n\ndescribe('unitStringToPixelConverter', () => {\n\ttest('parses px values', () => {\n\t\tconst conv = unitStringToPixelConverter('20px');\n\t\texpect(conv(100)).toBe(20);\n\t\texpect(conv(500)).toBe(20); // px is absolute\n\t});\n\n\ttest('parses percentage values', () => {\n\t\tconst conv = unitStringToPixelConverter('50%');\n\t\texpect(conv(200)).toBe(100);\n\t\texpect(conv(400)).toBe(200);\n\t});\n\n\ttest('parses negative values', () => {\n\t\tconst conv = unitStringToPixelConverter('-10px');\n\t\texpect(conv(100)).toBe(-10);\n\t});\n\n\ttest('throws on invalid string', () => {\n\t\texpect(() => unitStringToPixelConverter('abc')).toThrow();\n\t});\n});\n\ndescribe('toPixelConverter', () => {\n\ttest('wraps number as constant converter', () => {\n\t\tconst conv = toPixelConverter(42);\n\t\texpect(conv(0)).toBe(42);\n\t\texpect(conv(999)).toBe(42);\n\t});\n\n\ttest('parses unit string', () => {\n\t\tconst conv = toPixelConverter('25%');\n\t\texpect(conv(200)).toBe(50);\n\t});\n\n\ttest('handles \"here\" shorthand (0%)', () => {\n\t\tconst conv = toPixelConverter('here');\n\t\texpect(conv(200)).toBe(0);\n\t\texpect(conv(400)).toBe(0);\n\t});\n\n\ttest('handles \"center\" shorthand (50%)', () => {\n\t\tconst conv = toPixelConverter('center');\n\t\texpect(conv(200)).toBe(100);\n\t\texpect(conv(400)).toBe(200);\n\t});\n\n\ttest('handles \"opposite\" shorthand (100%)', () => {\n\t\tconst conv = toPixelConverter('opposite');\n\t\texpect(conv(200)).toBe(200);\n\t\texpect(conv(400)).toBe(400);\n\t});\n\n\ttest('accepts valid function', () => {\n\t\tconst fn = (size: number) => size * 2;\n\t\tconst conv = toPixelConverter(fn);\n\t\texpect(conv(50)).toBe(100);\n\t});\n\n\ttest('throws on function that does not return number', () => {\n\t\tconst fn = () => 'nope' as unknown as number;\n\t\texpect(() => toPixelConverter(fn)).toThrow('Function must return a number');\n\t});\n\n\ttest('throws on function that throws', () => {\n\t\tconst fn = () => {\n\t\t\tthrow new Error('boom');\n\t\t};\n\t\texpect(() => toPixelConverter(fn)).toThrow('Unsupported value type');\n\t});\n});\n\ndescribe('selectorToSingleElement', () => {\n\tbeforeEach(() => {\n\t\tdocument.body.innerHTML = '';\n\t});\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('returns the first matching element', () => {\n\t\tconst div = document.createElement('div');\n\t\tdiv.className = 'target';\n\t\tdocument.body.appendChild(div);\n\t\texpect(selectorToSingleElement('.target')).toBe(div);\n\t});\n\n\ttest('throws when no element matches', () => {\n\t\texpect(() => selectorToSingleElement('.nonexistent')).toThrow('No element found for selector .nonexistent');\n\t});\n\n\ttest('warns when selector matches multiple elements', () => {\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tdocument.body.innerHTML = '<div class=\"multi\"></div><div class=\"multi\"></div><div class=\"multi\"></div>';\n\t\tconst result = selectorToSingleElement('.multi');\n\t\texpect(result).toBe(document.querySelector('.multi'));\n\t\texpect(warnSpy).toHaveBeenCalledOnce();\n\t\texpect(warnSpy).toHaveBeenCalledWith(\n\t\t\texpect.stringContaining('matched 3 elements, using only the first')\n\t\t);\n\t});\n\n\ttest('does not warn when selector matches exactly one element', () => {\n\t\tconst warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tdocument.body.innerHTML = '<div id=\"unique\"></div>';\n\t\tselectorToSingleElement('#unique');\n\t\texpect(warnSpy).not.toHaveBeenCalled();\n\t});\n});\n\ndescribe('toSvgOrHtmlElement', () => {\n\tbeforeEach(() => {\n\t\tdocument.body.innerHTML = '';\n\t});\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\ttest('accepts an HTMLElement that is in the document', () => {\n\t\tconst div = document.createElement('div');\n\t\tdocument.body.appendChild(div);\n\t\texpect(toSvgOrHtmlElement(div)).toBe(div);\n\t});\n\n\ttest('accepts an SVGElement that is in the document', () => {\n\t\tconst svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n\t\tdocument.body.appendChild(svg);\n\t\texpect(toSvgOrHtmlElement(svg)).toBe(svg);\n\t});\n\n\ttest('resolves a CSS selector to the matching element', () => {\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tconst div = document.createElement('div');\n\t\tdiv.id = 'target';\n\t\tdocument.body.appendChild(div);\n\t\texpect(toSvgOrHtmlElement('#target')).toBe(div);\n\t});\n\n\ttest('throws for an element not in the document', () => {\n\t\tconst detached = document.createElement('div');\n\t\texpect(() => toSvgOrHtmlElement(detached)).toThrow('Invalid element');\n\t});\n\n\ttest('throws for a non-HTML/SVG element', () => {\n\t\t// Comment nodes, etc. — anything not HTML or SVG\n\t\tconst comment = document.createComment('hi') as unknown as Element;\n\t\texpect(() => toSvgOrHtmlElement(comment)).toThrow('Invalid element');\n\t});\n});\n\ndescribe('toValidContainer', () => {\n\tbeforeEach(() => {\n\t\tdocument.body.innerHTML = '';\n\t});\n\n\t// NOTE: window pass-through relies on `instanceof Window` which fails in jsdom.\n\t// That path is covered by e2e tests.\n\n\ttest('accepts an HTMLElement in the document', () => {\n\t\tconst div = document.createElement('div');\n\t\tdocument.body.appendChild(div);\n\t\texpect(toValidContainer(div)).toBe(div);\n\t});\n\n\ttest('rejects an SVGElement as container', () => {\n\t\tconst svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n\t\tdocument.body.appendChild(svg);\n\t\texpect(() => toValidContainer(svg)).toThrow(\"Can't use SVG as container\");\n\t});\n\n\ttest('resolves a CSS selector to the container element', () => {\n\t\tvi.spyOn(console, 'warn').mockImplementation(() => {});\n\t\tconst div = document.createElement('div');\n\t\tdiv.id = 'container';\n\t\tdocument.body.appendChild(div);\n\t\texpect(toValidContainer('#container')).toBe(div);\n\t});\n});\n\ndescribe('skipNull', () => {\n\ttest('calls function when value is not null', () => {\n\t\tconst double = skipNull((n: number) => n * 2);\n\t\texpect(double(5)).toBe(10);\n\t});\n\n\ttest('returns null when value is null', () => {\n\t\tconst double = skipNull((n: number) => n * 2);\n\t\texpect(double(null)).toBeNull();\n\t});\n});\n"
  },
  {
    "path": "tests/unit/typeguards.test.ts",
    "content": "import { describe, test, expect } from 'vitest';\nimport { isWindow, isHTMLElement, isSVGElement } from '../../src/util/typeguards';\n\ndescribe('isWindow', () => {\n\t// NOTE: jsdom's window does not pass `instanceof Window`, so the positive case\n\t// is covered by e2e tests in a real browser. Here we only test rejection.\n\ttest('returns false for non-window values', () => {\n\t\texpect(isWindow(null)).toBe(false);\n\t\texpect(isWindow(undefined)).toBe(false);\n\t\texpect(isWindow(document.createElement('div'))).toBe(false);\n\t\texpect(isWindow({})).toBe(false);\n\t});\n});\n\ndescribe('isHTMLElement', () => {\n\ttest('returns true for HTML elements', () => {\n\t\texpect(isHTMLElement(document.createElement('div'))).toBe(true);\n\t\texpect(isHTMLElement(document.createElement('span'))).toBe(true);\n\t\texpect(isHTMLElement(document.body)).toBe(true);\n\t});\n\n\ttest('returns false for non-HTML values', () => {\n\t\texpect(isHTMLElement(null)).toBe(false);\n\t\texpect(isHTMLElement(window)).toBe(false);\n\t\texpect(isHTMLElement({})).toBe(false);\n\t\texpect(isHTMLElement(document.createElementNS('http://www.w3.org/2000/svg', 'rect'))).toBe(false);\n\t});\n});\n\ndescribe('isSVGElement', () => {\n\ttest('returns true for SVG elements', () => {\n\t\texpect(isSVGElement(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))).toBe(true);\n\t\texpect(isSVGElement(document.createElementNS('http://www.w3.org/2000/svg', 'rect'))).toBe(true);\n\t});\n\n\ttest('returns false for non-SVG values', () => {\n\t\texpect(isSVGElement(null)).toBe(false);\n\t\texpect(isSVGElement(document.createElement('div'))).toBe(false);\n\t\texpect(isSVGElement(window)).toBe(false);\n\t});\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\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\": \"ES2020\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"strict\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"resolveJsonModule\": true,\n\t\t\"declaration\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"sourceMap\": true,\n\t\t\"inlineSources\": true\n\t},\n\t\"include\": [\"src/**/*\", \"tests/**/*\"]\n}\n"
  },
  {
    "path": "typedoc.json",
    "content": "{\n\t\"$schema\": \"https://typedoc.org/schema.json\",\n\t\"entryPoints\": [\"src/index.ts\", \"src/util.ts\"],\n\t\"out\": \"docs/tsdoc\",\n\t\"tsconfig\": \"./tsconfig.json\",\n\t\"name\": \"ScrollMagic\",\n\t\"excludePrivate\": true,\n\t\"excludeInternal\": true\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\nimport { playwright } from '@vitest/browser-playwright';\n\n// `vitest` (watch mode) → show browser; `vitest run` (single-run) → headless\ndeclare const process: { argv: string[] };\nconst isSingleRun = process.argv.includes('run');\n\nexport default defineConfig({\n\ttest: {\n\t\tprojects: [\n\t\t\t{\n\t\t\t\ttest: {\n\t\t\t\t\tname: 'unit',\n\t\t\t\t\tinclude: [`tests/unit/**/*.test.ts`],\n\t\t\t\t\tenvironment: 'jsdom',\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\ttest: {\n\t\t\t\t\tname: 'e2e',\n\t\t\t\t\tinclude: [`tests/e2e/**/*.test.ts`],\n\t\t\t\t\tbrowser: {\n\t\t\t\t\t\tenabled: true,\n\t\t\t\t\t\theadless: isSingleRun,\n\t\t\t\t\t\tprovider: playwright(),\n\t\t\t\t\t\tinstances: [{ browser: 'chromium' }],\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t],\n\t},\n});\n"
  }
]