# Cloudflare Static Publish — 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:** Publish the simulator as a fully-static site (no origin server) to `benstull.art/human-experience-simulator` on Cloudflare Pages, with media on R2.

**Architecture:** Port the only live server logic (`/api/alteration`, the delta=0 random pick) to client JS; serve the read-APIs as baked JSON; point media at a configurable R2 base with CORS; keep the existing graduated prefetch. A Python build script emits `dist/` (Pages) + a media sync tree (R2). The dev FastAPI server stays for localhost/authoring.

**Tech Stack:** Vanilla JS (UMD modules, `node --test`), Python 3 build script (imports existing `simulator`/`player` builders), Playwright E2E, `wrangler` (Pages + R2), Cloudflare redirect rule.

## Global Constraints

- **Path:** app served at `/human-experience-simulator`; apex `benstull.art` **redirects** there.
- **Media base:** `https://static.benstull.art/` (R2 custom domain), public bucket.
- **CORS is load-bearing:** R2 must send `Access-Control-Allow-Origin: https://benstull.art`; `<video>`/`<audio>` carry `crossOrigin="anonymous"` — else the WebGL dream shader taints.
- **Served media = manifest-referenced only:** `base.mp4` clip bases + 154 transition morphs + 10 `.mp3`. NEVER sync `master.mp4`/`mezzanine.mp4`.
- **Versioning preserved:** keep the `?v=<hash>` query; set those objects `Cache-Control: public, max-age=31536000, immutable` on the CDN.
- **No new runtime:** no Pages Functions / Worker; no Service Worker.
- **Project is exempt from flotilla/PPE/§9** (operator-set) — deploy directly to Cloudflare. Safety/hygiene rules (git-ssh, branch→PR, secrets) still hold.
- **Module idiom:** UMD — `(function(root,factory){ if (module?.exports) module.exports=factory(); else root.HEFx=factory(); })(...)` — matching `simulator/static/scrub.js`.
- **JS tests run:** `node --test simulator/unit`. **Python tests run:** `.venv/bin/python -m pytest`.

---

### Task 1: Client-side alteration engine (`HEFAlteration`)

Port `player/alteration.py::plan_alteration` (+ `render_plan_to_dict`) and `player/audio.py::resolve_visual`/`resolve_audio` to a pure UMD JS module, producing byte-for-byte the same shape `/api/alteration` returns. Calibration is always unity (`DEFAULT_CALIBRATION`; the client never sends one — `app.js:619`).

**Files:**
- Create: `simulator/static/alteration.js`
- Test: `simulator/unit/alteration.test.js`

**Interfaces:**
- Produces (browser global `HEFAlteration`, also `module.exports`):
  - `plan(controls)` → `{ grade:{tone}, overlay:{level,intensity}, affect:{strength,intensity}, dream:{strength,intensity}, is_identity }`
  - `renderAudio(source, scaleAudio, mediaBase)` → `{ source, url, altitude_coupled }`
  - `alteration(controls, scaleAudio, mediaBase)` → `{ plan, render:{ video:{shown}, audio:{source,url,altitude_coupled} } }` (the `/api/alteration` response)
  - `controls` shape (from `app.js:595` `controls()`): `{ visual:"on"|"off", audio:"soundtrack"|"off", left:int, right:int, dark:int, light:int }`

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

```js
// simulator/unit/alteration.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const A = require("../static/alteration.js");

const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });

test("identity at zero knobs", () => {
  const p = A.plan(c({}));
  assert.equal(p.grade.tone, 0);
  assert.equal(p.overlay.level, 0);
  assert.equal(p.dream.strength, 0);
  assert.equal(p.is_identity, true);
});

test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
  assert.equal(A.plan(c({ light: 4 })).grade.tone, 1);     // full light
  assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1);     // full dark
  assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
});

test("left drives overlay level + intensity; right drives dream + affect", () => {
  const p = A.plan(c({ left: 2, right: 4 }));
  assert.equal(p.overlay.level, 2);
  assert.equal(p.overlay.intensity, 0.5);
  assert.equal(p.dream.strength, 4);
  assert.equal(p.dream.intensity, 1);
  assert.equal(p.affect.strength, 4);
  assert.equal(p.affect.intensity, 1);
  assert.equal(p.is_identity, false);
});

test("renderAudio: off → null, no coupling", () => {
  assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
    { source: "off", url: null, altitude_coupled: false });
});

test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
  assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
    { source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
  // no scale audio → null url but still coupled
  assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
    { source: "soundtrack", url: null, altitude_coupled: true });
});

test("alteration() composes plan + render with video.shown from visual", () => {
  const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
  assert.equal(r.render.video.shown, false);
  assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
  assert.equal(r.plan.overlay.level, 1);
});
```

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

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

- [ ] **Step 3: Write the implementation**

```js
// simulator/static/alteration.js
// Client-side port of player/alteration.py + player/audio.py (the alteration
// engine). Replaces the live POST /api/alteration in the static build — pure math,
// unity calibration. UMD so the browser gets `HEFAlteration` and node --test can
// require() it. Keep IN SYNC with the Python (guarded by parity tests there).
(function (root, factory) {
  const api = factory();
  if (typeof module !== "undefined" && module.exports) module.exports = api;
  else root.HEFAlteration = api;
})(typeof self !== "undefined" ? self : this, function () {
  "use strict";
  const KNOB_MAX = 4;                                   // player/alteration.py:25
  const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));

  function plan(c) {
    const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
    const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
    const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
    const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1);   // affect uses overlay_gain too
    const is_identity = tone === 0 && c.left === 0 && c.right === 0;
    return {
      grade: { tone },
      overlay: { level: c.left, intensity: overlayIntensity },
      affect: { strength: c.right, intensity: affectIntensity },
      dream: { strength: c.right, intensity: dreamIntensity },
      is_identity,
    };
  }

  function renderAudio(source, scaleAudio, mediaBase) {
    if (source === "off") return { source: "off", url: null, altitude_coupled: false };
    // soundtrack — couple to the current altitude's asset
    const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
    return { source: "soundtrack", url, altitude_coupled: true };
  }

  function alteration(controls, scaleAudio, mediaBase) {
    return {
      plan: plan(controls),
      render: {
        video: { shown: controls.visual === "on" },
        audio: renderAudio(controls.audio, scaleAudio, mediaBase),
      },
    };
  }

  return { plan, renderAudio, alteration, KNOB_MAX };
});
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `node --test simulator/unit/alteration.test.js`
Expected: PASS (6 tests).

- [ ] **Step 5: Add a Python parity test (guards JS↔Python drift)**

Create `tests/test_alteration_js_parity.py`:

```python
"""The static build reimplements plan_alteration/resolve_audio in JS
(simulator/static/alteration.js). This pins the numeric contract so the Python
and JS cannot silently diverge — if you change the engine, change both."""
from hef.selection import Coordinate
from player.alteration import plan_alteration, render_plan_to_dict
from player.audio import resolve_audio, resolve_visual


def test_engine_contract_matches_js_expectations():
    # mirrors the cases asserted in simulator/unit/alteration.test.js
    p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
    assert p["overlay"] == {"level": 2, "intensity": 0.5}
    assert p["dream"] == {"strength": 4, "intensity": 1.0}
    assert p["affect"] == {"strength": 4, "intensity": 1.0}
    assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
    assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
    assert resolve_visual("off") is False
    a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
    assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
```

- [ ] **Step 6: Run + commit**

Run: `node --test simulator/unit/alteration.test.js && .venv/bin/python -m pytest tests/test_alteration_js_parity.py -q`
Expected: all PASS.

```bash
git add simulator/static/alteration.js simulator/unit/alteration.test.js tests/test_alteration_js_parity.py
git commit -m "feat(static): client-side alteration engine (port of player.alteration/audio)"
```

---

### Task 2: Static config + boot rewiring in `app.js`

Introduce a runtime config the build injects, then make `app.js` (a) read media from a configurable base, (b) boot from baked JSON in static mode, (c) pick pool members client-side, (d) compute alteration locally, (e) tag media elements with `crossOrigin`, (f) skip the dev-version poll in static mode. All changes degrade to today's behavior when config is absent (dev server unaffected).

**Files:**
- Create: `simulator/static/config.js` (dev default; the build overwrites it in `dist/`)
- Modify: `simulator/static/app.js` (boot `loadData` ~59, `pickRandomMember` ~87, `mediaNetUrl` ~123, alteration fetch ~617, dev poll ~1041, video/audio element creation)
- Modify: `simulator/static/index.html:111` (load `config.js` before `app.js`)

**Interfaces:**
- Consumes: `HEFAlteration` (Task 1).
- Produces: global `HEF_CONFIG = { mediaBase: string, static: boolean }`. Dev default `{ mediaBase: "/media/", static: false }`.

- [ ] **Step 1: Create the dev-default config and load it**

`simulator/static/config.js`:
```js
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
```

In `simulator/static/index.html`, add before `app.js` (line 111 area):
```html
  <script src="/config.js"></script>
```
(Place it before `<script src="/app.js"></script>`.)

- [ ] **Step 2: Media base — modify `mediaNetUrl` (`app.js:123`)**

Replace:
```js
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
```
with:
```js
function mediaNetUrl(file) {
  const base = (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/";
  const v = mediaVersions[file];
  return base + file + (v ? "?v=" + v : "");
}
```
Note: audio-layer absolute urls already begin with the configured base — keep the existing `file.startsWith("/media/")` guard in `mediaUrl`, and ALSO short-circuit absolute `http` urls:
```js
function mediaUrl(file) {
  if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
  return mediaBlobs[file] || mediaNetUrl(file);
}
```
And in `HEFAlteration.alteration(...)` we pass `mediaBase`, so the audio `url` it returns is already absolute (R2) in static mode and `/media/...` in dev.

- [ ] **Step 3: Boot from baked JSON — modify `loadData` (`app.js:59`)**

Replace the three `fetch("/api/...")` calls with a base that depends on mode:
```js
async function loadData() {
  const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
    ? { clips: "./clips.json", versions: "./media-versions.json", ring: "./ring.json" }
    : { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
  const clips = (await (await fetch(api.clips)).json()).clips || [];
  clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
  try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
  catch (_) { mediaVersions = {}; }
  const r = await fetch(api.ring);
  serverRing = r.ok;
  ring = r.ok ? await r.json() : null;
  // ...rest unchanged (fallback ring synth, morphByPair build, ringIndex = 0)
}
```
(`clips.json` mirrors `{clips:[...]}`, `ring.json` mirrors `ring_to_dict(...)`, `media-versions.json` mirrors `{versions:{...}}` — Task 3 bakes them.)

- [ ] **Step 4: Client-side pick — modify `pickRandomMember` (`app.js:87`)**

The delta=0 server pick becomes a uniform client-side pool pick (matches `hef`'s `pick_clip_id`):
```js
async function pickRandomMember() {
  const scale = ring && ring.scales[ringIndex];
  if (!scale) return null;
  const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
  return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
```
(Drag/scroll navigation already resolves picks + morphs client-side via `scrub.js`; this removes the last `/api/ring/advance` dependency. `serverRing` stays as the "real ring vs synthesized" flag.)

- [ ] **Step 5: Local alteration — modify the fetch at `app.js:617`**

Replace:
```js
  const resp = await fetch("/api/alteration", { /* ... */ body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }) });
  const data = await resp.json();
```
with a mode-aware path that reuses the engine:
```js
  let data;
  if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
    const scale = ring && ring.scales[ringIndex];
    const scaleAudio = (scale && scale.audio) || "";
    const base = window.HEF_CONFIG.mediaBase;
    data = HEFAlteration.alteration(controls(), scaleAudio, base);
  } else {
    const resp = await fetch("/api/alteration", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
    });
    data = await resp.json();
  }
```
**Prerequisite:** `ring.json` must expose each scale's `audio` field. Verify `ring_to_dict` includes it; if not, that is a Task 3 bake concern (add it). The rest of the function (`data.render`, `data.plan.*`) is unchanged.

- [ ] **Step 6: crossOrigin on media elements (CORS for WebGL)**

Find where `<video>` and `<audio>` elements are created/used in `app.js` (the double-buffered `#vid`/loop videos and the audio layer). Set `crossOrigin` BEFORE assigning `src`:
```js
videoEl.crossOrigin = "anonymous";
```
Apply to: the visible video, the preload/double-buffer video, and the `<audio>` element. (In dev, same-origin `/media/` ignores it; in static it prevents GL-texture taint and lets ranged `fetch()` succeed under CORS.) Also set `crossorigin="anonymous"` on any static `<video>`/`<audio>` in `index.html`.

- [ ] **Step 7: Skip the dev-version poll in static mode (`app.js:1041`)**

Wrap the `/dev/version` poller so it no-ops when `HEF_CONFIG.static` (there is no dev server):
```js
if (!(window.HEF_CONFIG && window.HEF_CONFIG.static)) {
  // existing setInterval(... fetch("/dev/version") ...) poll
}
```

- [ ] **Step 8: Manual dev smoke (no regression)**

Run (this env has only `python3`):
```bash
.venv/bin/python -m uvicorn simulator.app:app --port 8000
```
Open `http://localhost:8000/`, confirm boot, altitude changes, dream/overlay still work (dev mode: `HEF_CONFIG.static=false`, behavior identical to before).

- [ ] **Step 9: Commit**

```bash
git add simulator/static/config.js simulator/static/app.js simulator/static/index.html
git commit -m "feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin"
```

---

### Task 3: Static build script → `dist/` + media sync tree

A Python script that produces the deployable `dist/` (frontend + baked JSON + static config) and a separate media sync tree containing ONLY manifest-referenced files. Reuses the live app's loaders so the baked JSON is identical to the API output.

**Files:**
- Create: `tools/build_static.py`
- Create: `tests/test_build_static.py`

**Interfaces:**
- Consumes: `simulator.app.create_app` (to render the same JSON the API serves) OR the underlying loaders (`simulator.clips`/`build_pool_manifest` + `player`/`hef`). Prefer driving `create_app(manifest)` via `fastapi.testclient.TestClient` so the bake is byte-identical to the API.
- Produces: `build_static(out_dir, media_out, *, media_base, app_path) -> dict` (counts), and a CLI.

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

```python
# tests/test_build_static.py
import json
from pathlib import Path
from tools.build_static import build_static

def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
    out = tmp_path / "dist"
    media = tmp_path / "media"
    result = build_static(out, media, media_base="https://static.benstull.art/",
                          app_path="/human-experience-simulator")

    # frontend present, dev/author surfaces excluded
    assert (out / "index.html").exists()
    assert (out / "app.js").exists()
    assert (out / "scrub.js").exists()
    assert (out / "alteration.js").exists()
    assert (out / "config.js").exists()
    assert not (out / "author.html").exists()
    assert not list(out.glob("review*.html"))

    # baked JSON matches the API shape
    clips = json.loads((out / "clips.json").read_text())
    assert "clips" in clips and clips["clips"]
    ring = json.loads((out / "ring.json").read_text())
    assert "scales" in ring and "transitions" in ring
    assert all("audio" in s for s in ring["scales"])           # needed by client alteration
    versions = json.loads((out / "media-versions.json").read_text())
    assert "versions" in versions

    # config.js points at R2 + static mode
    cfg = (out / "config.js").read_text()
    assert "https://static.benstull.art/" in cfg and "static: true" in cfg

    # media tree holds ONLY referenced files — no masters/mezzanines
    synced = {p.name for p in media.rglob("*.mp4")}
    assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
    # every versions key exists in the media tree
    for f in versions["versions"]:
        assert (media / f).exists(), f"missing synced media: {f}"
    assert result["media_files"] == len(list(media.rglob("*")))
```

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

Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: FAIL — `ModuleNotFoundError: tools.build_static`.

- [ ] **Step 3: Implement the build script**

```python
# tools/build_static.py
"""Build the fully-static deployable for Cloudflare (Pages + R2).

Emits `out_dir/` (frontend + baked API JSON + static config.js) and a `media_out/`
sync tree containing ONLY the files the manifest references (clip bases + transition
morphs + audio) — never the master/mezzanine pipeline sources. The baked JSON is
produced through the real app (TestClient) so it is byte-identical to the API.

Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
deploy/cloudflare/ — this script only produces artifacts.
"""
from __future__ import annotations

import json
import shutil
from pathlib import Path

from fastapi.testclient import TestClient

from simulator.app import create_app, MEDIA_DIR

STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]


def _bake_api(out: Path) -> dict:
    app = create_app()
    client = TestClient(app)
    clips = client.get("/api/clips").json()
    ring = client.get("/api/ring").json()
    versions = client.get("/api/media-versions").json()
    (out / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
    (out / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
    (out / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
    return versions["versions"]


def _write_config(out: Path, media_base: str) -> None:
    (out / "config.js").write_text(
        "// GENERATED by tools/build_static.py — do not edit in dist/.\n"
        f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
    )


def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
    out = Path(out_dir)
    media = Path(media_out)
    for d in (out, media):
        if d.exists():
            shutil.rmtree(d)
        d.mkdir(parents=True)

    for name in PUBLIC_ASSETS:
        src = STATIC / name
        if src.exists():
            shutil.copy2(src, out / name)

    versions = _bake_api(out)
    _write_config(out, media_base)

    # Sync ONLY referenced media (versions covers bases + morphs); add audio explicitly.
    referenced = set(versions.keys())
    ring = json.loads((out / "ring.json").read_text())
    for s in ring.get("scales", []):
        if s.get("audio"):
            referenced.add(f"audio/{s['audio']}")
    n = 0
    for rel in sorted(referenced):
        src = MEDIA_DIR / rel
        if not src.exists():
            raise FileNotFoundError(f"referenced media missing on disk: {rel}")
        dst = media / rel
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        n += 1

    return {"out": str(out), "media": str(media), "media_files": n, "app_path": app_path}


if __name__ == "__main__":
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", default="dist")
    ap.add_argument("--media-out", default="dist-media")
    ap.add_argument("--media-base", default="https://static.benstull.art/")
    ap.add_argument("--app-path", default="/human-experience-simulator")
    a = ap.parse_args()
    r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
    print(json.dumps(r, indent=2))
```

**Verify before running the test:** confirm `simulator/app.py` exports `MEDIA_DIR` and that `ring_to_dict` includes `audio` per scale. If `audio` is absent from `ring.json`, add it to `ring_to_dict` (small change in `simulator/app.py` / the ring serializer) and add a Python test asserting `"audio" in scale dict`.

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

Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: PASS. (Test asserts media tree excludes masters and includes every versioned file + audio.)

- [ ] **Step 5: Real build smoke**

Run: `.venv/bin/python tools/build_static.py`
Expected: prints counts; `dist/` has the frontend + 3 JSON + `config.js`; `dist-media/` ≈ ~2 GB, no `master.mp4`/`mezzanine.mp4`.

- [ ] **Step 6: Commit** (do NOT commit `dist/`/`dist-media/` — add to `.gitignore`)

```bash
printf '\n/dist/\n/dist-media/\n' >> .gitignore
git add tools/build_static.py tests/test_build_static.py .gitignore simulator/app.py
git commit -m "feat(static): build script — dist/ + manifest-only media sync tree, baked API JSON"
```

---

### Task 4: Cloudflare config artifacts + deploy runbook

Produce the version-controlled Cloudflare config (CORS policy, redirect, deploy commands). The actual deploy needs operator Cloudflare auth (`wrangler login` / API token) — `wrangler` is NOT installed here, so this task delivers artifacts + exact commands and the operator runs the authenticated steps.

**Files:**
- Create: `deploy/cloudflare/README.md` (runbook)
- Create: `deploy/cloudflare/r2-cors.json` (bucket CORS policy)
- Create: `deploy/cloudflare/_redirects` (copied into `dist/` for the apex/path redirect, or documented as a dashboard Redirect Rule)

**Interfaces:** none (ops artifacts).

- [ ] **Step 1: CORS policy**

`deploy/cloudflare/r2-cors.json`:
```json
[
  {
    "AllowedOrigins": ["https://benstull.art"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["Range", "Content-Type"],
    "ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
    "MaxAgeSeconds": 86400
  }
]
```

- [ ] **Step 2: Redirect artifact**

`deploy/cloudflare/_redirects` (Pages `_redirects` form — apex/root to the app path):
```
/  /human-experience-simulator  308
```
(If the app is deployed as a Pages project bound to `benstull.art`, also document a dashboard **Redirect Rule**: `benstull.art/` → `/human-experience-simulator` as the authoritative alternative.)

- [ ] **Step 3: Runbook**

`deploy/cloudflare/README.md` — exact operator commands (run after `tools/build_static.py`):
```bash
npm i -g wrangler
wrangler login

wrangler r2 bucket create hef-media
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
# bind custom domain static.benstull.art to the bucket (dashboard: R2 > bucket > Settings > Custom Domains)
# upload media (preserves keys):
wrangler r2 bucket sync ./dist-media r2://hef-media

cp deploy/cloudflare/_redirects dist/_redirects
wrangler pages project create human-experience-simulator
wrangler pages deploy dist --project-name human-experience-simulator
# bind benstull.art to the Pages project; serve at /human-experience-simulator
# add the apex Redirect Rule if not using _redirects
```
Document: set the media objects' `Cache-Control: public, max-age=31536000, immutable` (sync flag or bucket lifecycle), and verify `wrangler r2 bucket sync` excludes nothing unexpected (the tree is already filtered by the build).

- [ ] **Step 4: Commit**

```bash
git add deploy/cloudflare/
git commit -m "chore(static): Cloudflare deploy artifacts — R2 CORS, apex redirect, runbook"
```

---

### Task 5: E2E against the static build

Verify the built `dist/` boots with **no `/api/*` server**, the dream shader survives cross-origin media (CORS, no taint), and scrub/morph/audio work. Serve `dist/` and the media from **two different origins** to exercise CORS the way R2 will.

**Files:**
- Create: `simulator/e2e/tests/static-build.spec.ts`
- Create: `simulator/e2e/serve-static.mjs` (tiny dual static server: app on one port, media w/ CORS on another)

**Interfaces:** consumes the `dist/` + `dist-media/` produced by Task 3.

- [ ] **Step 1: Dual static server with CORS**

`simulator/e2e/serve-static.mjs`:
```js
// Serves dist/ (app) and dist-media/ (media w/ CORS + Range) on two ports — a
// local stand-in for Pages + R2, so the static-build E2E exercises cross-origin
// media exactly as production will.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname } from "node:path";

const TYPES = { ".html":"text/html", ".js":"text/javascript", ".json":"application/json",
  ".css":"text/css", ".mp4":"video/mp4", ".mp3":"audio/mpeg" };

function serve(rootEnv, port, cors) {
  const root = process.env[rootEnv];
  http.createServer((req, res) => {
    if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
    let p = decodeURIComponent(req.url.split("?")[0]);
    if (p === "/") p = "/index.html";
    const file = join(root, p);
    try {
      const st = statSync(file);
      res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
      const range = req.headers.range;                       // honor Range for video scrub
      if (range && /^bytes=/.test(range)) {
        const [s, e] = range.replace("bytes=", "").split("-");
        const start = +s, end = e ? +e : st.size - 1;
        res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${st.size}`,
          "Content-Length": end - start + 1 });
        createReadStream(file, { start, end }).pipe(res);
      } else { res.writeHead(200, { "Content-Length": st.size }); createReadStream(file).pipe(res); }
    } catch { res.writeHead(404); res.end("nf"); }
  }).listen(port);
}
serve("DIST_DIR", 8077, false);
serve("MEDIA_DIR_E2E", 8078, true);
console.log("static app :8077  media(cors) :8078");
```

- [ ] **Step 2: The spec**

`simulator/e2e/tests/static-build.spec.ts`:
```ts
import { test, expect } from "@playwright/test";

// Built dist/ is served on :8077; media (CORS) on :8078. The build's config.js
// must be overridden to point media at :8078 for this run (see Step 3).
test.use({ baseURL: "http://localhost:8077" });

test("boots fully static — no /api calls, ring + clips load from JSON", async ({ page }) => {
  const apiCalls: string[] = [];
  page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
  await page.goto("/");
  await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
  expect(apiCalls, "no /api/* in static mode").toEqual([]);
});

test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
  const errors: string[] = [];
  page.on("pageerror", (e) => errors.push(String(e)));
  await page.goto("/");
  // crank Right to engage the dream, let a few frames render
  await page.evaluate(() => { const r = document.getElementById("right") as HTMLInputElement; r.value = "4"; r.dispatchEvent(new Event("input", { bubbles: true })); });
  await page.waitForTimeout(2000);
  // a tainted texture throws SecurityError on the GL draw; assert none surfaced
  expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
```

- [ ] **Step 3: Wire the run (build → point media → serve → test)**

Document in `simulator/e2e/README.md` and use a throwaway config for the run:
```bash
.venv/bin/python tools/build_static.py --media-base http://localhost:8078/
DIST_DIR=$PWD/dist MEDIA_DIR_E2E=$PWD/dist-media node simulator/e2e/serve-static.mjs &
cd simulator/e2e && npx playwright test static-build.spec.ts
```
(Building with `--media-base http://localhost:8078/` writes that base into `dist/config.js`, so the app fetches media cross-origin from the CORS server — the production CORS path, locally.)

- [ ] **Step 4: Run the E2E**

Expected: both tests PASS — zero `/api/*` requests; no `SecurityError` from the dream shader.

- [ ] **Step 5: Commit**

```bash
git add simulator/e2e/serve-static.mjs simulator/e2e/tests/static-build.spec.ts simulator/e2e/README.md
git commit -m "test(static): E2E — static boot with no API + dream shader survives cross-origin CORS"
```

---

## Self-Review

**Spec coverage:**
- No-origin static frontend → Tasks 2, 3. ✅
- Media on R2 + CORS + crossOrigin → Tasks 2 (crossOrigin), 4 (CORS policy). ✅
- Bake read-APIs to JSON → Task 3. ✅
- Random pick client-side → Task 2 Step 4. ✅
- `/api/alteration` client-side → Tasks 1, 2 Step 5. ✅
- Served = manifest-only (no masters) → Task 3 (test asserts). ✅
- `?v=` versioning + immutable caching → Task 2 (preserved), Task 4 (cache header). ✅
- Path `/human-experience-simulator` + apex redirect → Task 4. ✅
- E2E against static build (no API, dream survives CORS) → Task 5. ✅
- Prefetch unchanged → no task needed (only `mediaNetUrl` base changes, Task 2 Step 2). ✅

**Placeholder scan:** none — every code step has full content.

**Type consistency:** `HEF_CONFIG.{mediaBase,static}`, `HEFAlteration.{plan,renderAudio,alteration}`, `controls()` shape, and `build_static(out, media_out, *, media_base, app_path)` are used identically across tasks. ✅

**Open verify-at-execution items (flagged, not placeholders):** (a) confirm `ring_to_dict` emits per-scale `audio` — add it if missing (Task 3 Step 3); (b) confirm `simulator/app.py` exports `MEDIA_DIR` (used by the build); (c) locate the exact video/audio element creation sites for `crossOrigin` (Task 2 Step 6).
