plan: Cloudflare static publish — implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 07:23:57 -07:00
parent 7a51d8f6d4
commit 7ef3e0ccc6
@@ -0,0 +1,706 @@
# 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).