diff --git a/docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md b/docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md new file mode 100644 index 0000000..85408d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md @@ -0,0 +1,149 @@ +# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design + +**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans. + +## Goal + +Publish the Human Experience **Simulator** publicly at +`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the +`benstull.art` apex **redirecting** to that path for now. + +The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of +static frontend plus video/audio from a local `/media/` mount. The public target +has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**, +and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing +runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment +stays as the dev/authoring surface — this is a *new, additional* public target, not +a migration of the dev box. + +## Why this shape (the constraint that forces it) + +Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free / +100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10 +mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no +relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its +10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the +media.** (Verified against Cloudflare docs 2026-06-30.) + +## Decisions (locked) + +- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art` + redirects** there (302/308 redirect, not a rewrite). +- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial + client-side (see Component 2). +- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding). +- **No cross-session cache** (no Service Worker) for now — the existing in-memory + graduated prefetch is sufficient. Deferred, not rejected. +- **benstull.art is already a Cloudflare zone** — no nameserver migration needed. +- **Operates outside Wiggleverse deployment patterns (standing, operator-set):** + this **entire project** is exempt from the typical Wiggleverse deployment + conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline, + and the §9 prod-promotion gate do not bind it. It is a static public art target + hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets + rule, branch→PR — still apply; only the *deployment* machinery is out of scope.) + +## Served media — what actually ships + +The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest +(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips — +the `master.mp4` (20 files, incl. the 91–117 MB `coast_birdrock` set) and +`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The +served set is: + +- the `base.mp4` clip bases (one per pool member), +- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already + kept < 25 MB), +- the **10 audio `.mp3`** layers (~12 MB). + +≈ **~2 GB** to R2. The build computes the exact set by walking the manifest — it +**must not** blindly sync `sample_media/` (that would push the masters too). + +## Components + +### 1. Static build (`tools/` — new build script → `dist/`) + +Replaces the server with baked artifacts: + +- **Bake the read endpoints to static JSON** the page loads at boot, replacing the + fetches to `/api/clips`, `/api/ring`, `/api/media-versions`: + - `clips.json` ← `GET /api/clips` + - `ring.json` ← `GET /api/ring` + - `media-versions.json` ← `GET /api/media-versions` (file → content-hash) + Generate these by importing the existing builders (`simulator/clips.py`, + `build_pool_manifest.py`) directly — **single source of truth, no hand-copy**. +- **Copy only manifest-referenced media** into the R2 sync tree (see Served media). +- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`, + `i18n.js`) into `dist/`, **excluding** author/review/dev surfaces + (`author.*`, `review*.html`, `/api/author/*`). +- Output is deterministic and re-runnable; checked by the integration test below. + +### 2. Client-side dynamic logic (`simulator/static/app.js`) + +The only server-computed behavior is the **random clip pick on landing** +(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already +moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair` +lookup — so the remaining task is to ensure **every** entry path (initial load, tap- +label jumps) uses the client-side pick and that **no code path depends on a live +`/api/*` call**. Boot loads the baked JSON instead of fetching the API. +`/api/alteration` is verified client-resolvable or dropped from the public build. + +### 3. Media URL base + CORS (`simulator/static/app.js`) + +- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable + **`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing + `?v=` content-hash versioning. A single const, fed from the baked config so + localhost dev still works against the FastAPI `/media/` mount. +- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each + clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a + blob, and any clip that falls back to the *direct network* `