# Scrub-driven Altitude Transitions Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the Altitude knob position *continuously* drive the scale transition — dragging the dial scrubs the morph video's `currentTime` and crossfades the two adjacent scale soundtracks by knob angle, holding wherever the knob stops, fully reversible.

**Architecture:** Introduce a continuous float **position** `pos` (integers = altitudes/detents, fractions = mid-morph blend). A small pure module (`scrub.js`) owns the math (position→segment, frac→currentTime, audio gains, integer-crossing detection); `app.js` becomes the DOM/event glue that drives `pos` from the dial, seeks the morph `<video>`, crossfades two `<audio>` elements, and commits/locks/re-rolls on integer crossings. Phase 1 builds the interaction against the *current* (sparse-GOP) morphs to validate feel; Phase 2 re-bakes all 154 morphs all-intra for smooth seeking.

**Tech Stack:** Vanilla JS (no framework, plain `<script>`), Node built-in `node --test` for pure-logic unit tests, Playwright for E2E, Python + ffmpeg/x264 for the morph re-bake (`simulator/build_pool_manifest.py`), git-LFS for media.

## Global Constraints

- **Spec contract:** `docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md` — implement its locked decisions verbatim.
- **In-between state:** hold the blend wherever the knob stops — **no auto-complete** (continuous-encoder model).
- **Turn-back:** scrubs the **same** segment morph in reverse; landing back on the start integer re-locks **exactly** the clip you left (fully reversible).
- **Destination randomness:** the destination altitude's clip is a random pick from its pool, **fixed for a single continuous gesture**, **re-rolled on each fresh approach** (each fresh entry into a segment from an integer).
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. **Tap a dial label:** auto-scrub to that altitude the shortest way around.
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you commit into a different one.
- **Canonical segment file (locked interpretation):** for the segment between altitude indices `lo` and `lo+1`, always use the **descend/forward** morph `morphByPair["<clip@lo>→<clip@lo+1>"]`, with `currentTime = frac × duration`. Reverse travel seeks the **same** file backward. `.rev` files are not used by the scrub interaction (they remain baked for back-compat).
- **Git transport:** SSH only (`ssh://git@git.benstull.org/...`). No inline trailing comments on shell/CLI command lines.
- **Pipeline:** ship via §9 — localhost + E2E green → PPE + E2E green → prod (prod human-gated). Every UI change carries its E2E browser tests as first-class tasks.

---

## File Structure

- **Create** `simulator/static/scrub.js` — pure scrub math, UMD (browser global `HEFScrub` + CommonJS `module.exports`). No DOM.
- **Create** `simulator/unit/scrub.test.js` — `node --test` unit suite for `scrub.js`.
- **Create** `simulator/unit/README.md` — one-liner on running the unit suite.
- **Modify** `simulator/static/index.html` — load `scrub.js` before `app.js`; add a second `<audio id="aud-b" loop preload="auto">`.
- **Modify** `simulator/static/app.js` — replace the discrete drag/wheel/tap `advance()` flow with the scrub engine (`setPos`, `rebuildSegment`, `commitCrossings`, auto-scrub animator); two-element audio crossfade; client-side clip pick from `scale.pool`.
- **Modify** `simulator/e2e/tests/altitude-lock.spec.ts` (or add `scrub.spec.ts`) — assert `currentTime` + the two audio gains track the dial angle and reverse on turn-back; full turn commits+locks; wheel auto-scrubs one altitude and locks.
- **Modify** `simulator/build_pool_manifest.py` — `_make_transition` / `_make_reverse` emit **all-intra** H.264 (`-g 1 -keyint_min 1 -sc_threshold 0`) for seekable frames.
- **Modify** `tests/test_build_pool_manifest.py` — assert the ffmpeg arg list carries the all-intra flags.

---

## Phase 1 — Scrub interaction against current morphs

### Task 1: Pure scrub math module (`scrub.js`) + Node unit tests

**Files:**
- Create: `simulator/static/scrub.js`
- Create: `simulator/unit/scrub.test.js`
- Create: `simulator/unit/README.md`

**Interfaces:**
- Produces (all pure, no DOM), exported on `HEFScrub` and `module.exports`:
  - `clamp01(x: number): number`
  - `wrapIndex(i: number, n: number): number` — `((i % n) + n) % n`
  - `segmentOf(pos: number): { lo: number, frac: number }` — `lo = floor(pos)`, `frac = pos - lo`
  - `accumToPos(restPos: number, accumDeg: number, stepDeg: number): number` — `restPos + accumDeg/stepDeg`
  - `fracToTime(frac: number, duration: number): number` — `clamp01(frac) * (duration || 0)`
  - `crossfadeGains(frac: number): { from: number, to: number }` — `{ from: 1-clamp01(frac), to: clamp01(frac) }`
  - `integerCrossings(prevPos: number, pos: number): Array<{ index: number, dir: number }>` — integers strictly crossed going `prevPos → pos`, in travel order, `dir` = `+1` descending (pos increasing) / `-1` ascending. Landing exactly on an integer counts as crossing it.

- [ ] **Step 1: Write the failing unit suite**

Create `simulator/unit/scrub.test.js`:

```js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const S = require("../static/scrub.js");

test("clamp01 bounds to [0,1]", () => {
  assert.equal(S.clamp01(-0.2), 0);
  assert.equal(S.clamp01(0.4), 0.4);
  assert.equal(S.clamp01(1.5), 1);
});

test("wrapIndex wraps both directions", () => {
  assert.equal(S.wrapIndex(0, 5), 0);
  assert.equal(S.wrapIndex(5, 5), 0);
  assert.equal(S.wrapIndex(-1, 5), 4);
  assert.equal(S.wrapIndex(7, 5), 2);
});

test("segmentOf splits floor and fraction", () => {
  assert.deepEqual(S.segmentOf(2), { lo: 2, frac: 0 });
  const s = S.segmentOf(2.4);
  assert.equal(s.lo, 2);
  assert.ok(Math.abs(s.frac - 0.4) < 1e-9);
});

test("accumToPos maps knob degrees to position", () => {
  assert.equal(S.accumToPos(2, 0, 72), 2);
  assert.equal(S.accumToPos(2, 72, 72), 3);
  assert.equal(S.accumToPos(2, -36, 72), 1.5);
});

test("fracToTime clamps and scales by duration", () => {
  assert.equal(S.fracToTime(0, 1.5), 0);
  assert.equal(S.fracToTime(0.5, 1.5), 0.75);
  assert.equal(S.fracToTime(1.2, 1.5), 1.5);
  assert.equal(S.fracToTime(0.5, 0), 0);
});

test("crossfadeGains splits energy by fraction", () => {
  assert.deepEqual(S.crossfadeGains(0), { from: 1, to: 0 });
  assert.deepEqual(S.crossfadeGains(1), { from: 0, to: 1 });
  assert.deepEqual(S.crossfadeGains(0.25), { from: 0.75, to: 0.25 });
});

test("integerCrossings: none while inside a segment", () => {
  assert.deepEqual(S.integerCrossings(2.1, 2.9), []);
  assert.deepEqual(S.integerCrossings(2.0, 2.5), []);
  assert.deepEqual(S.integerCrossings(3.0, 2.5), []);
});

test("integerCrossings: ascending and descending single crossings", () => {
  assert.deepEqual(S.integerCrossings(2.4, 3.0), [{ index: 3, dir: 1 }]);
  assert.deepEqual(S.integerCrossings(2.0, 3.0), [{ index: 3, dir: 1 }]);
  assert.deepEqual(S.integerCrossings(3.4, 3.0), [{ index: 3, dir: -1 }]);
});

test("integerCrossings: multiple crossings in travel order", () => {
  assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
  assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
```

Create `simulator/unit/README.md`:

```markdown
# Simulator unit tests

Pure-logic JS unit tests (no browser), run with Node's built-in test runner:

    node --test simulator/unit/

`scrub.test.js` covers `simulator/static/scrub.js` — the pure altitude-scrub math
(position→segment, frac→currentTime, audio gains, integer-crossing detection).
```

- [ ] **Step 2: Run the suite to verify it fails**

Run: `node --test simulator/unit/scrub.test.js`
Expected: FAIL — `Cannot find module '../static/scrub.js'`.

- [ ] **Step 3: Write `scrub.js` to pass**

Create `simulator/static/scrub.js`:

```js
// Pure altitude-scrub math — no DOM. A continuous `pos` (float) is the knob:
// integers are altitudes/detents, fractions are mid-morph blend. UMD so the
// browser gets a `HEFScrub` global and `node --test` can require() it.
(function (root, factory) {
  const api = factory();
  if (typeof module !== "undefined" && module.exports) module.exports = api;
  else root.HEFScrub = api;
})(typeof self !== "undefined" ? self : this, function () {
  "use strict";

  const clamp01 = (x) => Math.max(0, Math.min(1, x));
  const wrapIndex = (i, n) => ((i % n) + n) % n;

  function segmentOf(pos) {
    const lo = Math.floor(pos);
    return { lo, frac: pos - lo };
  }

  function accumToPos(restPos, accumDeg, stepDeg) {
    return restPos + accumDeg / stepDeg;
  }

  function fracToTime(frac, duration) {
    return clamp01(frac) * (duration || 0);
  }

  function crossfadeGains(frac) {
    const f = clamp01(frac);
    return { from: 1 - f, to: f };
  }

  // Integers strictly crossed moving prevPos -> pos, in travel order. Landing
  // exactly on an integer counts as crossing it (it commits that altitude).
  function integerCrossings(prevPos, pos) {
    const out = [];
    if (pos > prevPos) {
      for (let k = Math.floor(prevPos) + 1; k <= pos + 1e-9; k++) out.push({ index: k, dir: 1 });
    } else if (pos < prevPos) {
      for (let k = Math.ceil(prevPos) - 1; k >= pos - 1e-9; k--) out.push({ index: k, dir: -1 });
    }
    return out;
  }

  return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
});
```

- [ ] **Step 4: Run the suite to verify it passes**

Run: `node --test simulator/unit/scrub.test.js`
Expected: PASS — all tests, 0 failures.

- [ ] **Step 5: Commit**

```bash
git add simulator/static/scrub.js simulator/unit/scrub.test.js simulator/unit/README.md
git commit -m "feat(sim): pure altitude-scrub math module + node unit suite"
```

---

### Task 2: Two-element audio crossfade

**Files:**
- Modify: `simulator/static/index.html` (add `<audio id="aud-b">`; load `scrub.js`)
- Modify: `simulator/static/app.js` (audio layer ~lines 950–1045)
- Test: `simulator/unit/scrub.test.js` (gains already covered in Task 1 — no new pure logic)

**Interfaces:**
- Consumes: `HEFScrub.crossfadeGains`, `soundtrackUrl()`-style per-scale URL resolution.
- Produces:
  - `scaleAudioUrl(index: number): string|null` — the soundtrack URL for ring scale `index` (wrapped), using the ring `audio` field then `SCALE_AUDIO_FALLBACK`.
  - `blendAudio(loIndex: number, frac: number): void` — element A plays scale `loIndex`, element B plays scale `loIndex+1` (wrapped), gains `crossfadeGains(frac)`; loads each element's src on first use per gesture. No-op when Audio toggle is off.
  - `restAudio(index: number): void` — settle to a single scale at rest (full gain on the element holding `index`, fade the other to 0). Replaces the tail of `applyAudio()` for the at-rest case.

- [ ] **Step 1: Add the second audio element + scrub.js script tag**

In `simulator/static/index.html`, beside the existing `<audio id="aud" loop preload="auto"></audio>` (line ~21), add:

```html
        <audio id="aud" loop preload="auto"></audio>
        <audio id="aud-b" loop preload="auto"></audio>
```

And load the pure module before `app.js` (line ~112):

```html
  <script src="/scrub.js"></script>
  <script src="/app.js"></script>
```

- [ ] **Step 2: Write the failing E2E expectation (gains exist on two elements)**

Add to `simulator/e2e/tests/altitude-lock.spec.ts` a focused check (full assertions land in Task 5; this verifies the element + hook exist):

```ts
test("two audio elements exist for crossfade", async ({ page }) => {
  await boot(page);
  const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length);
  expect(n).toBe(2);
});
```

Run: `cd simulator/e2e && npx playwright test -g "two audio elements"`
Expected: FAIL until the markup change is loaded (and PASS once the running server serves the new `index.html`).

- [ ] **Step 3: Implement the crossfade audio layer in `app.js`**

Replace the single-element logic around `playUrl`/`applyAudio` (app.js ~1019–1045). Keep `aud` as element A and add `audB` as element B; generalize URL resolution by index:

```js
const aud = $("aud");
const audB = $("aud-b");
let audLastErr = "";

// Soundtrack URL for ring scale `index` (wrapped), ring `audio` field then fallback.
function scaleAudioUrl(index) {
  if (!ring || !ring.scales.length) return null;
  const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
  const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
  return a ? "/media/audio/" + a : null;
}

// Start (once) and set the volume of one element to a given url. Safari needs the
// first play() inside a user gesture; later programmatic plays reuse the element.
function ensurePlaying(el, url, vol) {
  if (!url) { el.volume = 0; if (!el.paused) el.pause(); return; }
  if (el.dataset.url !== url) {
    el.dataset.url = url;
    el.src = mediaUrl(url);
  }
  if (el.paused) {
    const pr = el.play();
    if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); })
            .catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); });
  }
  el.volume = HEFScrub.clamp01(vol);
}

// Mid-segment: A = scale `loIndex`, B = scale loIndex+1, gains by frac.
function blendAudio(loIndex, frac) {
  if (!$("audio").checked) return;
  const g = HEFScrub.crossfadeGains(frac);
  ensurePlaying(aud, scaleAudioUrl(loIndex), g.from);
  ensurePlaying(audB, scaleAudioUrl(loIndex + 1), g.to);
  updateAudioStatus();
}

// At rest on a single altitude: the element already holding it stays at full gain,
// the other fades to 0. Picks whichever element currently carries `index`'s url.
function restAudio(index) {
  if (!$("audio").checked) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); return; }
  const url = scaleAudioUrl(index);
  const onB = audB.dataset.url === url && url;
  const active = onB ? audB : aud;
  const idle = onB ? aud : audB;
  ensurePlaying(active, url, 1);
  fadeVolume(idle, 0, FADE_MS);
}
```

Then make the Audio toggle and altitude-rest paths call `restAudio(ringIndex)` instead of the old `applyAudio()` swap. Keep `applyAudio()` as a thin shim that calls `restAudio(ringIndex)` so existing call sites (e.g. the toggle handler at ~1073, `update()`) keep working:

```js
function applyAudio() { restAudio(ringIndex); }
```

- [ ] **Step 4: Run unit suite + the focused E2E**

Run: `node --test simulator/unit/scrub.test.js`
Expected: PASS.
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"` (with the dev server running)
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
git commit -m "feat(sim): two-element audio crossfade keyed by scrub fraction"
```

---

### Task 3: Scrub engine — drag drives `pos`, seeks the morph, commits/locks/re-rolls

**Files:**
- Modify: `simulator/static/app.js` (`onDialDown/Move/Up` ~766–795; `advance` ~640–679; `playTransition` ~595–636; needle/render ~739–751)
- Test: `simulator/unit/scrub.test.js` (pure parts covered; engine glue is DOM, covered by E2E in Task 5)

**Interfaces:**
- Consumes: `HEFScrub.*`, `morphByPair`, `scale.pool` (client ring), `mediaUrl`, `setNeedle`, `dialStep`, `ensureClipMedia`/`update` (base-clip loop), `blendAudio`/`restAudio`.
- Produces:
  - `pos` (module-level float; rest value = `ringIndex`).
  - `pickPoolClip(index: number): string` — random `clip_id` from ring scale `index`'s pool (wrapped).
  - `rebuildSegment(lo: number, enteredFrom: number): void` — sets `activeSeg = { lo, clipLo, clipHi, file }`; the side equal to the just-rested altitude takes `activeClipId`, the other is a fresh `pickPoolClip` (re-roll); `file = morphByPair["<clipLo>→<clipHi>"]` (descend canonical).
  - `setPos(next: number, opts?: { commit?: boolean }): void` — clamps thrash via one seek per rAF; seeks `vid.currentTime = fracToTime(frac, dur)`; `setNeedle(next * dialStep())`; `blendAudio(lo, frac)`; processes `integerCrossings(pos, next)` to commit/lock/re-roll; updates `pos`.
  - `commitTo(index: number): void` — `ringIndex = wrapIndex(index,n)`, `activeClipId = clip at that index for the active segment`, settle base loop + `restAudio` when frac becomes 0.

- [ ] **Step 1: Write the failing E2E (drag partway tracks currentTime + gains)**

Add to the E2E spec (full version in Task 5; this drives the engine):

```ts
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
  await boot(page);
  // Drag the dial ~40% of one detent (clockwise = descend).
  const box = await page.locator("#dial").boundingBox();
  const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
  await page.mouse.move(cx, cy - 30);
  await page.mouse.down();
  await page.mouse.move(cx + 18, cy - 24, { steps: 8 });   // partial turn
  const state = await page.evaluate(() => ({
    t: (document.querySelector("video") as HTMLVideoElement).currentTime,
    ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
    gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
  }));
  expect(state.t).toBeGreaterThan(0);          // morph scrubbed off frame 0
  expect(state.gb).toBeGreaterThan(0);         // next scale audio fading in
  expect(state.ga).toBeLessThan(1);            // current scale fading out
  await page.mouse.up();
});
```

Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"`
Expected: FAIL (engine not built yet).

- [ ] **Step 2: Implement the scrub engine in `app.js`**

Add the position state + segment + setPos near the dial logic, and rewrite `onDialMove`/`onDialUp` to drive it live:

```js
let pos = 0;                 // continuous knob position; rest value == ringIndex
let activeSeg = null;        // { lo, clipLo, clipHi, file }
let seekPending = false;     // throttle: one currentTime seek per rAF

function pickPoolClip(index) {
  const n = ring.scales.length;
  const s = ring.scales[HEFScrub.wrapIndex(index, n)];
  const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
  return pool[Math.floor(Math.random() * pool.length)].clip_id;
}

// Build (or re-roll) the segment [lo, lo+1]. The side equal to the rested altitude
// keeps the locked clip; the other end is a fresh random pick (re-roll).
function rebuildSegment(lo, enteredFrom) {
  const n = ring.scales.length;
  const loId = (enteredFrom === HEFScrub.wrapIndex(lo, n)) ? activeClipId : pickPoolClip(lo);
  const hiId = (enteredFrom === HEFScrub.wrapIndex(lo + 1, n)) ? activeClipId : pickPoolClip(lo + 1);
  const file = morphByPair[`${loId}→${hiId}`] || null;
  activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
  if (file) {
    overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
    vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
    if (vid.dataset.morph !== file) { vid.dataset.morph = file; vid.src = mediaUrl(file); vid.pause(); }
    (window.__hefMorphs || (window.__hefMorphs = [])).push(file);
  }
}

function setPos(next) {
  if (!ring || ring.scales.length < 2) return;
  const n = ring.scales.length;
  // Commit every integer crossed between the old and new position.
  for (const c of HEFScrub.integerCrossings(pos, next)) {
    ringIndex = HEFScrub.wrapIndex(c.index, n);
    // The clip at the crossed integer is whichever segment end matches it.
    if (activeSeg) activeClipId = (HEFScrub.wrapIndex(activeSeg.lo, n) === ringIndex) ? activeSeg.clipLo : activeSeg.clipHi;
    activeSeg = null;          // force a fresh-approach re-roll for the next segment
  }
  pos = next;
  const { lo, frac } = HEFScrub.segmentOf(pos);
  if (frac === 0) {            // settled on an altitude
    currentClipId = null;      // force ensureClipMedia to reload + loop the locked clip
    update();
    restAudio(ringIndex);
    setNeedle(ringIndex * dialStep());
    return;
  }
  if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
  setNeedle(pos * dialStep());
  blendAudio(lo, frac);
  if (activeSeg.file && !seekPending) {        // throttle seeks to one per frame
    seekPending = true;
    requestAnimationFrame(() => {
      seekPending = false;
      if (vid.dataset.morph === activeSeg.file) vid.currentTime = HEFScrub.fracToTime(frac, vid.duration);
    });
  }
}
```

Rewrite the drag handlers to drive `pos` live (replacing the no-live-follow `onDialMove` and the commit-on-release `onDialUp`):

```js
function onDialDown(e) {
  if (!ring || ring.scales.length < 2) return;
  e.preventDefault();
  dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: ringIndex };
}
function onDialMove(e) {
  if (!dialDrag) return;
  const a = dialAngle(e);
  dialDrag.accum += angDelta(a, dialDrag.lastAng);
  dialDrag.lastAng = a;
  dialDrag.moved += Math.abs(angDelta(a, dialDrag.lastAng));
  setPos(HEFScrub.accumToPos(dialDrag.restPos, dialDrag.accum, dialStep()));   // LIVE scrub
}
function onDialUp(e) {
  if (!dialDrag) return;
  const { moved, target } = dialDrag;
  dialDrag = null;
  if (moved < 6) {                                  // a tap, not a turn
    if (target && target.classList && target.classList.contains("dial-label")) jumpToScale(+target.getAttribute("data-index"));
    return;
  }
  // No auto-complete: hold wherever the knob stopped (continuous-encoder model).
}
```

Keep `renderDial()` reading `pos` for the needle when mid-segment, else `ringIndex`. Retire `advance()`'s body (or leave it unused) — wheel/tap use the auto-scrub in Task 4.

- [ ] **Step 3: Run the focused E2E**

Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"` (dev server running)
Expected: PASS.

- [ ] **Step 4: Run the unit suite (no regressions)**

Run: `node --test simulator/unit/scrub.test.js`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
git commit -m "feat(sim): scrub engine — drag drives pos, seeks morph, commits+locks+re-rolls"
```

---

### Task 4: Wheel + tap become auto-scrubs, then lock

**Files:**
- Modify: `simulator/static/app.js` (`onWheel` ~681–691; `jumpToScale` ~798–805)

**Interfaces:**
- Consumes: `setPos`, `ringIndex`, `pos`, `HEFScrub.wrapIndex`.
- Produces:
  - `autoScrub(targetPos: number, ms = 600): void` — rAF-animates `pos` from its current value to `targetPos` (calling `setPos` each frame), landing exactly on the integer target, then locks (frac 0 settles in `setPos`).

- [ ] **Step 1: Write the failing E2E (wheel auto-scrubs one altitude then locks)**

Add to the E2E spec:

```ts
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
  await boot(page);
  const start = (await page.locator("#scale-name").textContent())!;
  await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 });
  await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
  const landed = (await page.locator("#scale-name").textContent())!;
  expect(landed).toContain("orbit");
  // Mid-flight currentTime moved; after landing the morph is settled (frac 0).
  await page.waitForTimeout(1500);
  expect(await page.locator("#scale-name").textContent()).toBe(landed);    // locked
});
```

Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"`
Expected: FAIL.

- [ ] **Step 2: Implement `autoScrub` and rewire wheel + tap**

```js
let autoRaf = 0;
function autoScrub(targetPos, ms = 600) {
  if (autoRaf) cancelAnimationFrame(autoRaf);
  const fromPos = pos, dist = targetPos - fromPos;
  if (!dist) { setPos(targetPos); return; }
  let startTs = null;
  const tick = (ts) => {
    if (startTs === null) startTs = ts;
    const k = Math.min(1, (ts - startTs) / ms);
    setPos(fromPos + dist * k);
    if (k < 1) autoRaf = requestAnimationFrame(tick);
    else { autoRaf = 0; setPos(targetPos); }     // land exactly + lock (frac 0)
  };
  autoRaf = requestAnimationFrame(tick);
}

let wheelAccum = 0, wheelTimer = null;
function onWheel(e) {
  e.preventDefault();
  wheelAccum += e.deltaY;
  clearTimeout(wheelTimer);
  wheelTimer = setTimeout(() => {
    const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1);
    wheelAccum = 0;
    if (detents) autoScrub(ringIndex + detents);
  }, 90);
}

function jumpToScale(idx) {
  if (!ring) return;
  const n = ring.scales.length;
  let d = (idx - ringIndex) % n;
  if (d > n / 2) d -= n;
  if (d < -n / 2) d += n;
  if (d) autoScrub(ringIndex + d);
}
```

- [ ] **Step 3: Run the focused E2E**

Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"` (dev server running)
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add simulator/static/app.js
git commit -m "feat(sim): wheel + label-tap auto-scrub one/path of altitudes, then lock"
```

---

### Task 5: Full E2E tier for scrub-driven transitions

**Files:**
- Modify: `simulator/e2e/tests/altitude-lock.spec.ts`

**Interfaces:**
- Consumes: the running simulator, `window.__hefMorphs`, `#scale-name`, `#dial`, `video`, `#aud`/`#aud-b`.

- [ ] **Step 1: Replace the `.rev`-asserting reverse test with a scrub-reverse test**

The old "zoom back out plays a reverse morph" test asserted a `.rev.mp4` file. Under the scrub model, turn-back seeks the **same** segment morph backward. Replace it:

```ts
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
  await boot(page);
  const box = await page.locator("#dial").boundingBox();
  const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
  const read = () => page.evaluate(() => ({
    t: (document.querySelector("video") as HTMLVideoElement).currentTime,
    ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
    gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
    name: document.querySelector("#scale-name")?.textContent,
  }));

  await page.mouse.move(cx, cy - 30);
  await page.mouse.down();
  await page.mouse.move(cx + 22, cy - 20, { steps: 10 });   // descend partway
  const fwd = await read();
  await page.mouse.move(cx + 4, cy - 30, { steps: 10 });     // turn back toward start
  const back = await read();
  await page.mouse.up();

  expect(back.t).toBeLessThan(fwd.t);          // morph seeking backward
  expect(back.gb).toBeLessThan(fwd.gb);        // next-scale audio receding
  expect(back.name).toContain("cosmos");       // re-locked the altitude we left
});
```

- [ ] **Step 2: Add the full-turn commit+lock test**

```ts
test("a full turn commits and locks the destination clip", async ({ page }) => {
  await boot(page);
  const start = (await page.locator("#scale-name").textContent())!;
  const box = await page.locator("#dial").boundingBox();
  const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
  await page.mouse.move(cx, cy - 30);
  await page.mouse.down();
  await page.mouse.move(cx + 30, cy, { steps: 6 });
  await page.mouse.move(cx, cy + 30, { steps: 6 });          // ~full detent clockwise
  await page.mouse.up();
  await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
  const landed = (await page.locator("#scale-name").textContent())!;
  expect(landed).toContain("orbit");
  const played = await page.evaluate(() => (window as any).__hefMorphs || []);
  expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
  await page.waitForTimeout(2000);
  expect(await page.locator("#scale-name").textContent()).toBe(landed);   // locked
});
```

- [ ] **Step 3: Keep the wheel + drag tests from Tasks 2–4; run the whole tier**

Run: `cd simulator/e2e && npx playwright test`
Expected: PASS — all scrub tests green (drag-scrub, turn-back, full-turn, wheel, two-audio-elements).

- [ ] **Step 4: Commit**

```bash
git add simulator/e2e/tests/altitude-lock.spec.ts
git commit -m "test(e2e): scrub tier asserts currentTime + audio gains track the dial, reverse, commit-lock, wheel"
```

---

### Task 6: Phase-1 localhost verification

**Files:** none (verification task).

- [ ] **Step 1: Run the unit suite**

Run: `node --test simulator/unit/`
Expected: PASS.

- [ ] **Step 2: Run the full Python test suite (no regressions)**

Run: `python -m pytest -q`
Expected: PASS (existing tally; no new failures).

- [ ] **Step 3: Boot the simulator and run E2E against it**

Run (server): start the simulator dev server per `simulator/` README on its configured port with the venv python.
Run (tests): `cd simulator/e2e && npx playwright test`
Expected: all PASS.

- [ ] **Step 4: Eyeball the feel**

Confirm by hand: slow drag eases the morph slowly; fast drag races it; stopping mid-drag holds a blended frame with mixed audio; turning back reverses; the wheel auto-scrubs one altitude and locks; a label tap travels the shortest way. Note any steppiness (expected on sparse-GOP morphs — Phase 2 fixes it).

- [ ] **Step 5: Checkpoint the transcript**

Re-publish the in-progress transcript (no commit needed beyond what tasks already pushed).

---

## Phase 2 — All-intra morph re-bake for smooth seeking

### Task 7: Re-bake the 154 morphs all-intra (dense keyframes)

**Files:**
- Modify: `simulator/build_pool_manifest.py` (`_make_transition` ~402–417, `_make_reverse` ~420–429)
- Test: `tests/test_build_pool_manifest.py`

**Interfaces:**
- Produces: every baked morph encoded all-intra (each frame a keyframe) so arbitrary-frame seeking is smooth.

- [ ] **Step 1: Write the failing arg-builder test**

`_make_transition`/`_make_reverse` currently call `subprocess.run` directly, so factor the ffmpeg command into a pure builder to test it. Add to `simulator/build_pool_manifest.py`:

```python
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
```

Add a pure helper and a test. In `tests/test_build_pool_manifest.py`:

```python
from simulator import build_pool_manifest as B

def test_transition_cmd_is_all_intra():
    cmd = B.transition_cmd("ffmpeg", "/a/base.mp4", "/b/base.mp4", "/out/x__y.mp4")
    assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
    assert "-keyint_min" in cmd and cmd[cmd.index("-keyint_min") + 1] == "1"
    assert "-sc_threshold" in cmd and cmd[cmd.index("-sc_threshold") + 1] == "0"

def test_reverse_cmd_is_all_intra():
    cmd = B.reverse_cmd("ffmpeg", "/out/x__y.mp4", "/out/x__y.rev.mp4")
    assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
    assert "reverse" in " ".join(cmd)
```

Run: `python -m pytest tests/test_build_pool_manifest.py -q`
Expected: FAIL — `transition_cmd`/`reverse_cmd` not defined.

- [ ] **Step 2: Extract `transition_cmd`/`reverse_cmd` and apply all-intra flags**

```python
def transition_cmd(ff, a, b, out):
    norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
    return [
        ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
        f"[0:v]{norm}[a];[1:v]{norm}[b];"
        "[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
        "-map", "[v]", "-an", *ALL_INTRA, str(out),
    ]

def reverse_cmd(ff, forward, out):
    return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
```

Then make `_make_transition`/`_make_reverse` call these builders inside `subprocess.run(..., check=True, capture_output=True)`.

- [ ] **Step 3: Run the arg-builder test**

Run: `python -m pytest tests/test_build_pool_manifest.py -q`
Expected: PASS.

- [ ] **Step 4: Regenerate the morph media**

Run: `python simulator/build_pool_manifest.py` (or its documented `generate_media` entrypoint) to re-bake all 154 morphs all-intra. Confirm files regrew (sparse → dense keyframes; expect ~0.5–0.9 GB total, each clip well under the 25 MB LFS/proxy ceiling noted in memory).

- [ ] **Step 5: Commit the re-baked media via git-LFS**

```bash
git add simulator/sample_media/transitions
git commit -m "feat(pipeline): re-bake 154 morphs all-intra for smooth scrub seeking (LFS)"
```

Confirm they are LFS pointers: `git lfs ls-files | head`.

---

### Task 8: Phase-2 verification — smooth scrub on dense keyframes

**Files:** none (verification task).

- [ ] **Step 1: Re-run the unit + E2E suites against re-baked media**

Run: `node --test simulator/unit/` → PASS.
Run (server up): `cd simulator/e2e && npx playwright test` → PASS.

- [ ] **Step 2: Eyeball smoothness**

Confirm the scrub now seeks smoothly frame-to-frame (no stepping) on a slow drag across a full segment.

- [ ] **Step 3: Push the branch**

```bash
git push -u origin session-0026
```

---

## Ship via §9 (execution, not a code task)

After Phase 1 + Phase 2 are green on localhost (unit + E2E), ship through the mandatory pipeline:
1. **localhost + E2E green** (Tasks 6 + 8).
2. **PPE deploy + E2E green** (flotilla; provision PPE if it doesn't exist yet). Stamp the `release/<YYYY-MM-DDTHH-MM>` tag.
3. **prod is human-gated** — the operator promotes the validated tag; the session stops at PPE-green.

---

## Self-Review

**Spec coverage:**
- Core model (continuous `pos`, frac drives currentTime + audio) → Tasks 1, 3, 2.
- In-between hold / no auto-complete → Task 3 (`onDialUp` holds).
- Turn-back reversible / re-lock start clip → Tasks 3, 5.
- Destination randomness fixed-per-gesture, re-roll on fresh approach → Task 3 (`rebuildSegment`, re-roll on `activeSeg` reset at crossing).
- Scroll wheel auto-scrub then lock; tap shortest way → Task 4.
- Lock-per-altitude preserved → Task 3 (`commit` sets `activeClipId`; frac 0 settles base loop).
- Audio crossfade two adjacent soundtracks by frac → Task 2.
- All-intra re-bake (154 morphs, LFS) → Task 7.
- Unit tests (position→segment, frac→time, commit on crossing, re-roll, reverse) → Task 1 (pure) + E2E for glue.
- Playwright E2E (currentTime + gains track angle, reverse, full-turn commit-lock, wheel) → Task 5.
- Phasing (interaction first, re-bake second) → Phase 1 then Phase 2.

**Placeholder scan:** none — every code step carries real code and exact commands.

**Type consistency:** `setPos`, `rebuildSegment`, `activeSeg {lo,clipLo,clipHi,file}`, `pickPoolClip`, `blendAudio(loIndex,frac)`, `restAudio(index)`, `scaleAudioUrl(index)`, `autoScrub(targetPos,ms)`, `HEFScrub.*` names are used consistently across Tasks 1–5. `transition_cmd`/`reverse_cmd`/`ALL_INTRA` consistent in Tasks 7.

**Open interpretation (logged as deferred decision):** the canonical-segment-file choice (always the descend/forward morph, scrubbed bidirectionally; `.rev` unused by scrub) reinterprets the spec's literal `morphByPair["<from>→<to>"]` directed lookup in favor of its "scrub the same morph in reverse" + full-reversibility requirements. Surfaced for operator awareness at finalize.
