Compare commits

..

18 Commits

Author SHA1 Message Date
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
BenStullsBets e064a2e684 feat(speed): switch Playback Speed to 0x–4x forward (drop reverse)
Real-browser eyeball: below-0x reverse jittered because the base loop
clips aren't all-intra (the flagged risk). Narrow the slider to 0x–4x
forward (still continuous, step=any); 0x still freezes; OS reduced-motion
behavior unchanged. Removes the manual rAF reverse loop and the
HEFScrub.reverseStep helper + its unit tests.

Tests: node unit 10/10; a11y e2e 12/12 (Task 5 now 3 slider tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:48:14 -07:00
BenStullsBets df8b245af3 feat(speed): Playback Speed slider replaces Reduce motion toggle
Continuous -2x..2x slider drives ONLY the resting altitude loop video
(#vid-loop); the morph/transition video is scrub-driven and untouched.
Forward = native playbackRate; ~0x = freeze; below 0x = a manual rAF
reverse loop (native negative playbackRate is unreliable). Reverse math
is the pure, node-tested HEFScrub.reverseStep helper.

Reduced-motion accessibility is preserved but now derives live from the
OS prefers-reduced-motion setting (no visible toggle): when active the
loop stays frozen and the slider is disabled. Retires hef.reduceMotion.
i18n: speed.label (en/es/fr/ja); warn.body reworded to point at the OS
setting.

Tests: +4 node unit cases for reverseStep (14/14 green); a11y e2e Task 5
rewritten for the slider (13/13 green); static-build e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:39:06 -07:00
BenStullsBets 539618b1b8 docs(spec): Playback Speed slider replaces Reduce motion toggle
Continuous -2x..2x speed control for the resting altitude loop video
(#vid-loop); manual rAF reverse loop below 0x; reduced-motion a11y
behavior preserved invisibly via OS prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:39:06 -07:00
BenStullsBets 22bd152f33 add sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md + replace placeholder/variant SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md 2026-06-30 10:54:38 -07:00
benstull 5696b94328 Merge pull request 'feat(dial): snap to the closest altitude on drag release' (#31) from worktree-session-0037-altitude-snap-on-release into main 2026-06-30 17:51:39 +00:00
BenStullsBets 8e790b4a95 feat(dial): snap to the closest altitude on drag release
A drag is a way of grabbing the altitude dial, not a free-standing encoder
detent. Until now onDialUp held the live blend wherever the knob stopped (the
"no auto-complete" model), so a grab-and-release could leave the experience
parked mid-morph between two altitudes.

Now a real turn (moved >= 6) auto-scrubs the short way to Math.round(pos) and
settles there (frac 0 locks) — a grab-and-release always lands on a discrete
altitude, exactly like a tap or label click. The tap path (moved < 6 ->
jumpToScale) is unchanged, and reduced motion is handled by autoScrub (jump to
target, no tween).

Adds an altitude-lock e2e asserting a partial drag settles on an integer
altitude after release. Full altitude-lock (14) + a11y + i18n suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:50:51 -07:00
BenStullsBets 1b646c8ade claim human-experience-filter-art session 0037 (placeholder) + sessions.json entry 2026-06-30 10:45:10 -07:00
BenStullsBets 00fd9aab17 add sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md + replace placeholder/variant SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md 2026-06-30 10:39:44 -07:00
BenStullsBets 0334fc8bc4 claim human-experience-filter-art session 0036 (placeholder) + sessions.json entry 2026-06-30 10:36:01 -07:00
BenStullsBets a4d3d831b0 Merge remote-tracking branch 'origin/main' into chore/land-isolation 2026-06-30 10:20:33 -07:00
BenStullsBets d711db6a2c fix(guard): scope the edit-block to files INSIDE the repo
The PreToolUse guard blocked ALL edits while in the main checkout — including files
outside the repo (auto-memory, /tmp, scratchpad). Now it only denies edits to files
under the main checkout's working tree; external files are allowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:18:39 -07:00
BenStullsBets 17856cac32 chore(dev): enforce isolated worktrees outside main for every session
Multiple sessions sharing this one checkout clobbered each other (branch switches,
edits landing on the wrong branch). Enforce per-session isolation:
- worktree.bgIsolation=worktree + baseRef=fresh (native bg-session isolation)
- PreToolUse(Edit|Write|NotebookEdit) hook DENIES edits while in the main checkout
- SessionStart hook directs each session to EnterWorktree (fresh worktree, off main)
.claude/hooks/worktree-guard.sh detects main-vs-worktree via git-dir != git-common-dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:15:57 -07:00
BenStullsBets 34478bb3a3 add sessions/0035/SESSION-0035.0-TRANSCRIPT-2026-06-30T08-45--2026-06-30T10-08.md + replace placeholder/variant SESSION-0035.0-TRANSCRIPT-2026-06-30T08-45--INPROGRESS.md 2026-06-30 10:15:43 -07:00
BenStullsBets f7abb5c29b claim human-experience-filter-art session 0035 (placeholder) + sessions.json entry 2026-06-30 10:10:03 -07:00
BenStullsBets 86ce291056 add sessions/0034/SESSION-0034.0-TRANSCRIPT-2026-06-30T07-24--2026-06-30T10-01.md + replace placeholder/variant SESSION-0034.0-TRANSCRIPT-2026-06-30T07-24--INPROGRESS.md 2026-06-30 10:06:07 -07:00
BenStullsBets e0964eae6c claim human-experience-filter-art session 0034 (placeholder) + sessions.json entry 2026-06-30 10:03:35 -07:00
15 changed files with 888 additions and 42 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
# they share the main checkout they clobber each other (branch switches, lost edits,
# commits on the wrong branch). Every session must work in its OWN git worktree
# OUTSIDE the main directory.
#
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
# files INSIDE the main checkout while in it. Files
# outside the repo (memory, /tmp, scratchpad) are allowed.
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
#
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
set -u
mode="${1:-notice}"
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
root=$(git rev-parse --show-toplevel 2>/dev/null)
repo=$(basename "${root:-repo}")
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
a fresh worktree branched from origin/main), or run \
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
Do NOT edit files in the main checkout."
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
if [ "$mode" = "block" ]; then
# Only guard files INSIDE the main checkout's working tree; allow edits to files
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
fp=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
except Exception: print("")' 2>/dev/null)
case "$fp" in
/*) abs="$fp" ;;
"") abs="$root" ;;
*) abs="$root/$fp" ;;
esac
case "$abs" in
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
*) exit 0 ;; # outside the repo → allow
esac
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
else
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
fi
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"worktree": {
"bgIsolation": "worktree",
"baseRef": "fresh"
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
}
]
}
]
}
}
@@ -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,107 @@
# Session 0034.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T07-24 (PST)
> End: 2026-06-30T10-01 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
> Status: FINALIZED
## Launch prompt
> The audio isn't playing at all. Let's fix that and then let's make sure there are
> left and right brain tracking labels for everything. Make sure you're working on a
> worktree. There are other concurrent sessions that may have real merge conflicts to
> handle.
## Pre-state
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
plus a per-altitude soundtrack with a 010 audio dial and a built-in
`#audio-status` diagnostic.
- s0027 had brought all 41 clips to left+right annotation with 4-lang parity, but the
left labels were thin (many clips 12) and STATIC; affect was authored PER-SCALE
(every clip in an altitude shared 4 feelings).
- Operator was running a stale `:8000` server on the `design/cloudflare-static-publish`
branch (a concurrent session's WIP — "Run simulation" begin-experience flow, +
later a credits page). Project is EXEMPT from the Wiggleverse deploy pipeline
(flotilla/PPE/§9); public target is static Cloudflare. cv2/imageio NOT installed in
this env.
## Session arc
1. **Worktree.** Created native worktree `fix+audio-and-tracking-labels` off
`origin/main` (582183d). All work done here.
2. **Audio (systematic-debugging) — FALSE ALARM.** Could not reproduce "no audio" in
ANY automated condition: Chromium + WebKit, strict autoplay
(`--autoplay-policy=user-gesture-required`), clean-main server AND the operator's
`:8000` WIP server, via Video-toggle / Audio-dial / "Run simulation" button; all 5
soundtracks decode to real non-silent audio (RMS 0.080.24). The raw
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
the element reported playing → isolated it BELOW the app. Operator confirmed:
**their Safari sound was muted.** No code change.
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
(design branch), whose manifest had that clip's `annotations: []`. My branch
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
merge`. (commit `8cfceff`)
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
a11y on top (`380c2d1`).
## Cut state (final)
- All work committed, merged to `origin/main`, pushed. Working tree clean.
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
## Deferred decisions (low-confidence calls)
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
repro rather than guess-patch a working engine; operator confirmed muted Safari.
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
clips, except bluemarble) — factual text is grounded, positions are estimates that
want by-eye review.
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
explicit request; gh is GitHub-only here and no `tea` CLI.
## Operator plate
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
creature. For Feel: check emotions ring true once clips are in motion.
## Next /goal
```
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
```
@@ -0,0 +1,102 @@
# Session 0035.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-45 (PST)
> End: 2026-06-30T10-08 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
Not a `/goal` launch. Opened conversationally: "We need to make the entire
simulator accessible, assuming it's not being used at a kiosk. What are the
different types of accessibility and your suggestions (and difficulty)?" Then:
build the first pass on a worktree, and add an `about.html` about the project's
intent (accessible to all humans; vantage points from underwater→drones→space
exploration; built with LLMs; honest that it's imperfect but more than the author
alone could make).
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
transcript ID was claimed at finalize (this file).
## Pre-state
- Simulator built for an attended **kiosk**; the Cloudflare static publish
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
piece that is continuous motion + a "trippy at max" WebGL shader.
## Arc (turn-by-turn)
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
motion/seizure layer.
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
design spec.
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
that, keep separate until ready for main." Created
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
4. **Plan** — 10 bite-sized TDD tasks, committed.
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
shared `boot()` helper.
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
had advanced 29 commits (static-publish already landed there) → divergent merge;
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
Removed the worktree; stopped bg servers.
## Cut state
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
- App has **no content repo** → plan stays in-repo at
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
operator ("leave anything that isn't yours alone"). Other worktree
`fix+audio-and-tracking-labels` also left alone.
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
## Deferred decisions
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
- **Branched off the design branch, not main** — corrected after operator input.
- **Merged the full divergent history to main** (static-publish was already on
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
## Next /goal
```
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
```
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
@@ -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 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).
+15
View File
@@ -97,5 +97,20 @@
},
"0033": {
"title": ""
},
"0034": {
"title": ""
},
"0035": {
"title": ""
},
"0036": {
"title": ""
},
"0037": {
"title": ""
},
"0038": {
"title": ""
}
}
+45 -10
View File
@@ -41,23 +41,58 @@ test.describe("Task 4 — Photosensitivity warning gate", () => {
});
});
test.describe("Task 5 — Reduced motion", () => {
test("reduce-motion toggle pauses video playback", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
await page.goto("/");
test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () => {
// Helper: start the experience so #vid-loop is playing.
async function startExperience(page: import("@playwright/test").Page) {
await page.locator("#motion-warning-continue").click().catch(() => {});
const rm = page.locator("#reduce-motion");
await expect(rm).not.toBeChecked(); // default-off under no-preference
await page.locator("#run-sim").click();
await page.waitForTimeout(500);
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(false); // experience running → video plays
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
await expect(rm).toBeChecked();
.toBe(false); // experience running → loop plays
}
test("the Reduce motion toggle is gone, replaced by a 04x speed slider", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await expect(page.locator("#reduce-motion")).toHaveCount(0);
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", "4");
});
test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await startExperience(page);
await page.locator("#play-speed").fill("4");
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
.toBe(4);
});
test("0x freezes the loop video", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await startExperience(page);
await page.locator("#play-speed").fill("0");
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(true); // reduced motion → frozen
.toBe(true);
});
test("OS reduced-motion disables the slider and freezes the loop", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
await page.locator("#run-sim").click();
await page.waitForTimeout(500);
await expect(page.locator("#play-speed")).toBeDisabled();
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(true); // OS prefers-reduced-motion → frozen regardless of slider
});
});
+29 -1
View File
@@ -276,7 +276,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 });
@@ -293,3 +294,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
});
+66 -25
View File
@@ -313,7 +313,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/direction (forward, freeze, or reverse)
}
function ensureClipMedia() {
@@ -1076,7 +1076,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.
@@ -1116,39 +1120,76 @@ function onDialKey(e) {
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
const WARN_KEY = "hef.motionWarnDismissed";
const RM_KEY = "hef.reduceMotion";
const SPEED_KEY = "hef.playSpeed";
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.
let reduceMotion = false;
function isReduced() { return reduceMotion; }
function initReduceMotion() {
const box = $("reduce-motion");
// --- Playback speed + reduced motion -----------------------------------------
// "Reduce motion" is no longer a visible toggle — its accessibility behavior is
// derived LIVE from the OS `prefers-reduced-motion` setting (no persisted
// override). The visible control is the Playback Speed slider (0x4x forward),
// which 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 playback was dropped: base
// loop clips aren't all-intra, so native/manual reverse seeking jitters.) When
// the OS asks for reduced motion the loop stays frozen and the slider is
// disabled, so a vestibular/photosensitive visitor never gets motion they did
// not ask for.
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
let playSpeed = 1;
const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null;
function isReduced() { return !!(reduceMq && reduceMq.matches); }
// Apply the chosen slider speed to the resting loop video. When reduced motion
// holds or video output is off, the freeze/play decision belongs to
// syncReducedMotion / playLoop — here we only keep the forward rate sane for
// when it resumes.
function applyPlaySpeed() {
if (isReduced() || !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
}
}
function updateSpeedLabel() {
const el = $("play-speed-val");
if (el) el.textContent = playSpeed.toFixed(2) + "×";
}
function initPlaySpeed() {
const slider = $("play-speed");
let stored = null;
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
reduceMotion = stored === null ? !!prefers : stored === "1";
if (box) {
box.checked = reduceMotion;
box.addEventListener("change", () => {
reduceMotion = box.checked;
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
applyReduceMotion();
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
if (slider) {
slider.value = String(playSpeed);
slider.addEventListener("input", () => {
playSpeed = parseFloat(slider.value) || 0;
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
updateSpeedLabel();
applyPlaySpeed();
});
}
applyReduceMotion();
updateSpeedLabel();
syncReducedMotion(); // initial enable/disable + freeze/play state
if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener("change", syncReducedMotion);
}
function applyReduceMotion() {
if (reduceMotion) {
// Reconcile to the OS prefers-reduced-motion setting: when reduced, freeze both
// videos and disable the speed slider; otherwise (re)apply the chosen speed.
function syncReducedMotion() {
const slider = $("play-speed");
const reduced = isReduced();
if (slider) slider.disabled = reduced;
if (reduced) {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
} else if (videoEverOn && $("visual").checked) {
playLoop(); // resume the held loop on opt-out
applyPlaySpeed(); // resume the held loop at the chosen speed
}
}
@@ -1519,7 +1560,7 @@ 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 the language dropdown + wire live switching
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
initPlaySpeed(); // playback-speed slider + OS-derived reduced motion
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
+2 -2
View File
@@ -31,9 +31,9 @@
"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: "開発モード" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", ja: "再生速度" },
"warn.title": { en: "Heads up — motion & flashing" },
"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” in the panel before you begin." },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on your devices “Reduce motion” setting before you begin — the experience honours it automatically." },
"warn.continue": { en: "Continue" },
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
+9 -4
View File
@@ -54,11 +54,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="dev-switch" for="reduce-motion">
<input type="checkbox" id="reduce-motion" />
<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 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"
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>
</datalist>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
+4
View File
@@ -164,6 +164,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 { 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. */