Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ec8b607d2 | |||
| b135cb5f5e | |||
| 950c1e6c8c | |||
| 958c3060d2 | |||
| c36f995463 | |||
| b63518bfbb | |||
| 4555bb29a8 | |||
| 8c63b14967 | |||
| ecd444b53b | |||
| fafb498d6f | |||
| e06191db32 | |||
| ccd40103a2 | |||
| 79f64e3f7c | |||
| 7cdf594794 | |||
| 5f9ae6b212 | |||
| 54cad607fb | |||
| 053bb5c1a3 | |||
| c176ae7231 | |||
| 6647390cb1 | |||
| e064a2e684 | |||
| df8b245af3 | |||
| 539618b1b8 | |||
| 22bd152f33 | |||
| 5696b94328 | |||
| 8e790b4a95 | |||
| 1b646c8ade | |||
| 00fd9aab17 | |||
| 0334fc8bc4 |
@@ -30,3 +30,7 @@ simulator/static/review_audio.html
|
||||
# Static build outputs (tools/build_static.py)
|
||||
/dist/
|
||||
/dist-media/
|
||||
|
||||
# Playwright e2e artifacts (should never be committed)
|
||||
simulator/e2e/test-results/
|
||||
simulator/e2e/playwright-report/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,142 @@
|
||||
# Playback Speed slider (replaces "Reduce motion") — design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Shipped, then revised
|
||||
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
|
||||
|
||||
> **Revision (2026-06-30, post-eyeball):** Reverse playback was **dropped**. In a
|
||||
> real browser the below-0× reverse loop jittered — the base loop clips aren't
|
||||
> all-intra, so backward seeking can't stay smooth (exactly the risk flagged
|
||||
> below). The slider is now **0×–4× forward** (still continuous, `step="any"`).
|
||||
> `0×` still freezes; OS reduced-motion behavior is unchanged. The
|
||||
> `HEFScrub.reverseStep` helper and its tests were removed. Sections below
|
||||
> describing the −2…2 range and the manual reverse loop are superseded by this
|
||||
> note.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a continuous **Playback Speed** slider (−2× … 2×) that controls the resting
|
||||
**altitude loop video** (`#vid-loop`) in real time as the user drags it. Below
|
||||
`0×` the loop plays **in reverse**. The slider **replaces** the "Reduce motion"
|
||||
toggle in the Output panel — but the reduced-motion *accessibility behavior* is
|
||||
preserved, now derived silently from the OS `prefers-reduced-motion` setting
|
||||
rather than a visible toggle.
|
||||
|
||||
## What it affects (and what it does not)
|
||||
|
||||
- **Affects:** the resting **altitude loop video** `#vid-loop` — the clip that
|
||||
loops while parked at an altitude. The slider sets its play speed/direction.
|
||||
- **Does NOT affect:** the **morph / transition video** `#vid`. That element is
|
||||
scrub-driven — the altitude dial sets its `currentTime` directly — so a
|
||||
playback rate has no meaning there. Speed control never touches it.
|
||||
|
||||
## UI
|
||||
|
||||
Replace the `reduce-motion` checkbox `<label>` in `index.html` with a range
|
||||
slider, styled like the existing Audio level control:
|
||||
|
||||
```
|
||||
Speed [ −2 ········●········ 2 ] 1.37×
|
||||
```
|
||||
|
||||
- `<input type="range" id="play-speed" min="-2" max="2" step="any" value="1">`
|
||||
— `step="any"` so dragging yields a **continuous** value, not 0.25 detents.
|
||||
- A live numeric **readout** (`<span id="play-speed-val">`) shows the current
|
||||
value formatted to 2 decimals with a `×` suffix (e.g. `1.37×`, `−0.75×`,
|
||||
`0.00×`).
|
||||
- Reference **tick labels** at the nice increments (`−2 −1.75 … 0 … 2`) via a
|
||||
`<datalist>` bound to the input, so the round numbers are visible without
|
||||
quantizing the actual applied value.
|
||||
- New i18n key `speed.label` ("Speed") in en/es/fr/ja. The `rm.label` key is
|
||||
retired.
|
||||
|
||||
## Accessibility (preserved, now invisible)
|
||||
|
||||
The reduce-motion *toggle* is removed, but its *behavior* is kept. `isReduced()`
|
||||
now derives **live from the OS setting only** —
|
||||
`matchMedia("(prefers-reduced-motion: reduce)")`, re-read on its `change` event —
|
||||
with no stored override and no UI. Every existing `isReduced()` call site keeps
|
||||
working unchanged:
|
||||
|
||||
- loop-play gate (`playLoop`),
|
||||
- instant (no-tween) auto altitude transitions (`autoScrub`),
|
||||
- the photosensitivity-flash luminance floor.
|
||||
|
||||
Consequences:
|
||||
|
||||
- The `hef.reduceMotion` localStorage key (`RM_KEY`) is **retired**.
|
||||
- When OS reduced-motion is **active**: the loop stays frozen and the speed
|
||||
slider is **disabled** (with a short hint), so a vestibular/photosensitive
|
||||
visitor never gets motion they did not ask for. If the OS pref toggles at
|
||||
runtime, the slider enables/disables to match.
|
||||
- `warn.body` copy (all 4 langs) is reworded: it currently tells users to "turn
|
||||
on Reduce motion in the panel," which no longer exists. New copy points to the
|
||||
system reduced-motion setting (now auto-applied).
|
||||
|
||||
## Speed behavior (`app.js`)
|
||||
|
||||
A new `applyPlaySpeed(speed)` drives `#vid-loop`, with the chosen value persisted
|
||||
to `localStorage["hef.playSpeed"]` (default `1`). Applied live on the slider's
|
||||
`input` event:
|
||||
|
||||
- **speed > 0** → `loopVid.playbackRate = speed`; native forward play. The
|
||||
existing duration→`loopStartFrame()` wrap is unchanged. Any active manual
|
||||
reverse loop is cancelled.
|
||||
- **|speed| ≈ 0** (within a small epsilon) → pause the element (freeze on the
|
||||
current frame). Reversible by moving the slider off zero. Cancels any reverse
|
||||
loop.
|
||||
- **speed < 0** → native negative `playbackRate` is unreliable (Chrome ignores
|
||||
it), so a `requestAnimationFrame` loop owns the element: the element is
|
||||
**paused** and each frame decrements `loopVid.currentTime` by `|speed|·dt`.
|
||||
When it crosses the bottom boundary it **wraps to the tail** (mirror of the
|
||||
forward wrap), reusing the direction-aware landing offset. The loop is
|
||||
cancelled when speed goes ≥ 0, on reduced-motion freeze, when video output is
|
||||
turned off, or when a new loop clip loads.
|
||||
|
||||
Interaction with existing motion:
|
||||
|
||||
- The manual reverse loop and the **auto-transition tween** (`autoRaf`, which
|
||||
drives the *morph* `#vid`) are independent — different elements, different
|
||||
rAF handles. The reverse loop only owns `#vid-loop`.
|
||||
- On landing on a new altitude (`loadLoop` + `playLoop`), the current slider
|
||||
speed is re-applied so the new clip inherits the chosen rate/direction.
|
||||
|
||||
## Test seam + tests
|
||||
|
||||
Mirrors the existing `scrub.js` / `node --test` pattern:
|
||||
|
||||
- A **pure** helper added to `scrub.js`:
|
||||
`reverseStep(t, absSpeed, dt, loopStart, duration) → { next, wrapped }`
|
||||
— computes the next `currentTime` and whether a bottom-boundary wrap occurred.
|
||||
This is the reverse-loop math with no DOM; `app.js` just applies `next` to
|
||||
`loopVid.currentTime`. Covered by a new `node --test` case (steps, wrap at the
|
||||
boundary, clamping).
|
||||
- A **Playwright** e2e added to the existing suite: the slider is present;
|
||||
forward value sets `#vid-loop.playbackRate`; `0×` pauses it; a negative value
|
||||
makes `currentTime` decrease over time. Also assert the slider is disabled
|
||||
when `prefers-reduced-motion` is emulated.
|
||||
|
||||
## Known risk (noted, not blocking)
|
||||
|
||||
Reverse smoothness depends on the **base loop clips'** keyframe density. The
|
||||
morphs are all-intra (smooth reverse), but the base altitude clips may not be;
|
||||
on some browsers reverse seeking could stutter. The manual loop stays *correct*
|
||||
regardless — smoothness is a visual-polish follow-up (re-encode base clips
|
||||
all-intra if needed), not a blocker for this change.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `simulator/static/index.html` — swap toggle → slider + datalist; reword warn.
|
||||
- `simulator/static/app.js` — `applyPlaySpeed`, reverse rAF loop, derive
|
||||
`isReduced()` from OS pref, retire `RM_KEY`, re-apply speed on landing.
|
||||
- `simulator/static/scrub.js` — add pure `reverseStep` helper.
|
||||
- `simulator/static/i18n.js` — add `speed.label`, reword `warn.body` (en/es/fr/ja),
|
||||
retire `rm.label`.
|
||||
- `simulator/static/styles.css` — slider styling (reuse audio-level pattern).
|
||||
- Tests: `node --test` for `reverseStep`; Playwright e2e for the slider.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Re-encoding base clips for smoother reverse (follow-up if stutter is visible).
|
||||
- Any speed control over morph/transition videos.
|
||||
- Per-altitude or audio speed coupling (audio level is its own control).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Session 0036.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T06-30 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: finalized
|
||||
|
||||
## Launch prompt
|
||||
|
||||
"Let's publish this to benstull.art. Ideally this would be hosted on Cloudflare
|
||||
Pages. Cloudflare Pages does have limits though, so we should check that against
|
||||
this project." (Started conversationally — no tracked init; finalized as
|
||||
planning-and-executing/yolo.)
|
||||
|
||||
## Pre-state
|
||||
|
||||
The simulator was a FastAPI server (`simulator/app.py`) serving a ~92 KB static
|
||||
frontend + 3.9 GB of media (1080p clip bases + 558 already-720p transition morphs +
|
||||
audio) via dynamic endpoints. Not yet public anywhere. Operator wanted it live at
|
||||
benstull.art on Cloudflare Pages. Concurrent sessions (0029/0031/0033/0034/0035)
|
||||
were active on the SAME checkout.
|
||||
|
||||
## Arc
|
||||
|
||||
**1. Brainstorm → design → plan (Cloudflare publish).** Established the blocker:
|
||||
Pages has a 25 MiB/file hard limit; the project has 26 files >25 MiB. Designed a
|
||||
fully-static target: Pages frontend + R2 media (no per-file limit, free egress) +
|
||||
read-APIs baked to JSON + client-side pick + `/api/alteration` ported to JS.
|
||||
Operator chose: both tiers irrelevant→dropped; path `/human-experience-simulator`
|
||||
with apex redirect; no cross-session cache (initially). Spec + plan written under
|
||||
`docs/superpowers/`. Operator declared the WHOLE project exempt from flotilla/PPE/§9.
|
||||
|
||||
**2. Executed the plan (5 tasks).** Client-side alteration engine (`alteration.js`
|
||||
+ node/py parity tests); config-driven `mediaBase` + baked-JSON boot + client pick +
|
||||
`crossOrigin` for the WebGL dream shader; `tools/build_static.py` (dist/ + manifest-
|
||||
only ~2.3 GB media tree + `_redirects`); Cloudflare deploy artifacts; static-build
|
||||
E2E. Hit + cleanly extracted a parallel session's in-flight "Run simulation" work
|
||||
that got swept into a commit (operator chose extraction).
|
||||
|
||||
**3. Live deploy.** Installed wrangler; operator did `wrangler login`. Created R2
|
||||
bucket `human-experience-simulator-media`, CORS (wrangler's `{rules:[...]}` schema,
|
||||
not S3-style), bound `static.benstull.art` via CLI (zone-id from API), parallel
|
||||
604-file upload (no `wrangler r2 sync` exists). Created + deployed Pages project;
|
||||
operator bound `benstull.art`. **Live.**
|
||||
|
||||
**4. Debugging (systematic).** "Morphs not showing" = upload still in progress
|
||||
(transitions sort last) → fixed by parallel upload. "Video + audio not playing" →
|
||||
drove the LIVE site with Playwright: video fine, **audio 404'd** because
|
||||
`scaleAudioUrl` hardcoded `/media/audio/` instead of `mediaBase()` — fixed.
|
||||
"Still no video after hard refresh" → **stale cached JS** (Pages caches .js
|
||||
max-age=14400, ignores `_headers`) → fixed with content-versioned asset URLs
|
||||
(`app.js?v=<hash>`) + no-cache `_headers`. Private-window confirmed fresh code worked.
|
||||
|
||||
**5. Preload algorithm.** "Next altitude's video doesn't start" = a buggy
|
||||
hold-last-frame settle gate I'd added (gated playLoop behind a canplay that doesn't
|
||||
re-fire) → reverted. Operator specified the algorithm: gate on 1 clip/altitude +
|
||||
connecting morphs (~126 MB, ~10-20s — measured), then grow the pool in the
|
||||
background; the random pick is restricted to fully-loaded (eligible) clips so
|
||||
navigation never stalls and new clips are reachable only once fully downloaded.
|
||||
Built `preload.js` (pure planner + node tests), wired phase-1 gate + eligible picks.
|
||||
|
||||
**6. Worktree isolation enforcement.** The shared checkout caused branch-switch
|
||||
chaos (my commits landed on a switched-to `main`). Operator: "make sure every
|
||||
session works off a fresh worktree not in the main directory." Built
|
||||
`.claude/settings.json` (worktree.bgIsolation + baseRef:fresh) + a PreToolUse hook
|
||||
(`worktree-guard.sh`) that DENIES edits to repo files while in the main checkout +
|
||||
SessionStart notice → EnterWorktree. Proven live (blocked my own edit); fixed an
|
||||
over-broad version that blocked external files too. Dogfooded the merge of guard +
|
||||
preload onto origin/main IN an isolated worktree.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **Live:** https://benstull.art/human-experience-simulator/ — Pages + R2, preload
|
||||
algorithm, audio fixed, cache-versioned. Operator confirmed video+audio work.
|
||||
- **origin/main (`a4d3d83`+):** has the Cloudflare publish, preload algorithm, and
|
||||
worktree-isolation guard, merged cleanly with parallel a11y work.
|
||||
- Plan archived in-repo (`docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`),
|
||||
on origin/main. **No `content` repo** → plan stays in-repo (app.json gap).
|
||||
- Local `main` checkout is behind origin/main (parallel session actively deploying).
|
||||
- Backup branches on origin: `chore/worktree-isolation`, `fix/preload-gate`,
|
||||
`design/cloudflare-static-publish` (content all merged to main; safe to delete).
|
||||
|
||||
## Pipeline (§9) status
|
||||
|
||||
Operator-set Cloudflare exception (no flotilla/PPE). localhost + **E2E green** (33
|
||||
node tests; 13 altitude-lock + 2 static-build Playwright); no PPE (exempt); **prod =
|
||||
live** via `make deploy`. Repeatable deploy: `make deploy` / `make deploy-media`
|
||||
(`deploy/cloudflare/`).
|
||||
|
||||
## Operator plate (next session)
|
||||
|
||||
- By-eye/ear review of the live site: load time, video, audio, rapid-altitude
|
||||
navigation smoothness (eligible-pick growth), the preload behavior under real
|
||||
network. The hold/grow path only triggers when media isn't cached — needs eyes.
|
||||
- Restart any still-running sessions so they pick up the worktree guard + move to
|
||||
worktrees. Clean up backup branches if desired.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- Estimated session start time (no tracked init) — used 2026-06-30T06-30.
|
||||
- Did NOT reconcile local `main` (behind origin/main) — a parallel session is
|
||||
actively deploying origin/main; left the shared checkout untouched to avoid
|
||||
disrupting it.
|
||||
- Cross-session Service-Worker disk cache and a 720p base tier were proposed and
|
||||
DEFERRED (morphs already 720p; bases dominate load but per-altitude growth covers it).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Session 0037.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T10-42 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: aa7ac424-d797-4918-8837-2a904441d91e
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/session-0037-altitude-snap-on-release
|
||||
> End: 2026-06-30T10-52 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
|
||||
## Summary
|
||||
|
||||
Single-task UX session. Made the altitude dial **snap to the closest altitude on
|
||||
drag release** so a grab-and-release always lands on a discrete altitude, just
|
||||
like a tap/label-click.
|
||||
|
||||
### Arc
|
||||
|
||||
1. Claimed session 0037 (two stale `--INPROGRESS` placeholders 0015/0017 noted,
|
||||
not live). Worked in an isolated worktree
|
||||
(`session-0037-altitude-snap-on-release`) branched off `origin/main` (local
|
||||
`main` was 7 behind).
|
||||
2. Located the behavior: `onDialUp` (`simulator/static/app.js`) held the live
|
||||
blend wherever a real turn stopped — the "no auto-complete" continuous-encoder
|
||||
model. Tap path (`moved < 6` → `jumpToScale`) and wheel/label auto-scrub were
|
||||
already snap-to-altitude.
|
||||
3. TDD: added an `altitude-lock.spec.ts` e2e ("releasing a partial drag snaps to
|
||||
the closest altitude") — confirmed RED (held pos never became integer).
|
||||
4. Implemented: a real turn (`moved >= 6`) now `autoScrub(Math.round(pos))` →
|
||||
short scrub to the nearest altitude, settle + lock (frac 0). Reduced motion is
|
||||
handled inside `autoScrub` (jump-to-target). GREEN.
|
||||
5. Verified: full altitude-lock (14) + a11y + i18n e2e suites green. Committed,
|
||||
pushed, opened PR #31, merged to `main` (`5696b94`).
|
||||
|
||||
### 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. No plan artifact archived: the change
|
||||
was a trivial leaf `task`, planned inline (no separate plan file; app has no
|
||||
content repo).
|
||||
|
||||
### Next session
|
||||
|
||||
Operator by-eye review of the snap feel on the live sim, then redeploy to
|
||||
Cloudflare.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> When the user is dragging the slider (rather than clicking on an altitude),
|
||||
> let's transition to the closest altitude when they let go. That way, the user
|
||||
> will always land on an altitude as if they clicked one, even if they're
|
||||
> grabbing and releasing the slider.
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: leaf `task` (UX behavior tweak — directly plan-eligible, R2b).
|
||||
|
||||
Snap-on-release for the altitude dial. Today `onDialUp` (app.js) holds the live
|
||||
blend wherever a drag stops (the "no auto-complete" continuous-encoder model).
|
||||
Change: after a real turn (`moved >= 6`), `autoScrub(Math.round(pos))` so the
|
||||
knob settles on the CLOSEST altitude (frac 0 → lock), identical to a tap/click.
|
||||
Tap path (`moved < 6` → `jumpToScale`) unchanged. Reduced-motion is handled by
|
||||
`autoScrub` (jump-to-target, no tween).
|
||||
|
||||
Verification: new e2e in `altitude-lock.spec.ts` ("releasing a partial drag
|
||||
snaps to the closest altitude") — TDD red→green; full altitude-lock (14) + a11y
|
||||
+ i18n suites green. Project is exempt from §9/PPE (static Cloudflare target);
|
||||
ships branch → PR → merge → operator redeploy.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -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 0–4** (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).
|
||||
@@ -103,5 +103,14 @@
|
||||
},
|
||||
"0035": {
|
||||
"title": ""
|
||||
},
|
||||
"0036": {
|
||||
"title": ""
|
||||
},
|
||||
"0037": {
|
||||
"title": ""
|
||||
},
|
||||
"0038": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -37,34 +37,71 @@ test.describe("Task 1 — Output panel layout", () => {
|
||||
});
|
||||
|
||||
test.describe("Task 4 — Photosensitivity notice", () => {
|
||||
test("the welcome screen carries the heads-up notice and a Reduce-motion control", async ({ page }) => {
|
||||
test("the welcome screen carries the heads-up notice and the Speed control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// The heads-up is shown on the welcome screen itself (it is the entry gate).
|
||||
await expect(page.locator("#welcome-title")).toBeVisible();
|
||||
await expect(page.locator("#welcome-title")).toHaveText(/motion|flashing/i);
|
||||
// The Reduce-motion control sits right there, before the visitor launches.
|
||||
// (The checkbox itself is the visually-hidden pattern; assert its label.)
|
||||
await expect(page.locator('label[for="welcome-reduce-motion"]')).toBeVisible();
|
||||
// The Speed control + Launch button sit right there, before the visitor launches.
|
||||
await expect(page.locator("#welcome-play-speed")).toBeVisible();
|
||||
await expect(page.locator("#welcome-launch")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 5 — Reduced motion", () => {
|
||||
test("reduce-motion toggle pauses video playback", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
|
||||
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
|
||||
test("Speed is a 0–2x forward slider; the loop follows it (default 1x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked(); // default-off under no-preference
|
||||
await enter(page); // Video defaults on → experience runs
|
||||
await page.waitForTimeout(500);
|
||||
await enter(page);
|
||||
const slider = page.locator("#play-speed");
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveValue("1"); // default = normal speed
|
||||
await expect(slider).toHaveAttribute("min", "0");
|
||||
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 2x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator("#play-speed").fill("2");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
test("0x freezes the loop video", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator("#play-speed").fill("0");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(false); // experience running → video plays
|
||||
const rm = page.locator("#reduce-motion");
|
||||
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
|
||||
await expect(rm).toBeChecked();
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(true); // reduced motion → frozen
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("Reduce motion defaults from the OS setting and does NOT disable Speed", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
// The welcome toggle reflects the OS preference before launch.
|
||||
await expect(page.locator("#welcome-reduce-motion")).toBeChecked();
|
||||
await enter(page);
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked(); // panel mirrors it
|
||||
await expect(page.locator("#play-speed")).toBeEnabled(); // Speed is independent of reduce-motion
|
||||
});
|
||||
|
||||
test("Reduce motion turns altitude changes into instant cuts (no morph tween)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator('label[for="reduce-motion"]').click(); // hidden checkbox → toggle via label
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
const t0 = await page.evaluate(() => performance.now());
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 }); // descend one altitude
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 5000 });
|
||||
const dt = (await page.evaluate(() => performance.now())) - t0;
|
||||
expect(dt).toBeLessThan(600); // instant cut, not the ~1200ms animated morph tween
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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())!;
|
||||
@@ -254,7 +268,8 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
|
||||
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
|
||||
// crossing integer 1 (orbit) so it commits, then hold past it WHILE STILL DRAGGING
|
||||
// (the live blend holds wherever the knob is held; the snap happens only on release).
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
|
||||
@@ -271,3 +286,30 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
|
||||
expect(st2.activeClipId).toBe(st.activeClipId);
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test("releasing a partial drag snaps to the closest altitude (no held mid-blend)", async ({ page }) => {
|
||||
// The operator can grab the dial, turn it PART of a detent, and let go between two
|
||||
// altitudes. On release the knob must auto-scrub to the CLOSEST altitude and settle
|
||||
// there (integer position, frac 0) — landing on a discrete altitude exactly as if
|
||||
// they had clicked it, rather than holding the half-finished blend.
|
||||
await boot(page);
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // ~0.5 detent — parks BETWEEN altitudes
|
||||
// While held we are mid-blend: a fractional knob position.
|
||||
await page.waitForFunction(
|
||||
() => { const p = (window as any).__hefState().pos; return Math.abs(p - Math.round(p)) > 0.05; },
|
||||
null, { timeout: 5000 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
// After release the position settles on an integer altitude (the snap).
|
||||
await page.waitForFunction(
|
||||
() => Number.isInteger((window as any).__hefState().pos),
|
||||
null, { timeout: 10000 },
|
||||
);
|
||||
const st = await page.evaluate(() => (window as any).__hefState());
|
||||
expect(Number.isInteger(st.pos)).toBe(true); // landed on an altitude, not held mid-blend
|
||||
expect(st.ringIndex).toBe(st.pos); // committed altitude == the position it landed on
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -25,12 +25,13 @@ test("welcome screen shows on load with the expected defaults; the panel is hidd
|
||||
await expect(page.locator("#welcome")).toBeVisible();
|
||||
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
|
||||
|
||||
// Defaults: Video ON, Reduce motion OFF, Audio 2, English.
|
||||
await expect(page.locator("#welcome-visual")).toBeChecked();
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
|
||||
// Defaults: Language English, Speed 1, Audio 2, Reduce motion off (no OS pref).
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-play-speed")).toHaveValue("1");
|
||||
await expect(page.locator("#welcome-play-speed-val")).toHaveText("1.00×");
|
||||
await expect(page.locator("#welcome-audio")).toHaveValue("2");
|
||||
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
|
||||
|
||||
// Heads-up + Launch button present; "Loading Universe" progress present.
|
||||
await expect(page.locator("#welcome-title")).toBeVisible();
|
||||
@@ -66,7 +67,7 @@ test("Launch when already loaded enters the simulation directly and reveals the
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.locator(".panel")).toBeVisible();
|
||||
|
||||
// Welcome defaults carried into the panel: Video on, Audio 2.
|
||||
// Welcome assumes Video on; Audio default 2 carried into the panel.
|
||||
await expect(page.locator("#visual")).toBeChecked();
|
||||
await expect(page.locator("#audio")).toHaveValue("2");
|
||||
});
|
||||
@@ -76,14 +77,13 @@ test("welcome control values carry into the panel on entry", async ({ page }) =>
|
||||
await ready(page);
|
||||
|
||||
await setRange(page, "welcome-audio", "5");
|
||||
await page.locator('label[for="welcome-visual"]').click(); // default-on → off
|
||||
await page.locator('label[for="welcome-reduce-motion"]').click(); // default-off → on
|
||||
await setRange(page, "welcome-play-speed", "2"); // mirrors into the panel slider live
|
||||
await page.locator("#welcome-launch").click();
|
||||
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.locator("#audio")).toHaveValue("5");
|
||||
await expect(page.locator("#visual")).not.toBeChecked();
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
await expect(page.locator("#play-speed")).toHaveValue("2");
|
||||
await expect(page.locator("#visual")).toBeChecked(); // welcome assumes video on
|
||||
});
|
||||
|
||||
test("Launch while still loading shows the centered loader with controls, then auto-enters", async ({ page }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
+86
-32
@@ -312,8 +312,7 @@ function loadLoop(clip, landFrame) {
|
||||
}
|
||||
|
||||
function playLoop() {
|
||||
if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
applyPlaySpeed(); // honor the chosen speed (forward, or 0x = freeze) — independent of reduce-motion
|
||||
}
|
||||
|
||||
function ensureClipMedia() {
|
||||
@@ -1016,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:
|
||||
@@ -1023,8 +1028,7 @@ function setPos(next) {
|
||||
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
|
||||
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
|
||||
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
|
||||
if (!isReduced()) playLoop(); // reduced motion holds the landing frame; else run the base loop
|
||||
else { vid.pause(); loopVid.pause(); }
|
||||
playLoop(); // run the resting loop at the chosen Speed (0x freezes it) — independent of reduce-motion
|
||||
showActiveSource();
|
||||
setNeedle(ringIndex * dialStep());
|
||||
renderScaleReadout();
|
||||
@@ -1076,7 +1080,11 @@ function onDialUp(e) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
|
||||
// Snap on release: a drag is a way of GRABBING the dial, not a free-standing
|
||||
// encoder detent — so when the operator lets go between altitudes, auto-scrub the
|
||||
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
|
||||
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
|
||||
autoScrub(Math.round(pos));
|
||||
}
|
||||
|
||||
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
|
||||
@@ -1115,18 +1123,72 @@ function onDialKey(e) {
|
||||
// data the renderer already has (clips, ring, the alteration response, the preload
|
||||
// cache) — no server endpoints. Editing labels stays in /author.html.
|
||||
const DEV_KEY = "hef.devMode";
|
||||
const SPEED_KEY = "hef.playSpeed";
|
||||
const RM_KEY = "hef.reduceMotion";
|
||||
let devMode = false;
|
||||
let devStatsTimer = null;
|
||||
|
||||
// --- Reduced motion (freeze-to-stills) ---------------------------------------
|
||||
// Default follows the OS `prefers-reduced-motion` until the visitor chooses, then
|
||||
// their choice persists. When ON: video holds a frame (paused), auto transitions
|
||||
// jump instantly instead of tweening — knob changes still re-grade the still.
|
||||
// --- Playback speed -----------------------------------------------------------
|
||||
// The Playback Speed slider (0x–4x forward) drives ONLY the resting altitude loop
|
||||
// video (#vid-loop); the morph/transition video (#vid) is scrub-driven and is
|
||||
// never speed-controlled. 0x freezes the loop on its current frame. (Reverse was
|
||||
// dropped: base loop clips aren't all-intra, so reverse seeking jitters.)
|
||||
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
|
||||
let playSpeed = 1;
|
||||
|
||||
// Apply the chosen slider speed to the resting loop video. When video output is
|
||||
// off we just keep the forward rate sane for when it resumes.
|
||||
function applyPlaySpeed() {
|
||||
if (!videoEverOn || !$("visual").checked) {
|
||||
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
|
||||
return;
|
||||
}
|
||||
if (playSpeed > SPEED_EPS) {
|
||||
loopVid.playbackRate = playSpeed;
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
} else {
|
||||
loopVid.pause(); // 0x → freeze on the current frame
|
||||
}
|
||||
}
|
||||
// Both the welcome screen's slider and the panel's drive the same playSpeed; each
|
||||
// mirrors the other and the shared "1.00×" readout.
|
||||
const SPEED_SLIDERS = ["welcome-play-speed", "play-speed"];
|
||||
const SPEED_VALS = ["welcome-play-speed-val", "play-speed-val"];
|
||||
function syncSpeedSliders() {
|
||||
for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); }
|
||||
}
|
||||
function updateSpeedLabel() {
|
||||
const txt = playSpeed.toFixed(2) + "×";
|
||||
for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; }
|
||||
}
|
||||
function initPlaySpeed() {
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
|
||||
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
|
||||
syncSpeedSliders();
|
||||
for (const id of SPEED_SLIDERS) {
|
||||
const slider = $(id);
|
||||
if (!slider) continue;
|
||||
slider.addEventListener("input", () => {
|
||||
playSpeed = parseFloat(slider.value) || 0;
|
||||
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
|
||||
syncSpeedSliders();
|
||||
updateSpeedLabel();
|
||||
applyPlaySpeed();
|
||||
});
|
||||
}
|
||||
updateSpeedLabel();
|
||||
}
|
||||
|
||||
// --- Reduce motion (morph transitions) ----------------------------------------
|
||||
// A visible toggle (welcome + panel, mirrored) that turns OFF the morph/transition
|
||||
// videos: when ON, altitude changes are instant cuts instead of an animated morph
|
||||
// tween (see autoScrub's reduced branch). It does NOT touch the resting loop —
|
||||
// that is the Speed slider's job (Speed 0 freezes the loop independently).
|
||||
// Defaults from the OS `prefers-reduced-motion` setting, then remembers the
|
||||
// visitor's choice.
|
||||
let reduceMotion = false;
|
||||
function isReduced() { return reduceMotion; }
|
||||
// Two checkboxes drive the same reduceMotion state: the welcome screen's and the
|
||||
// panel's. Either flips the flag, persists it, mirrors the other, and re-applies.
|
||||
const RM_BOXES = ["welcome-reduce-motion", "reduce-motion"];
|
||||
function syncReduceMotionBoxes() {
|
||||
for (const id of RM_BOXES) { const b = $(id); if (b && b.checked !== reduceMotion) b.checked = reduceMotion; }
|
||||
@@ -1141,22 +1203,11 @@ function initReduceMotion() {
|
||||
const box = $(id);
|
||||
if (!box) continue;
|
||||
box.addEventListener("change", () => {
|
||||
reduceMotion = box.checked;
|
||||
reduceMotion = box.checked; // governs FUTURE transitions (cut vs morph)
|
||||
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
|
||||
syncReduceMotionBoxes();
|
||||
applyReduceMotion();
|
||||
});
|
||||
}
|
||||
applyReduceMotion();
|
||||
}
|
||||
function applyReduceMotion() {
|
||||
if (reduceMotion) {
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
vid.pause();
|
||||
loopVid.pause();
|
||||
} else if (videoEverOn && $("visual").checked) {
|
||||
playLoop(); // resume the held loop on opt-out
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
|
||||
@@ -1478,22 +1529,24 @@ let videoEverOn = false; // has Video ever been switched on this session?
|
||||
let universeReady = false; // has phase-1 preload finished (safe to enter)?
|
||||
let launchPending = false; // did the visitor press Launch while still loading?
|
||||
|
||||
// Begin the experience: mark video-on and play the soundtrack at the chosen
|
||||
// level. Called from the Launch gesture so the audio play() unlocks on Safari
|
||||
// (which blocks play() outside a user gesture). Idempotent via videoEverOn.
|
||||
// Begin the experience: mark video-on, play the soundtrack at the chosen level,
|
||||
// and start the resting loop at the chosen speed. Called from the Launch gesture
|
||||
// so the audio play() unlocks on Safari (which blocks play() outside a user
|
||||
// gesture). Idempotent via videoEverOn.
|
||||
function beginExperience() {
|
||||
videoEverOn = true;
|
||||
updateAudioLevelLabel();
|
||||
applyAudio();
|
||||
applyPlaySpeed();
|
||||
}
|
||||
|
||||
// Welcome screen ----------------------------------------------------------------
|
||||
// The welcome controls are mirrors of the panel controls. Reduce-motion and
|
||||
// language drive shared state live (see initReduceMotion / setLanguage); Video and
|
||||
// Audio are copied into the panel inputs at entry.
|
||||
// The welcome controls mirror the panel controls. Speed and Language drive shared
|
||||
// state live (see initPlaySpeed / setLanguage). Video is assumed ON at entry; the
|
||||
// Audio level is copied into the panel input.
|
||||
function applyWelcomeToPanel() {
|
||||
const wv = $("welcome-visual"), wa = $("welcome-audio");
|
||||
if (wv) $("visual").checked = wv.checked;
|
||||
$("visual").checked = true; // the welcome screen assumes Video on (no toggle)
|
||||
const wa = $("welcome-audio");
|
||||
if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); }
|
||||
}
|
||||
function updateWelcomeAudioLabel() {
|
||||
@@ -1544,7 +1597,8 @@ async function main() {
|
||||
buildDial(); // draw the altitude knob from the ring's scales
|
||||
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
|
||||
initLanguage(); // populate both language dropdowns + wire live switching
|
||||
initReduceMotion(); // reduced-motion state + both toggles (default from OS pref)
|
||||
initPlaySpeed(); // playback-speed sliders (welcome + panel)
|
||||
initReduceMotion(); // reduce-motion toggles (welcome + panel) — morph transitions off
|
||||
initWelcome(); // wire the welcome-screen controls + Launch button
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 d’Expérience Humaine — Aperçu d’alté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 l’univers", 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,15 +25,15 @@
|
||||
"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 d’altitude — 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 (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ(0〜4)" },
|
||||
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expé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: "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.", 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.", 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.", 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 d’altitude seront des coupes instantanées au lieu de s’animer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
|
||||
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
|
||||
|
||||
+31
-22
@@ -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>
|
||||
@@ -15,13 +15,16 @@
|
||||
<div class="welcome-inner">
|
||||
<div class="welcome-message">
|
||||
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion & flashing</h2>
|
||||
<p data-i18n="warn.body">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.</p>
|
||||
<p data-i18n="warn.body">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.</p>
|
||||
</div>
|
||||
<div class="welcome-controls">
|
||||
<label class="dev-switch" for="welcome-visual">
|
||||
<input type="checkbox" id="welcome-visual" checked />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="welcome-lang" aria-label="Language"></select>
|
||||
</label>
|
||||
<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>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
|
||||
@@ -32,9 +35,6 @@
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
|
||||
</label>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="welcome-lang" aria-label="Language"></select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="welcome-launch" class="run-sim" data-i18n="welcome.launch">Launch Simulator</button>
|
||||
<div class="welcome-loading">
|
||||
@@ -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 & 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,6 +86,16 @@
|
||||
<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">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.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" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
@@ -87,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 (0–4)</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>
|
||||
|
||||
@@ -153,6 +153,10 @@ input[type=range], select { width: 100%; }
|
||||
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.audio-level input[type=range] { flex: 1; width: auto; }
|
||||
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
|
||||
/* Playback speed (−2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
|
||||
#play-speed-val, #welcome-play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }
|
||||
.play-speed input[type=range]:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.play-speed:has(input:disabled) { opacity: 0.6; }
|
||||
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
|
||||
win, so the panel truly collapses when Dev Mode is off. */
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -120,9 +120,9 @@ def test_panel_silent_until_launch(page):
|
||||
|
||||
def test_welcome_launch_shown_after_load_with_defaults(page):
|
||||
# Once media is preloaded the welcome screen shows its Launch button with the
|
||||
# default controls (Video on, Audio 2) while the panel stays silent.
|
||||
# default controls (Speed 1, Audio 2) while the panel stays silent.
|
||||
assert page.is_visible("#welcome-launch")
|
||||
assert page.is_checked("#welcome-visual") is True
|
||||
assert page.evaluate("document.getElementById('welcome-play-speed').value") == "1"
|
||||
assert page.evaluate("document.getElementById('welcome-audio').value") == "2"
|
||||
|
||||
|
||||
|
||||
+32
-11
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user