# powered-ad-config

Configure Nine masthead/takeover ad formats from inside the ad iframe.

## Install

```bash
npm install powered-ad-config
```

## Quick Start

```js
import initAd from 'powered-ad-config';

if (window.frameElement) {
    const cleanup = initAd({
        adFormat: 'fireplace',
        stickyOffset: 0,
    });

    // Call when unmounting / replacing the ad
    // cleanup();
}
```

## Real-World Pattern (16145-paramount fireplace)

In your fireplace project, you load from CDN first and fall back to npm import if needed.

```js
import poweredAdConfigNpm from 'powered-ad-config';

export async function loadAdConfig() {
  const expectedVersion = window.poweredAdConfigExpectedVersion || null;
  const cdnUrl = new URL('https://cdn.jsdelivr.net/npm/powered-ad-config@latest/dist/powered-ad-config.umd.js');

  cdnUrl.searchParams.set('v', expectedVersion || poweredAdConfigNpm.version || 'latest');

  try {
    await new Promise((resolve, reject) => {
      const script = document.createElement('script');
      // Intentionally using @latest so runtime always picks up newest published powered-ad-config release.
      // Note: this can change behavior between deployments if a new version is published.
      // The version query busts stale browser/ad-frame caches during validation.
      script.src = cdnUrl.href;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });

    if (typeof window.poweredAdConfig === 'function') {
      if (expectedVersion && window.poweredAdConfig.version !== expectedVersion) {
        console.warn(
          `[powered-ad-config] Stale CDN cache detected: loaded v${window.poweredAdConfig.version}, expected v${expectedVersion}. Falling back to bundled npm copy.`
        );
        return poweredAdConfigNpm;
      }

      console.log(`CDN loaded successfully: v${window.poweredAdConfig.version || 'unknown'}`);
      return window.poweredAdConfig;
    }

    throw new Error('poweredAdConfig global not available');
  } catch (error) {
    console.error('Failed to load from CDN, falling back to npm package:', error);
    return poweredAdConfigNpm;
  }
}
```

Then initialize from your component `onMount` (or equivalent lifecycle hook):

```js
async function initAdConfig() {
    const adConfig = await loadAdConfig();

    if (adConfig) {
        if (window.frameElement) {
            adConfig({
                adFormat: 'fireplace',
                stickyOffset: window.customScrollTop,
            });
        }
    } else {
        console.error('Failed to load adConfig');
    }
}

onMount(async () => {
    await initAdConfig();
});
```

This pattern is useful when you want runtime fallback resilience while still bundling npm for local/dev reliability.

If you use `@latest`, runtime behavior can change between deployments as soon as a new package version is published.
If you need deterministic behavior, pin an explicit version in the CDN URL.
During QA, set `window.poweredAdConfigExpectedVersion = '0.1.53'` before calling `loadAdConfig()` or pin the exact package version so an already-cached ad iframe cannot silently keep executing an older `@latest` response.

## API

### initAd(options)

Initializes one of the supported formats and returns a cleanup function.

```js
const cleanup = initAd({
    adFormat,
    stickyOffset,
    logoPosX,
    logoHeight,
    logoSmallHeight,
});
```

#### Options

| Option | Type | Default | Used by | Description |
|---|---|---:|---|---|
| `adFormat` | `string` | `''` | all | Format selector. Supported: `fireplace`, `fireplace-collant`, `logomorph`, `interscroller`, `liquidframe`, `mobilefireplace`, `bottomscroller` |
| `stickyOffset` | `number` | `0` | fireplace, fireplace-collant | Extra offset added to sticky/scroll threshold calculations. On the modern Nine/WWOS shell, `0` means no initial masthead scroll. |
| `logoPosX` | `number` | `0` | logomorph | Scroll threshold where logo transitions to small state |
| `logoHeight` | `number` | `0` | logomorph | Height (px) for large logo state |
| `logoSmallHeight` | `number` | `0` | logomorph | Height (px) for small logo state |

### Return value

- `() => void`: Removes scroll listeners and pending timers added by the selected format.

## Supported Formats

- `fireplace`: Billboard/fireplace behavior with sticky scroll transforms.
- `fireplace-collant`: Fireplace behavior with collant transition and content masking.
- `logomorph`: Large-to-small logo takeover behavior on scroll.
- `interscroller`: Full-viewport scroll-through ad with sticky bands, GSAP stretch animation, and scroll progress callbacks.
- `liquidframe`: Viewport-adaptive mobile banner slot expansion.
- `mobilefireplace`: Mobile wallpaper takeover with in-flow or sticky billboard.
- `bottomscroller`: Bottom-fixed mobile banner (180px → 50px collapse) with scroll-gated timer.

---

## Mobile Fireplace

Creates a mobile wallpaper takeover with an optional sticky billboard. When enabled, the billboard releases at the first configured time or scroll-distance limit.

| Option | Type | Default | Description |
|---|---|---:|---|
| `sticky` | `boolean` | `false` | Keeps the billboard fixed after it reaches the viewport top |
| `stickyDurationMs` | `number` | `5000` | Maximum sticky duration in milliseconds; set to `0` to disable the time limit |
| `stickyScrollDistance` | `number` | `600` | Maximum sticky scroll distance in px; set to `0` to disable the scroll limit |
| `stickyTop` | `number` | `0` | Sticky billboard top offset in px |

The billboard iframe receives `mobilefireplace:sticky` messages with `{ active, progress, remainingMs }`, allowing creative UI such as a countdown ring to stay synchronized with the configured duration.

---

## Bottomscroller

Pins a 320×50 mobile banner iframe to the bottom of the viewport at `expandedHeight` (default 180px). Once the user scrolls, a collapse clock runs (time and/or scroll distance — whichever limit is reached first). The bar animates to `collapsedHeight` (default 50px) and stays sticky. Page content receives matching `padding-bottom`.

### Quick start

```js
import initAd from 'powered-ad-config';

initAd({
  adFormat: 'bottomscroller',
  expandedHeight: 180,
  collapsedHeight: 50,
  collapseDurationMs: 8000,
  collapseScrollDistance: 5600,
  onAdapt() {
    startEntranceAnimation();
  },
  onProgress({ phase, progress, remainingMs, height }) {
    driveScrollScrub(progress);
  },
});
```

### CONFIG options

| Option | Type | Default | Description |
|---|---|---|---|
| `expandedHeight` | `number` | `180` | Initial bar height in px |
| `collapsedHeight` | `number` | `50` | Collapsed bar height in px |
| `zIndex` | `number` | `10000` | Shell z-index |
| `collapseDurationMs` | `number` | `8000` | Max expanded time while scrolling |
| `collapseScrollDistance` | `number` | `5600` | Max `scrollY` while expanded |
| `collapseTransitionMs` | `number` | `400` | Height transition duration |
| `onAdapt` | `function` | `null` | Fired once shell is mounted |
| `onProgress` | `function` | `null` | Fired each RAF tick with `{ phase, progress, remainingMs, height, active }` |

### postMessage

The creative iframe receives `bottomscroller:progress` messages with the same payload as `onProgress`.

---

## Interscroller

Expands the ad container to a configurable scroll height (e.g. `300vh`) and locks a full-viewport-width creative iframe behind the page content using `clip-path`. As the user scrolls, the ad reveals and the top/bottom bands animate accordingly.

### Quick start

```js
import initAd from 'powered-ad-config';

initAd({
  adFormat: 'interscroller',

  // optional overrides (see CONFIG table below)
  adScrollHeight: '200vh',
  onProgress({ scrollProgress, adExposure, viewportCoverage, pxRemaining }) {
    gsap.set(myElement, { y: scrollProgress * -100 });
  },
  thresholds: [
    { at: 0.2, onEnter: () => playVideo(), onLeave: () => pauseVideo() },
  ],
});
```

### CONFIG options

All options are optional. The defaults are shown below.

#### Ad scroll zone

| Option | Type | Default | Description |
|---|---|---|---|
| `adScrollHeight` | `string` | `'300vh'` | CSS height of the ad container. Controls how long the ad stays in view. `100vh` = standard; `200vh` = twice as long. Accepts any CSS length (`'150vh'`, `'600px'`, etc.). |

#### Bands

| Option | Type | Default | Description |
|---|---|---|---|
| `bandHeight` | `number` | `24` | Height in px of both the top and bottom bands. |
| `bandBg` | `string` | `'#222'` | Band background colour. |
| `bandColor` | `string` | `'#fff'` | Band text colour. |
| `bandFontSize` | `string` | `'10px'` | Band font size. |
| `bandFontFamily` | `string` | `'Arial, Helvetica, sans-serif'` | Band font family. |
| `bandLetterSpacing` | `string` | `'1px'` | Band letter spacing. |
| `labelTop` | `string` | `'Advertisement'` | Text shown in the top band. |
| `labelBottomResume` | `string` | `'Content resumes in {n}px'` | Bottom band text while scrolling through the ad. `{n}` is replaced with the live pixel count. |
| `labelBottomNow` | `string` | `'Content resumes now ↓'` | Bottom band text once the post-ad content enters the viewport. |
| `zIndexTop` | `number` | `9` | `z-index` of the top band (keep below the page header). |
| `zIndexBottom` | `number` | `8` | `z-index` of the bottom band. |

#### Stretch animation

The top band stretches downward as the two bands approach each other, then snaps back with an elastic ease.

| Option | Type | Default | Description |
|---|---|---|---|
| `snapPx` | `number` | `30` | Gap in px between the bands at which the elastic snap fires. |
| `snapOverlap` | `number` | `2` | Fires the snap this many px before `snapPx` is reached (trigger timing adjustment). |
| `stretchOverlap` | `number` | `2` | Starts the stretch this many px before the bands touch, and extends the band this many px past the touchpoint — eliminates the sub-pixel rendering gap. |
| `snapDuration` | `number` | `0.9` | Duration in seconds of the elastic return animation. |
| `snapEase` | `string` | `'cubic-bezier(0.175, 0.885, 0.32, 1.275)'` | CSS timing function for the snap. Any valid CSS `transition-timing-function` value works. |

### Scroll callbacks

#### `onEnter()` · `onExit()`

Lifecycle callbacks fired once per ad pass.

```js
onEnter() {
  // ad top band just entered the viewport — start intro animation
},
onExit() {
  // post-ad content entered — pause video, clean up, etc.
},
```

#### `onProgress(state)`

Fired on every scroll event while the page is within range of the ad.

```js
onProgress({ scrollProgress, adExposure, viewportCoverage, pxRemaining }) {
  // drive your scroll-synced animation here
}
```

| Property | Range | Description |
|---|---|---|
| `scrollProgress` | `0 → 1` | Overall position through the full scroll zone. `0` = ad top enters viewport, `1` = ad bottom exits. |
| `adExposure` | `0 → 1` | Fraction of the ad's total height (`adScrollHeight`) that has scrolled into view. Useful for a multi-screen creative (e.g. a 300vh mini-page) — at `0.33`, one-third of the creative has been seen. |
| `viewportCoverage` | `0 → 1` | Fraction of the screen currently filled by the ad. Useful for parallax, opacity, or scale effects tied to how "dominant" the ad is on screen. |
| `pxRemaining` | `≥ 0` | Pixels left to scroll until the ad clears the bottom band. Same value shown in the bottom band counter. |

#### `thresholds`

An array of trigger points keyed to `scrollProgress`. Each fires `onEnter` when scrolling forward past `at`, and `onLeave` when scrolling back — direction-aware, so rubber-banding and reverse scrolling work correctly.

```js
thresholds: [
  {
    at: 0.2,  // fires when scrollProgress crosses 20%
    onEnter: (progress) => playVideo(),
    onLeave: (progress) => pauseVideo(),
  },
  {
    at: 0.5,
    onEnter: () => triggerSecondAnimation(),
    // onLeave is optional
  },
],
```

| Property | Type | Description |
|---|---|---|
| `at` | `number` (0–1) | `scrollProgress` value at which to cross. |
| `onEnter` | `function(progress)` | Called when scrolling forward past `at`. |
| `onLeave` | `function(progress)` | Called when scrolling back below `at`. Optional. |

## Integration Notes

- Must run from inside an iframe (`window.frameElement` is required).
- Parent page access must be same-origin (the package reads and mutates parent DOM/CSS).
- Site-specific behavior is selected from the parent URL; unknown URLs no-op safely.
- Calling `initAd(...)` again automatically cleans up the previous setup.

## Example: Re-initialize on format change

```js
import initAd from 'powered-ad-config';

let cleanup = () => {};

function mount(format) {
    cleanup();
    cleanup = initAd({
        adFormat: format,
        stickyOffset: 0,
        logoPosX: 120,
        logoHeight: 250,
        logoSmallHeight: 100,
    });
}
```

## Development / Publish

Runtime baseline:

```text
# .nvmrc
24.19.0          # Node LTS Krypton → npm 11.17.0
```

Supply-chain defaults (`.npmrc`):

```ini
min-release-age=7
min-release-age-exclude[]=powered-ad-config
ignore-scripts=true
```

Because `ignore-scripts` is on, `postpublish` does **not** auto-run. After every publish:

```bash
nvm use          # 24.19.0
npm run build
npm publish
npm run purge    # required — jsDelivr CDN purge + verify
```

Downstream ad projects exclude `powered-ad-config` from the age gate, so same-day PAC pins resolve on npm ≥ 11.17 without a CLI override.

## Troubleshooting

- Nothing happens:
    - Verify code runs inside an iframe.
    - Verify iframe and parent page are same-origin.
    - Verify `adFormat` is one of the supported values.
- Positioning looks wrong:
    - Check host URL matches a known Nine site config.
    - Tune `stickyOffset` for fireplace/collant integrations.