Compare commits

...

15 Commits

Author SHA1 Message Date
BenStullsBets 7ec8b607d2 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls 2026-06-30 15:06:59 -07:00
BenStullsBets b135cb5f5e fix(deploy): redirect the bare legacy root too (/* splat misses the empty case)
Add an explicit /human-experience-simulator/ → /human-machine-strata/ 301 (the
subpath splat doesn't match the trailing-slash root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:02:29 -07:00
BenStullsBets 950c1e6c8c feat(deploy): move app URL to /human-machine-strata + 301 from the legacy path
Build default app-path → /human-machine-strata; deploy.sh APP_PATH default too.
_write_redirects now emits a permanent redirect /human-experience-simulator/* →
/human-machine-strata/:splat so old links still land. Media (R2 bucket) + Pages
project names are unchanged (not in the benstull.art URL). Updated the build
test (asserts the legacy redirect) + static-build e2e path + README paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:00:40 -07:00
BenStullsBets 958c3060d2 feat(rename): retitle the project to "Human/Machine Strata"
Page <title>, on-page <h1> (app.title — kept as the proper title, same across
all 4 languages), the About + Credits headings/prose, and the author dev tool.
"Strata" = the layered cosmos→abyss descent; the slash = the human↔machine
membrane the work is about.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:00:40 -07:00
BenStullsBets c36f995463 feat(panel): move Altitude to the top of the panel (mobile: reachable with video on screen)
On mobile the panel wraps below the (sticky) stage; putting the Altitude knob
first means it sits right under the video instead of behind Output + knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:29:02 -07:00
BenStullsBets b63518bfbb chore(title): h1 header → "Human Experience Filter Simulation" (4-lang, matches page title)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:23:43 -07:00
BenStullsBets 4555bb29a8 chore(title): page title → "Human Experience Filter Simulation"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:22:51 -07:00
BenStullsBets 8c63b14967 chore(deploy): no-store the whole app path (per-HTML override didn't stick)
Cloudflare Pages let the /seg/* no-cache rule win over the per-HTML no-store, so
the index was still served no-cache. no-store the whole app path instead — the
shell is tiny and media is on R2 (separate, immutable). Guarantees a fresh index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:20:53 -07:00
BenStullsBets ecd444b53b fix(reduce-motion): re-roll the clip on an instant-cut altitude change
With reduce motion on, selecting a new altitude jumps (autoScrub's reduced
branch → setPos) and skips the segment that would re-roll the destination clip,
so activeClipId stayed on the previous altitude's clip and the video didn't
change. Self-heal at the landing point: if the landed altitude's pool doesn't
contain activeClipId, pickPoolClip a fresh one. TDD: new altitude-lock e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:18:16 -07:00
BenStullsBets fafb498d6f feat(panel): move Experience knobs above Altitude; drop dial hint + '(0–4)'
Per operator: Think/Feel/Mood group sits above the Altitude knob; remove the
'Turn the knob…' altitude hint (and its dead i18n key); 'Experience knobs (0–4)'
→ 'Experience knobs' (all 4 langs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:58 -07:00
BenStullsBets e06191db32 chore(deploy): no-store the app-shell HTML (stop Safari serving a stale index)
no-cache (revalidate) wasn't enough — Safari served a stale cached index from
disk/bfcache, which loaded old versioned JS and broke the page (black, silent).
HTML entry points now no-store (always fresh index → always fresh hashed assets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:57 -07:00
BenStullsBets ccd40103a2 fix(speed): cap Video speed at 2x (>2x stutters — base loops not all-intra)
Same root cause that retired reverse playback: the resting loop clips aren't
all-intra, so playbackRate >2x makes Safari stutter on inter-frame seeks. Narrow
both sliders (welcome + panel) + the tick datalist to 0–2x; default still 1x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:08:11 -07:00
BenStullsBets 79f64e3f7c feat(welcome): rename "Speed" control to "Video speed" (4-lang)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:57:13 -07:00
BenStullsBets 54cad607fb add sessions/0038/SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--2026-06-30T13-20.md + replace placeholder/variant SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--INPROGRESS.md 2026-06-30 13:19:40 -07:00
BenStullsBets 053bb5c1a3 claim human-experience-filter-art session 0038 (placeholder) + sessions.json entry 2026-06-30 13:17:25 -07:00
15 changed files with 204 additions and 58 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
# Deploying to benstull.art — Cloudflare Pages + R2
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-experience-simulator`, apex redirects to
Pages** (`benstull.art`, app at `/human-machine-strata`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
@@ -48,7 +48,7 @@ as a script):
```
Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
- `dist/` — Pages output: `dist/human-machine-strata/` (frontend + baked
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
@@ -109,7 +109,7 @@ Then **in the dashboard** (Workers & Pages → project → Custom domains) — t
no wrangler CLI for Pages custom domains:
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
serves `522` until the cert/routing provisions.)
- The app serves at `benstull.art/human-experience-simulator/`; the shipped
- The app serves at `benstull.art/human-machine-strata/`; the shipped
`dist/_redirects` sends `/` and the no-slash form to it.
## 4. Verify before/after going live
@@ -121,7 +121,7 @@ curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
`https://benstull.art/human-machine-strata/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
+1 -1
View File
@@ -15,7 +15,7 @@ set -euo pipefail
BUCKET="${BUCKET:-human-experience-simulator-media}"
PROJECT="${PROJECT:-human-experience-simulator}"
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
APP_PATH="${APP_PATH:-/human-experience-simulator}"
APP_PATH="${APP_PATH:-/human-machine-strata}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
@@ -0,0 +1,102 @@
# Session 0038.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T12-25 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 52b811fa-0926-4619-ae31-3c71d489c686
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> End: 2026-06-30T13-20 (PST)
> Status: **FINALIZED.**
## Summary
Added a **Playback Speed slider** to the simulator's Output panel, replacing the
"Reduce motion" toggle. Designed (brainstorm → spec), built TDD-first, shipped to
`main`, then **revised the scale after operator eyeball**: the initial 2×…2×
range with a manual reverse loop jittered in a real browser (base loop clips
aren't all-intra), so it was narrowed to **0×–4× forward** and the reverse
machinery removed.
### Arc
1. **Brainstorm** (no tracked init this session — claimed s0038 at finalize).
Surfaced the real tradeoff: "Reduce motion" is the a11y safety control
(`isReduced()`), doing three things beyond freezing the loop (OS-pref default,
instant transitions, photosensitivity floor). Two operator decisions via
AskUserQuestion: **keep a11y but move it** (derive from OS
`prefers-reduced-motion`, no visible toggle) and **implement a true manual
reverse loop** for the negative range.
2. **Spec** written + committed to
`docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md`.
3. **Build (round 1, 2…2 + reverse), TDD:** worked in worktree
`feat/playback-speed-slider` (off `origin/main`). Pure node-tested
`HEFScrub.reverseStep` helper (RED→GREEN); slider + datalist in `index.html`;
`applyPlaySpeed` + rAF reverse loop in `app.js`; `isReduced()` re-derived from
OS pref; retired `hef.reduceMotion`; i18n `speed.label` + reworded `warn.body`.
Verified 14/14 node unit, 13/13 a11y e2e (Task 5 rewritten), static-build e2e
2/2 (built the dist bundle to confirm). Merged to `main` (`df8b245`) —
concurrent session pushed mid-merge, so rebased onto the new tip and pushed.
4. **Operator eyeball:** reverse was jittery; operator chose **0×–4×** as the
scale.
5. **Build (round 2, 0×–4× forward):** worktree `feat/speed-forward-0-4x`.
Removed the reverse rAF loop + `reverseStep` helper + its 4 unit tests;
slider `min=0 max=4`; datalist ticks at 0.5; spec annotated with a revision
note. Verified 10/10 node unit, 12/12 a11y e2e (against the actually-served
worktree code). Merged to `main` (`e064a2e`, clean FF) and pushed.
6. Served the merged code locally at `http://localhost:8099/` for re-view both
rounds.
### Pipeline (§9)
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
static build is the remaining ship step. E2E browser tests (Playwright) were run
locally and are green. No plan artifact archived: design lived in the spec
(already in this repo's `specs/`, merged); app has no `content` repo.
### Next session
Operator by-eye review of the 0×–4× speed feel on the live sim, then redeploy to
Cloudflare to publish it.
## Launch prompt
> Let's add a "Playback Speed" slider that goes from -2x (which would play
> backwards) to 2x, with increments of 0.25. This only applies to the altitude
> videos, not the morph videos. This can replace the "Reduce motion" toggle
_(Mid-session the operator refined: continuous drag rather than 0.25 detents;
then, after eyeballing reverse jitter, narrowed the scale to 0×–4× forward.)_
## Plan
> Anchor: leaf `feature`/`task` (UI control on an existing surface). Design →
> spec → TDD build → ship branch → PR/merge → operator redeploy.
Continuous Playback Speed slider driving ONLY the resting altitude loop video
(`#vid-loop`); morph/transition video (`#vid`) is scrub-driven and untouched.
Reduced-motion accessibility preserved but derived silently from OS
`prefers-reduced-motion` (slider disabled when active). Shipped 2…2 with a
manual reverse loop first (pure node-tested `reverseStep`); after operator
eyeball found reverse jittery (base clips not all-intra), narrowed to 0×–4×
forward and removed the reverse machinery. Verification: node unit + Playwright
a11y e2e + static-build e2e, all green per round. Project exempt from §9/PPE
(static Cloudflare).
## Deferred decisions
- **Direct FF-merge to `main` (no PR).** Operator explicitly said "Merge to
main," so both rounds landed via fast-forward (round 1 rebased over a
concurrent push) rather than opening a Gitea PR. Alternative: branch → PR →
merge (§5.4 norm). Operator-directed, so low risk.
- **Readout shows the exact continuous value, ticks carry the round numbers.**
The slider value is shown as e.g. `1.37×`; the "nice" increments (0.5 marks)
live as `<datalist>` ticks rather than snapping the value. My call within the
approved design; operator approved the spec.
- **Datalist tick spacing 0.5 over 04** (vs 0.25 over 2…2 in round 1). Cosmetic
choice to avoid clutter on the wider forward range. My call.
- **Reverse fully removed, not kept as a smaller range.** Operator said "just go
0x - 4x as the scale," so the `reverseStep` helper + tests were deleted rather
than parked. If reverse is wanted later, re-encode base loop clips all-intra
first (noted in memory + spec revision).
+3
View File
@@ -109,5 +109,8 @@
},
"0037": {
"title": ""
},
"0038": {
"title": ""
}
}
+5 -5
View File
@@ -49,7 +49,7 @@ test.describe("Task 4 — Photosensitivity notice", () => {
});
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
test("Speed is a 04x forward slider; the loop follows it (default 1x)", async ({ page }) => {
test("Speed is a 02x forward slider; the loop follows it (default 1x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
@@ -57,17 +57,17 @@ test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
await expect(slider).toBeVisible();
await expect(slider).toHaveValue("1"); // default = normal speed
await expect(slider).toHaveAttribute("min", "0");
await expect(slider).toHaveAttribute("max", "4");
await expect(slider).toHaveAttribute("max", "2"); // capped at 2x — base clips aren't all-intra, so >2x stutters
});
test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => {
test("forward speed sets the loop video's playbackRate (up to 2x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
await page.locator("#play-speed").fill("4");
await page.locator("#play-speed").fill("2");
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
.toBe(4);
.toBe(2);
});
test("0x freezes the loop video", async ({ page }) => {
+14
View File
@@ -108,6 +108,20 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page
await page.mouse.up();
});
test("reduce motion: selecting a new altitude still swaps to a fresh clip (instant cut)", async ({ page }) => {
await boot(page);
// #scale-name reads "<scale> · <clip> (i/n)"; extract the clip id.
const clipId = (s: string) => (s.split("·")[1] || "").split("(")[0].trim();
await page.locator('label[for="reduce-motion"]').click(); // engage reduce motion
await expect(page.locator("#reduce-motion")).toBeChecked();
const before = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend one altitude (instant cut)
await page.waitForFunction(() => /orbit/.test(document.querySelector("#scale-name")?.textContent || ""), null, { timeout: 20000 });
const after = (await page.locator("#scale-name").textContent())!;
expect(after).toContain("orbit");
expect(clipId(after)).not.toBe(clipId(before)); // the clip actually changed (not stuck on the old scale's clip)
});
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
+1 -1
View File
@@ -3,7 +3,7 @@ import { test, expect, Page } from "@playwright/test";
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
// points media cross-origin — the production CORS path, locally. See README.md.
const APP = "http://localhost:8077/human-experience-simulator/";
const APP = "http://localhost:8077/human-machine-strata/";
// Enter the simulation via the welcome screen (Video defaults on → media plays).
async function enter(page: Page) {
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About — Human Experience Filter</title>
<title>About — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -15,7 +15,7 @@
<section>
<h2>For everyone</h2>
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
<p><em>Human/Machine Strata</em> is built to be accessible to — and
representative of — the full range of human experience. That is why it works
with a keyboard, with a screen reader, with reduced motion, and away from any
single screen or kiosk: an experience about being human should be open to as
+6
View File
@@ -1015,6 +1015,12 @@ function setPos(next) {
const { lo, frac } = HEFScrub.segmentOf(pos);
if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack
busy = false;
// If we JUMPED straight here (a reduced-motion instant cut skips the segment that
// would re-roll the destination), activeClipId still points at the previous
// altitude's clip. Re-roll from this altitude's pool so the video actually changes.
if (!poolIds(ring.scales[ringIndex]).includes(activeClipId)) {
activeClipId = pickPoolClip(ringIndex, activeClipId);
}
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
// Land the loop on the frame the morph last showed for THIS travel direction:
+2 -2
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Label Author Mode</title>
<title>Human/Machine Strata — Label Author Mode</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/author.css" />
</head>
<body class="author">
<header><h1>HEF — Label Author Mode</h1>
<header><h1>Human/Machine Strata — Label Author Mode</h1>
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
</header>
<main>
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human Experience Filter</title>
<title>Credits — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -19,7 +19,7 @@
<section class="declaration">
<h2>License &amp; reuse</h2>
<p>
The <em>Human Experience Filter</em> alters its source footage in real time
<em>Human/Machine Strata</em> alters its source footage in real time
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
frame you see is a <strong>derivative</strong> of the original clip.
</p>
+3 -4
View File
@@ -17,7 +17,7 @@
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"app.title": { en: "Human/Machine Strata", es: "Human/Machine Strata", fr: "Human/Machine Strata", ja: "Human/Machine Strata" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"welcome.launch": { en: "Launch Simulator", es: "Iniciar simulador", fr: "Lancer le simulateur", ja: "シミュレーターを起動" },
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
@@ -25,13 +25,12 @@
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer daltitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
"knobs.legend": { en: "Experience knobs (04)", es: "Mandos de experiencia (04)", fr: "Boutons dexpérience (04)", ja: "体験つまみ(0〜4" },
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons dexpérience", ja: "体験つまみ" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
"speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", ja: "再生速度" },
"speed.label": { en: "Video speed", es: "Velocidad de vídeo", fr: "Vitesse vidéo", ja: "映像速度" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar — los cambios de altitud serán cortes instantáneos en vez de animarse.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer — les changements daltitude seront des coupes instantanées au lieu de sanimer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
+20 -21
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<title>Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
@@ -21,8 +21,8 @@
<label class="lang-pick">🌐
<select id="welcome-lang" aria-label="Language"></select>
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="welcome-play-speed" min="0" max="4" step="any" value="1"
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="welcome-play-speed" min="0" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
<span id="welcome-play-speed-val">1.00×</span>
</label>
@@ -44,7 +44,7 @@
</div>
</div>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<h1 data-i18n="app.title">Human/Machine Strata</h1>
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
@@ -65,6 +65,16 @@
<section class="panel">
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
</fieldset>
<fieldset>
<legend data-i18n="output.legend">Output</legend>
<label class="dev-switch" for="visual">
@@ -76,15 +86,15 @@
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="play-speed" min="0" max="4" step="any" value="1"
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="play-speed" min="0" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="play-speed-val" />
<span id="play-speed-val">1.00×</span>
</label>
<datalist id="play-speed-ticks">
<option value="0"></option><option value="0.5"></option><option value="1"></option>
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
<option value="3"></option><option value="3.5"></option><option value="4"></option>
<option value="0"></option><option value="0.25"></option><option value="0.5"></option>
<option value="0.75"></option><option value="1"></option><option value="1.25"></option>
<option value="1.5"></option><option value="1.75"></option><option value="2"></option>
</datalist>
<label class="dev-switch" for="reduce-motion">
<input type="checkbox" id="reduce-motion" />
@@ -97,18 +107,7 @@
</fieldset>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
</fieldset>
<fieldset>
<legend data-i18n="knobs.legend">Experience knobs (04)</legend>
<legend data-i18n="knobs.legend">Experience knobs</legend>
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
+7 -5
View File
@@ -9,9 +9,9 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
result = build_static(
out, media,
media_base="https://static.benstull.art/",
app_path="/human-experience-simulator",
app_path="/human-machine-strata",
)
app_dir = out / "human-experience-simulator"
app_dir = out / "human-machine-strata"
# frontend present under the app path; dev/author surfaces excluded
assert (app_dir / "index.html").exists()
@@ -25,10 +25,12 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
# apex + no-slash redirect rules
# apex + no-slash redirect rules, plus the permanent legacy-path redirect
redirects = (out / "_redirects").read_text()
assert "/ /human-experience-simulator/ 308" in redirects
assert "/human-experience-simulator /human-experience-simulator/ 308" in redirects
assert "/ /human-machine-strata/ 308" in redirects
assert "/human-machine-strata /human-machine-strata/ 308" in redirects
assert "/human-experience-simulator/ /human-machine-strata/ 301" in redirects
assert "/human-experience-simulator/* /human-machine-strata/:splat 301" in redirects
# baked JSON matches the API shape
clips = json.loads((app_dir / "clips.json").read_text())
+32 -11
View File
@@ -59,23 +59,44 @@ def _write_config(app_dir: Path, media_base: str) -> None:
)
# The app's original deploy path, kept as a permanent redirect so old links to
# /human-experience-simulator/… still land on the renamed /human-machine-strata/….
LEGACY_APP_PATH = "human-experience-simulator"
def _write_redirects(out: Path, seg: str) -> None:
# Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset
# urls resolve under the prefix). Cloudflare Pages `_redirects` syntax.
(out / "_redirects").write_text(
f"/ /{seg}/ 308\n"
f"/{seg} /{seg}/ 308\n"
)
# urls resolve under the prefix). Plus a permanent (301) redirect from the legacy
# path to the current one. Cloudflare Pages `_redirects` syntax.
lines = [
f"/ /{seg}/ 308\n",
f"/{seg} /{seg}/ 308\n",
]
if LEGACY_APP_PATH and LEGACY_APP_PATH != seg:
# Exact root (trailing slash) first — the `/*` splat does NOT match the empty
# case, so the bare /legacy/ needs its own rule. Then the splat for subpaths,
# then the no-trailing-slash form.
lines.append(f"/{LEGACY_APP_PATH}/ /{seg}/ 301\n")
lines.append(f"/{LEGACY_APP_PATH}/* /{seg}/:splat 301\n")
lines.append(f"/{LEGACY_APP_PATH} /{seg}/ 301\n")
(out / "_redirects").write_text("".join(lines))
def _write_headers(out: Path, seg: str) -> None:
# The app shell (html/js/json) must NOT be browser-cached — otherwise a deploy is
# invisible to returning visitors for hours (Pages' default is max-age=14400).
# `no-cache` = store but revalidate every load (cheap 304 via etag). The media is
# on R2 (immutable + content-hash), unaffected. Cloudflare Pages `_headers` syntax.
# The app shell must NOT be browser-cached — otherwise a deploy is invisible to
# returning visitors for hours (Pages' default is max-age=14400). `no-store`
# (never stored → a guaranteed-fresh fetch every visit) for the whole app path:
# `no-cache` (revalidate) was NOT enough — Safari served a STALE index from its
# disk/bfcache, loaded the OLD versioned JS, and broke the page (black, silent).
# A per-HTML no-store override didn't stick (Pages let the `/seg/*` rule win), so
# we no-store the whole path. The app shell is tiny (~150 KB); the media is on R2
# (immutable + content-hash, a separate domain) and is unaffected. The content-
# hashed asset URLs (see _version_assets) remain as belt-and-suspenders for the
# case where Pages caches .js/.css at the edge by type and ignores _headers.
# Cloudflare Pages `_headers` syntax.
(out / "_headers").write_text(
f"/{seg}/*\n"
" Cache-Control: no-cache\n"
" Cache-Control: no-store\n"
)
@@ -151,7 +172,7 @@ if __name__ == "__main__":
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")
ap.add_argument("--app-path", default="/human-machine-strata")
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))