feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales

Add scale-ring navigation to the simulator per the scales-library design §3:
an endless rotary-encoder control (relative, vs the absolute 0-4 experience
knobs) walks a closed ring of neutral "scales of nature" clips, with placeholder
AI zoom/warp transitions between adjacent scales and a small->large wrap (the
infinite-zoom payoff).

- player/ring.py (new, canonical pure logic): ScaleRing + advance_ring; one
  transition clip per edge, played forward zooming inward / reversed outward;
  modulo wrap both ways; chained steps for a multi-detent spin. Mirrors
  player/content.py conventions (frozen dataclasses, pure functions).
- simulator: load_ring + ring_to_dict/ring_move_to_dict; GET /api/ring and
  POST /api/ring/advance (Python owns step/wrap/transition math; the browser
  only plays the returned transition(s) then settles on the target scale).
- frontend: endless-encoder control (zoom in/out buttons + stage scroll),
  current-scale readout, multi-clip active-scale selection, transition playback.
- media: two cheap true-PD scales so the ring is demonstrable -- cosmos
  (NASA/Hubble) + abyss (NOAA Ocean Exploration), provenance recorded in the
  manifest, bytes generated as labelled placeholders by setup_scales_media.py.
  The expensive multi-strength flow-stabilized Right re-bake is DEFERRED (new
  scales carry a raw base only) until the ring is liked; Pi renderer +
  serial/firmware remain deferred (simulator-first).
- docs: ROADMAP slice-3 done; USER_GUIDE scale-ring gesture; plan doc.

Verified: pytest 215 passed / 2 skipped (+22 new); sim boots, /api/ring +
/api/ring/advance correct incl. wrap, media served, and a headless-Chrome CDP
drive walked cosmos -> forest -> abyss -> (wrap) -> cosmos with the active clip
reloading correctly.

Spec: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md (§3)
Plan: docs/superpowers/plans/2026-06-07-scale-ring-navigation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 23:40:21 -07:00
parent 6cf1ae08ff
commit 7a50ae41bb
16 changed files with 951 additions and 25 deletions
+77 -1
View File
@@ -2,7 +2,14 @@ import json
import pytest
from simulator.clips import Clip, load_manifest
from player.ring import RingMove, ScaleRing, TransitionStep, advance_ring
from simulator.clips import (
Clip,
load_manifest,
load_ring,
ring_move_to_dict,
ring_to_dict,
)
def _manifest_dict():
@@ -67,3 +74,72 @@ def test_clip_serializes_to_dict_for_the_api(tmp_path):
def test_missing_manifest_raises(tmp_path):
with pytest.raises(FileNotFoundError):
load_manifest(tmp_path / "nope.json")
def _ring_manifest_dict():
return {
"clips": [],
"ring": {
"scales": [
{"id": "cosmos", "clip_id": "cosmos"},
{"id": "forest", "clip_id": "forest"},
{"id": "abyss", "clip_id": "abyss"},
],
"transitions": [
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
],
},
}
def test_load_ring_builds_a_scale_ring(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_ring_manifest_dict()))
ring = load_ring(p)
assert isinstance(ring, ScaleRing)
assert len(ring) == 3
assert ring.scales[0].id == "cosmos"
assert ring.scales[0].clip_id == "cosmos"
assert ring.transitions[2].file == "transitions/abyss-cosmos.mp4"
assert ring.transitions[0].model == "placeholder"
def test_load_ring_is_none_when_absent(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_manifest_dict())) # no "ring" key
assert load_ring(p) is None
def test_ring_to_dict_carries_scale_titles_from_clips(tmp_path):
p = tmp_path / "manifest.json"
md = _ring_manifest_dict()
md["clips"] = [
{"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4"},
{"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4"},
{"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4"},
]
p.write_text(json.dumps(md))
ring = load_ring(p)
clips = load_manifest(p)
d = ring_to_dict(ring, clips)
assert [s["id"] for s in d["scales"]] == ["cosmos", "forest", "abyss"]
assert d["scales"][0]["title"] == "NASA cosmos"
assert d["scales"][0]["clip_id"] == "cosmos"
assert d["transitions"][2]["file"] == "transitions/abyss-cosmos.mp4"
def test_ring_move_to_dict_serializes_steps(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_ring_manifest_dict()))
ring = load_ring(p)
move = advance_ring(ring, from_index=2, delta=1) # abyss -> wrap -> cosmos
d = ring_move_to_dict(move, ring)
assert d["from_index"] == 2
assert d["to_index"] == 0
assert d["wrapped"] is True
assert d["target_clip_id"] == "cosmos"
assert d["steps"][0]["file"] == "transitions/abyss-cosmos.mp4"
assert d["steps"][0]["reversed"] is False
assert d["steps"][0]["to_index"] == 0