RemiFabre commited on
Commit
b358f52
·
0 Parent(s):

Initial commit

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Simpledances
3
+ emoji: 👋
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Write your description here
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ ---
SimpleDances/__init__.py ADDED
File without changes
SimpleDances/dance_params.json ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dances": {
3
+ "chicken_peck": {
4
+ "amplitude_m": 0.02,
5
+ "antenna_amplitude_rad": 0.5235987755982988,
6
+ "antenna_move_name": "both",
7
+ "subcycles_per_beat": 1.0
8
+ },
9
+ "chin_lead": {
10
+ "antenna_amplitude_rad": 0.7853981633974483,
11
+ "antenna_move_name": "wiggle",
12
+ "pitch_amplitude_rad": 0.2617993877991494,
13
+ "subcycles_per_beat": 0.5,
14
+ "x_amplitude_m": 0.02
15
+ },
16
+ "dizzy_spin": {
17
+ "antenna_amplitude_rad": 0.7853981633974483,
18
+ "antenna_move_name": "wiggle",
19
+ "pitch_amplitude_rad": 0.2617993877991494,
20
+ "roll_amplitude_rad": 0.2617993877991494,
21
+ "subcycles_per_beat": 0.25
22
+ },
23
+ "grid_snap": {
24
+ "amplitude_rad": 0.3490658503988659,
25
+ "antenna_amplitude_rad": 0.17453292519943295,
26
+ "antenna_move_name": "both",
27
+ "subcycles_per_beat": 0.25
28
+ },
29
+ "groovy_sway_and_roll": {
30
+ "antenna_amplitude_rad": 1.8413223608540177,
31
+ "antenna_move_name": "both",
32
+ "roll_amplitude_rad": 0.4014257279586958,
33
+ "subcycles_per_beat": 1.0,
34
+ "y_amplitude_m": 0.035
35
+ },
36
+ "head_tilt_roll": {
37
+ "amplitude_rad": 0.2617993877991494,
38
+ "antenna_amplitude_rad": 0.7853981633974483,
39
+ "antenna_move_name": "wiggle",
40
+ "subcycles_per_beat": 0.5
41
+ },
42
+ "headbanger_combo": {
43
+ "antenna_amplitude_rad": 0.6981317007977318,
44
+ "antenna_move_name": "both",
45
+ "pitch_amplitude_rad": 0.5235987755982988,
46
+ "subcycles_per_beat": 1.0,
47
+ "waveform": "sin",
48
+ "z_amplitude_m": 0.015
49
+ },
50
+ "interwoven_spirals": {
51
+ "antenna_amplitude_rad": 0.7853981633974483,
52
+ "antenna_move_name": "wiggle",
53
+ "pitch_amp": 0.3490658503988659,
54
+ "roll_amp": 0.2617993877991494,
55
+ "subcycles_per_beat": 0.125,
56
+ "yaw_amp": 0.4363323129985824
57
+ },
58
+ "jackson_square": {
59
+ "antenna_amplitude_rad": 0.7853981633974483,
60
+ "antenna_move_name": "wiggle",
61
+ "subcycles_per_beat": 0.2,
62
+ "twitch_amplitude_rad": 0.3490658503988659,
63
+ "y_amp_m": 0.035,
64
+ "z_amp_m": 0.025,
65
+ "z_offset_m": -0.01
66
+ },
67
+ "neck_recoil": {
68
+ "amplitude_m": 0.015,
69
+ "antenna_amplitude_rad": 0.7853981633974483,
70
+ "antenna_move_name": "wiggle",
71
+ "subcycles_per_beat": 0.5
72
+ },
73
+ "pendulum_swing": {
74
+ "amplitude_rad": 0.4363323129985824,
75
+ "antenna_amplitude_rad": 0.7853981633974483,
76
+ "antenna_move_name": "wiggle",
77
+ "subcycles_per_beat": 0.25
78
+ },
79
+ "polyrhythm_combo": {
80
+ "antenna_amplitude_rad": 0.7853981633974483,
81
+ "antenna_move_name": "wiggle",
82
+ "nod_amplitude_rad": 0.17453292519943295,
83
+ "sway_amplitude_m": 0.02
84
+ },
85
+ "sharp_side_tilt": {
86
+ "antenna_amplitude_rad": 0.7853981633974483,
87
+ "antenna_move_name": "wiggle",
88
+ "roll_amplitude_rad": 0.3839724354387525,
89
+ "subcycles_per_beat": 1.0,
90
+ "waveform": "triangle"
91
+ },
92
+ "side_glance_flick": {
93
+ "antenna_amplitude_rad": 0.7853981633974483,
94
+ "antenna_move_name": "wiggle",
95
+ "subcycles_per_beat": 0.25,
96
+ "yaw_amplitude_rad": 0.7853981633974483
97
+ },
98
+ "side_peekaboo": {
99
+ "antenna_amplitude_rad": 1.0471975511965976,
100
+ "antenna_move_name": "both",
101
+ "pitch_amp": 0.3490658503988659,
102
+ "subcycles_per_beat": 0.5,
103
+ "y_amp": 0.03,
104
+ "z_amp": 0.04
105
+ },
106
+ "side_to_side_sway": {
107
+ "amplitude_m": 0.04,
108
+ "antenna_amplitude_rad": 0.7853981633974483,
109
+ "antenna_move_name": "wiggle",
110
+ "subcycles_per_beat": 0.5
111
+ },
112
+ "simple_nod": {
113
+ "amplitude_rad": 0.3490658503988659,
114
+ "antenna_amplitude_rad": 0.7853981633974483,
115
+ "antenna_move_name": "wiggle",
116
+ "subcycles_per_beat": 1.0
117
+ },
118
+ "stumble_and_recover": {
119
+ "antenna_amplitude_rad": 0.8726646259971648,
120
+ "antenna_move_name": "both",
121
+ "pitch_amplitude_rad": 0.17453292519943295,
122
+ "subcycles_per_beat": 0.25,
123
+ "y_amplitude_m": 0.015,
124
+ "yaw_amplitude_rad": 0.4363323129985824
125
+ },
126
+ "uh_huh_tilt": {
127
+ "amplitude_rad": 0.2617993877991494,
128
+ "antenna_amplitude_rad": 0.7853981633974483,
129
+ "antenna_move_name": "wiggle",
130
+ "subcycles_per_beat": 0.5
131
+ },
132
+ "yeah_nod": {
133
+ "amplitude_rad": 0.2617993877991494,
134
+ "antenna_amplitude_rad": 0.3490658503988659,
135
+ "antenna_move_name": "both",
136
+ "subcycles_per_beat": 1.0
137
+ }
138
+ },
139
+ "globals": {
140
+ "amplitude_scale": 0.55,
141
+ "bpm": 52.0
142
+ }
143
+ }
SimpleDances/main.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import math
6
+ import threading
7
+ import time
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+ import numpy as np
13
+ from fastapi import HTTPException
14
+ from pydantic import BaseModel, Field
15
+
16
+ from reachy_mini import ReachyMini, ReachyMiniApp
17
+ from reachy_mini.utils import create_head_pose
18
+
19
+ from reachy_mini_dances_library.collection.dance import AVAILABLE_MOVES
20
+ from reachy_mini_dances_library.rhythmic_motion import (
21
+ AVAILABLE_ANTENNA_MOVES,
22
+ MoveOffsets,
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Constants & dance catalog
27
+ # ---------------------------------------------------------------------------
28
+ APP_DIR = Path(__file__).parent
29
+ PRESET_FILE = APP_DIR / "dance_params.json"
30
+
31
+ WAVEFORMS = ["sin", "cos", "square", "triangle", "sawtooth"]
32
+ BPM_RANGE = (40.0, 180.0)
33
+ AMP_SCALE_RANGE = (0.0, 2.0)
34
+ DEFAULT_GLOBALS = {"bpm": 110.0, "amplitude_scale": 1.0}
35
+ RAD_NAME_OVERRIDES = {"pitch_amp", "roll_amp", "yaw_amp"}
36
+ METER_NAME_OVERRIDES = {"z_amp", "y_amp"}
37
+
38
+
39
+ class ParamSpec(BaseModel):
40
+ name: str
41
+ label: str
42
+ type: str
43
+ value: float | str
44
+ min: float | None = None
45
+ max: float | None = None
46
+ step: float | None = None
47
+ unit: str | None = None
48
+ options: list[str] | None = None
49
+
50
+
51
+ class SelectPayload(BaseModel):
52
+ name: str = Field(..., description="Name of the dance to select")
53
+
54
+
55
+ class ParamUpdatePayload(BaseModel):
56
+ name: str
57
+ params: dict[str, float | str]
58
+
59
+
60
+ class GlobalSettingsPayload(BaseModel):
61
+ bpm: float
62
+ amplitude_scale: float
63
+
64
+
65
+ class TogglePayload(BaseModel):
66
+ playing: bool
67
+
68
+
69
+ class ResetPayload(BaseModel):
70
+ name: str
71
+
72
+
73
+ def _normalize_params(raw: dict[str, Any]) -> dict[str, Any]:
74
+ normalized: dict[str, Any] = {}
75
+ for key, value in raw.items():
76
+ if isinstance(value, (int, float)):
77
+ normalized[key] = float(value)
78
+ else:
79
+ normalized[key] = value
80
+ return normalized
81
+
82
+
83
+ DANCE_CATALOG: dict[
84
+ str, dict[str, Any]
85
+ ] = {
86
+ name: {
87
+ "fn": fn,
88
+ "default_params": _normalize_params(copy.deepcopy(params)),
89
+ "metadata": metadata or {},
90
+ "label": name.replace("_", " ").title(),
91
+ }
92
+ for name, (fn, params, metadata) in AVAILABLE_MOVES.items()
93
+ }
94
+
95
+ DANCE_ORDER = sorted(DANCE_CATALOG.keys())
96
+ DEFAULT_DANCE = "simple_nod" if "simple_nod" in DANCE_CATALOG else DANCE_ORDER[0]
97
+ ANTENNA_CHOICES = sorted(AVAILABLE_ANTENNA_MOVES.keys())
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Helper functions (units, persistence, specs)
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def clamp(value: float, bounds: tuple[float, float]) -> float:
105
+ return max(bounds[0], min(bounds[1], value))
106
+
107
+
108
+ def param_is_degree(name: str) -> bool:
109
+ return "rad" in name or name in RAD_NAME_OVERRIDES
110
+
111
+
112
+ def param_is_meter(name: str) -> bool:
113
+ return name.endswith("_m") or name in METER_NAME_OVERRIDES or name.endswith(
114
+ "_amp_m"
115
+ )
116
+
117
+
118
+ def to_ui_units(name: str, value: float) -> tuple[float, str | None]:
119
+ if param_is_degree(name):
120
+ return math.degrees(value), "deg"
121
+ if param_is_meter(name):
122
+ return value * 1000.0, "mm"
123
+ return value, None
124
+
125
+
126
+ def from_ui_units(name: str, value: float) -> float:
127
+ if param_is_degree(name):
128
+ return math.radians(value)
129
+ if param_is_meter(name):
130
+ return value / 1000.0
131
+ return value
132
+
133
+
134
+ def range_for_param(name: str, ui_value: float) -> tuple[float, float, float]:
135
+ if name == "subcycles_per_beat":
136
+ return 0.1, 4.0, 0.05
137
+ if "phase_offset" in name:
138
+ return 0.0, 1.0, 0.01
139
+ if param_is_degree(name):
140
+ span = min(max(abs(ui_value) * 3.0, 30.0), 180.0)
141
+ return -span, span, 0.5
142
+ if param_is_meter(name):
143
+ span = min(max(abs(ui_value) * 3.0, 30.0), 150.0)
144
+ return -span, span, 1.0
145
+ base = max(abs(ui_value), 1.0)
146
+ span = max(base * 4.0, 2.0)
147
+ step = max(span / 100.0, 0.01)
148
+ return -span, span, step
149
+
150
+
151
+ def build_param_spec(name: str, value: Any) -> ParamSpec:
152
+ label = name.replace("_", " ").title()
153
+
154
+ if isinstance(value, str):
155
+ options = WAVEFORMS if name == "waveform" else ANTENNA_CHOICES
156
+ if value not in options:
157
+ value = options[0]
158
+ return ParamSpec(
159
+ name=name,
160
+ label=label,
161
+ type="select",
162
+ value=value,
163
+ options=options,
164
+ )
165
+
166
+ if not isinstance(value, (int, float)):
167
+ raise ValueError(f"Unsupported parameter type for {name}: {type(value)}")
168
+
169
+ ui_value, unit = to_ui_units(name, float(value))
170
+ min_val, max_val, step = range_for_param(name, ui_value)
171
+ return ParamSpec(
172
+ name=name,
173
+ label=label,
174
+ type="number",
175
+ value=ui_value,
176
+ min=min_val,
177
+ max=max_val,
178
+ step=step,
179
+ unit=unit,
180
+ )
181
+
182
+
183
+ def load_persisted() -> dict[str, Any]:
184
+ if not PRESET_FILE.exists():
185
+ return {"globals": DEFAULT_GLOBALS.copy(), "dances": {}}
186
+ try:
187
+ loaded = json.loads(PRESET_FILE.read_text())
188
+ except Exception:
189
+ return {"globals": DEFAULT_GLOBALS.copy(), "dances": {}}
190
+
191
+ globals_section = loaded.get("globals") or {}
192
+ dances_section = loaded.get("dances") or {}
193
+
194
+ return {
195
+ "globals": {
196
+ "bpm": float(globals_section.get("bpm", DEFAULT_GLOBALS["bpm"])),
197
+ "amplitude_scale": float(
198
+ globals_section.get("amplitude_scale", DEFAULT_GLOBALS["amplitude_scale"])
199
+ ),
200
+ },
201
+ "dances": {
202
+ name: _normalize_params(params)
203
+ for name, params in dances_section.items()
204
+ if isinstance(params, dict)
205
+ },
206
+ }
207
+
208
+
209
+ def serialize_state(snapshot: dict[str, Any]) -> None:
210
+ payload = {
211
+ "globals": {
212
+ "bpm": snapshot["bpm"],
213
+ "amplitude_scale": snapshot["amplitude_scale"],
214
+ },
215
+ "dances": snapshot["dance_params"],
216
+ }
217
+ PRESET_FILE.write_text(json.dumps(payload, indent=2, sort_keys=True))
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Reachy Mini App
222
+ # ---------------------------------------------------------------------------
223
+ class Simpledances(ReachyMiniApp):
224
+ custom_app_url: str | None = "http://0.0.0.0:8042"
225
+ request_media_backend: str | None = None
226
+
227
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
228
+ state_lock = threading.Lock()
229
+ persisted = load_persisted()
230
+
231
+ dance_params = {
232
+ name: {
233
+ **copy.deepcopy(DANCE_CATALOG[name]["default_params"]),
234
+ **persisted["dances"].get(name, {}),
235
+ }
236
+ for name in DANCE_ORDER
237
+ }
238
+
239
+ runtime_state: dict[str, Any] = {
240
+ "selected": DEFAULT_DANCE,
241
+ "playing": False,
242
+ "bpm": clamp(persisted["globals"].get("bpm", DEFAULT_GLOBALS["bpm"]), BPM_RANGE),
243
+ "amplitude_scale": clamp(
244
+ persisted["globals"].get("amplitude_scale", DEFAULT_GLOBALS["amplitude_scale"]),
245
+ AMP_SCALE_RANGE,
246
+ ),
247
+ "dance_params": dance_params,
248
+ "last_offsets": {
249
+ "position": [0.0, 0.0, 0.0],
250
+ "orientation": [0.0, 0.0, 0.0],
251
+ "antennas": [0.0, 0.0],
252
+ },
253
+ "last_saved": None,
254
+ "phase_start": time.perf_counter(),
255
+ }
256
+
257
+ def persist_locked() -> None:
258
+ serialize_state(runtime_state)
259
+ runtime_state["last_saved"] = datetime.utcnow().isoformat() + "Z"
260
+
261
+ def get_param_specs(dance_name: str) -> list[ParamSpec]:
262
+ return [
263
+ build_param_spec(param, value)
264
+ for param, value in runtime_state["dance_params"][dance_name].items()
265
+ ]
266
+
267
+ # -------------------- API Endpoints --------------------
268
+ @self.settings_app.get("/api/dances")
269
+ async def list_dances():
270
+ return {
271
+ "dances": [
272
+ {
273
+ "name": name,
274
+ "label": DANCE_CATALOG[name]["label"],
275
+ "description": DANCE_CATALOG[name]["metadata"].get(
276
+ "description", ""
277
+ ),
278
+ "default_duration_beats": DANCE_CATALOG[name]["metadata"].get(
279
+ "default_duration_beats", 4
280
+ ),
281
+ }
282
+ for name in DANCE_ORDER
283
+ ]
284
+ }
285
+
286
+ @self.settings_app.get("/api/state")
287
+ async def get_state():
288
+ with state_lock:
289
+ selected = runtime_state["selected"]
290
+ meta = DANCE_CATALOG[selected]["metadata"]
291
+ return {
292
+ "selected": selected,
293
+ "playing": runtime_state["playing"],
294
+ "bpm": runtime_state["bpm"],
295
+ "amplitude_scale": runtime_state["amplitude_scale"],
296
+ "param_specs": [spec.model_dump() for spec in get_param_specs(selected)],
297
+ "description": meta.get("description", ""),
298
+ "last_saved": runtime_state["last_saved"],
299
+ }
300
+
301
+ @self.settings_app.post("/api/select")
302
+ async def select_dance(payload: SelectPayload):
303
+ if payload.name not in runtime_state["dance_params"]:
304
+ raise HTTPException(status_code=404, detail="Unknown dance")
305
+ with state_lock:
306
+ runtime_state["selected"] = payload.name
307
+ runtime_state["phase_start"] = time.perf_counter()
308
+ specs = [spec.model_dump() for spec in get_param_specs(payload.name)]
309
+ meta = DANCE_CATALOG[payload.name]["metadata"]
310
+ return {
311
+ "selected": payload.name,
312
+ "param_specs": specs,
313
+ "description": meta.get("description", ""),
314
+ }
315
+
316
+ @self.settings_app.post("/api/params")
317
+ async def update_params(payload: ParamUpdatePayload):
318
+ dance_params = runtime_state["dance_params"].get(payload.name)
319
+ if dance_params is None:
320
+ raise HTTPException(status_code=404, detail="Unknown dance")
321
+
322
+ changed = False
323
+ with state_lock:
324
+ for key, ui_value in payload.params.items():
325
+ if key not in dance_params:
326
+ continue
327
+ current = dance_params[key]
328
+ if isinstance(current, str):
329
+ if not isinstance(ui_value, str):
330
+ continue
331
+ if key == "waveform" and ui_value not in WAVEFORMS:
332
+ continue
333
+ if key == "antenna_move_name" and ui_value not in ANTENNA_CHOICES:
334
+ continue
335
+ dance_params[key] = ui_value
336
+ changed = True
337
+ else:
338
+ try:
339
+ numeric = from_ui_units(key, float(ui_value))
340
+ except (TypeError, ValueError):
341
+ continue
342
+ if math.isclose(numeric, current, rel_tol=1e-4, abs_tol=1e-4):
343
+ continue
344
+ dance_params[key] = numeric
345
+ changed = True
346
+ if changed:
347
+ persist_locked()
348
+ specs = [spec.model_dump() for spec in get_param_specs(payload.name)]
349
+ return {"param_specs": specs, "last_saved": runtime_state["last_saved"]}
350
+
351
+ @self.settings_app.post("/api/globals")
352
+ async def update_globals(payload: GlobalSettingsPayload):
353
+ with state_lock:
354
+ runtime_state["bpm"] = clamp(float(payload.bpm), BPM_RANGE)
355
+ runtime_state["amplitude_scale"] = clamp(
356
+ float(payload.amplitude_scale), AMP_SCALE_RANGE
357
+ )
358
+ persist_locked()
359
+ return {
360
+ "bpm": runtime_state["bpm"],
361
+ "amplitude_scale": runtime_state["amplitude_scale"],
362
+ "last_saved": runtime_state["last_saved"],
363
+ }
364
+
365
+ @self.settings_app.post("/api/toggle")
366
+ async def toggle_playback(payload: TogglePayload):
367
+ with state_lock:
368
+ runtime_state["playing"] = bool(payload.playing)
369
+ runtime_state["phase_start"] = time.perf_counter()
370
+ return {"playing": runtime_state["playing"]}
371
+
372
+ @self.settings_app.post("/api/reset")
373
+ async def reset_dance(payload: ResetPayload):
374
+ if payload.name not in runtime_state["dance_params"]:
375
+ raise HTTPException(status_code=404, detail="Unknown dance")
376
+ with state_lock:
377
+ runtime_state["dance_params"][payload.name] = copy.deepcopy(
378
+ DANCE_CATALOG[payload.name]["default_params"]
379
+ )
380
+ persist_locked()
381
+ specs = [spec.model_dump() for spec in get_param_specs(payload.name)]
382
+ return {
383
+ "param_specs": specs,
384
+ "description": DANCE_CATALOG[payload.name]["metadata"].get(
385
+ "description", ""
386
+ ),
387
+ "last_saved": runtime_state["last_saved"],
388
+ }
389
+
390
+ @self.settings_app.get("/api/visualization")
391
+ async def visualization_snapshot():
392
+ with state_lock:
393
+ return {
394
+ "offsets": runtime_state["last_offsets"],
395
+ "playing": runtime_state["playing"],
396
+ "bpm": runtime_state["bpm"],
397
+ }
398
+
399
+ neutral_pose = create_head_pose()
400
+ neutral_antennas = np.zeros(2, dtype=float)
401
+ reachy_mini.set_target(head=neutral_pose, antennas=neutral_antennas)
402
+
403
+ loop_dt = 0.02
404
+
405
+ while not stop_event.is_set():
406
+ with state_lock:
407
+ selected = runtime_state["selected"]
408
+ playing = runtime_state["playing"]
409
+ bpm = runtime_state["bpm"]
410
+ amp_scale = runtime_state["amplitude_scale"]
411
+ params = copy.deepcopy(runtime_state["dance_params"][selected])
412
+ phase_start = runtime_state["phase_start"]
413
+
414
+ head_pose = neutral_pose
415
+ antennas_cmd = neutral_antennas
416
+ offsets_payload = {
417
+ "position": [0.0, 0.0, 0.0],
418
+ "orientation": [0.0, 0.0, 0.0],
419
+ "antennas": [0.0, 0.0],
420
+ }
421
+
422
+ if playing:
423
+ fn: Callable[..., MoveOffsets] | None = DANCE_CATALOG[selected]["fn"]
424
+ t_now = time.perf_counter()
425
+ elapsed = max(t_now - phase_start, 0.0)
426
+ beats = elapsed * (bpm / 60.0)
427
+ try:
428
+ offsets = fn(beats, **params)
429
+ except Exception as exc: # pragma: no cover - safety net
430
+ print(f"[SimpleDances] Move error ({selected}): {exc}")
431
+ pos = np.zeros(3)
432
+ ori = np.zeros(3)
433
+ antennas = np.zeros(2)
434
+ else:
435
+ pos = np.asarray(offsets.position_offset, dtype=float)
436
+ ori = np.asarray(offsets.orientation_offset, dtype=float)
437
+ antennas = np.asarray(offsets.antennas_offset, dtype=float)
438
+
439
+ pos *= amp_scale
440
+ ori *= amp_scale
441
+ antennas *= amp_scale
442
+
443
+ head_pose = create_head_pose(
444
+ x=float(pos[0]),
445
+ y=float(pos[1]),
446
+ z=float(pos[2]),
447
+ roll=float(ori[0]),
448
+ pitch=float(ori[1]),
449
+ yaw=float(ori[2]),
450
+ degrees=False,
451
+ mm=False,
452
+ )
453
+ antennas_cmd = antennas
454
+
455
+ offsets_payload = {
456
+ "position": pos.tolist(),
457
+ "orientation": ori.tolist(),
458
+ "antennas": antennas.tolist(),
459
+ }
460
+
461
+ reachy_mini.set_target(head=head_pose, antennas=antennas_cmd)
462
+
463
+ with state_lock:
464
+ runtime_state["last_offsets"] = offsets_payload
465
+
466
+ time.sleep(loop_dt)
467
+
468
+
469
+ if __name__ == "__main__":
470
+ app = Simpledances()
471
+ try:
472
+ app.wrapped_run()
473
+ except KeyboardInterrupt:
474
+ app.stop()
SimpleDances/static/index.html ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Reachy Mini • Simple Dances</title>
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ </head>
10
+
11
+ <body>
12
+ <div class="background-glow"></div>
13
+ <main class="app-shell">
14
+ <header class="hero">
15
+ <div>
16
+ <p class="eyebrow">Reachy Mini Playground</p>
17
+ <h1>Simple Dances Studio</h1>
18
+ <p class="subtitle">Pick a groove, fine-tune every motion axis, and let Reachy Mini vibe to your beat.</p>
19
+ </div>
20
+ <div class="hero-meta">
21
+ <span class="chip" id="chipPlaying">Idle</span>
22
+ <span class="chip" id="chipSaved">Not saved</span>
23
+ </div>
24
+ </header>
25
+
26
+ <section class="panel-grid">
27
+ <article class="card selection-card">
28
+ <div class="card-header">
29
+ <div>
30
+ <h2>Dance Library</h2>
31
+ <p>Browse every move from the Reachy Mini Dances Library.</p>
32
+ </div>
33
+ <button class="ghost" id="resetDance">Reset dance</button>
34
+ </div>
35
+ <div class="field">
36
+ <label for="danceSelect">Choose a dance</label>
37
+ <div class="select-wrapper">
38
+ <select id="danceSelect"></select>
39
+ <span class="chevron">⌄</span>
40
+ </div>
41
+ </div>
42
+ <p class="description" id="danceDescription">Loading dances…</p>
43
+ </article>
44
+
45
+ <article class="card global-card">
46
+ <h2>Global Groove</h2>
47
+ <div class="field">
48
+ <div class="field-heading">
49
+ <label for="bpmRange">Beats Per Minute</label>
50
+ <span id="bpmDisplay">0 BPM</span>
51
+ </div>
52
+ <div class="slider-row">
53
+ <input type="range" id="bpmRange" min="40" max="180" step="1">
54
+ <input type="number" id="bpmInput" min="40" max="180" step="1">
55
+ </div>
56
+ </div>
57
+ <div class="field">
58
+ <div class="field-heading">
59
+ <label for="ampRange">Amplitude Scale</label>
60
+ <span id="ampDisplay">0.0×</span>
61
+ </div>
62
+ <div class="slider-row">
63
+ <input type="range" id="ampRange" min="0" max="2" step="0.05">
64
+ <input type="number" id="ampInput" min="0" max="2" step="0.05">
65
+ </div>
66
+ </div>
67
+ </article>
68
+
69
+ <article class="card action-card">
70
+ <div class="card-header">
71
+ <h2>Playback</h2>
72
+ <p>Start or pause the routine at any time.</p>
73
+ </div>
74
+ <button id="togglePlayback" class="primary">Start Dancing</button>
75
+ <div class="status-line" id="statusLine">Syncing…</div>
76
+ </article>
77
+ </section>
78
+
79
+ <section class="card viz-card">
80
+ <div class="card-header">
81
+ <div>
82
+ <h2>Kinesthetic Visualizer</h2>
83
+ <p>Every DoF feeds the glow. Watch orientation, translation, and antennas pulse to the beat.</p>
84
+ </div>
85
+ <span class="chip" id="vizBpm">0 BPM</span>
86
+ </div>
87
+ <div class="viz-grid" id="vizGrid"></div>
88
+ </section>
89
+
90
+ <section class="card params-card">
91
+ <div class="card-header">
92
+ <div>
93
+ <h2>Dance Parameters</h2>
94
+ <p>Fine-tune anything: waveforms, amplitudes, twitches, antenna flair.</p>
95
+ </div>
96
+ </div>
97
+ <div id="paramsContainer" class="params-grid">
98
+ <div class="empty">Select a dance to load its parameters.</div>
99
+ </div>
100
+ </section>
101
+ </main>
102
+
103
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
104
+ <script src="/static/main.js" type="module"></script>
105
+ </body>
106
+
107
+ </html>
SimpleDances/static/main.js ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const danceSelect = document.getElementById('danceSelect');
2
+ const resetDanceBtn = document.getElementById('resetDance');
3
+ const descriptionEl = document.getElementById('danceDescription');
4
+ const paramsContainer = document.getElementById('paramsContainer');
5
+ const toggleBtn = document.getElementById('togglePlayback');
6
+ const statusLine = document.getElementById('statusLine');
7
+ const chipPlaying = document.getElementById('chipPlaying');
8
+ const chipSaved = document.getElementById('chipSaved');
9
+ const bpmRange = document.getElementById('bpmRange');
10
+ const bpmInput = document.getElementById('bpmInput');
11
+ const bpmDisplay = document.getElementById('bpmDisplay');
12
+ const ampRange = document.getElementById('ampRange');
13
+ const ampInput = document.getElementById('ampInput');
14
+ const ampDisplay = document.getElementById('ampDisplay');
15
+ const toast = document.getElementById('toast');
16
+ const vizGrid = document.getElementById('vizGrid');
17
+ const vizBpm = document.getElementById('vizBpm');
18
+
19
+ const currentUrl = new URL(window.location.href);
20
+ if (!currentUrl.pathname.endsWith('/')) {
21
+ currentUrl.pathname += '/';
22
+ }
23
+ currentUrl.search = '';
24
+ currentUrl.hash = '';
25
+ const buildApiUrl = (path) => {
26
+ const clean = path.startsWith('/') ? path.slice(1) : path;
27
+ return new URL(clean, currentUrl).toString();
28
+ };
29
+
30
+ const state = {
31
+ dances: [],
32
+ selected: null,
33
+ playing: false,
34
+ bpm: 110,
35
+ amplitudeScale: 1,
36
+ paramSpecs: [],
37
+ description: '',
38
+ lastSaved: null,
39
+ };
40
+
41
+ const paramQueue = new Map();
42
+ const vizNodes = [];
43
+ const vizConfig = [
44
+ { key: 'orientation', index: 0, label: 'Roll', unit: '°', scale: 60 },
45
+ { key: 'orientation', index: 1, label: 'Pitch', unit: '°', scale: 60 },
46
+ { key: 'orientation', index: 2, label: 'Yaw', unit: '°', scale: 60 },
47
+ { key: 'position', index: 0, label: 'X Shift', unit: 'mm', scale: 40 },
48
+ { key: 'position', index: 1, label: 'Y Shift', unit: 'mm', scale: 40 },
49
+ { key: 'position', index: 2, label: 'Z Lift', unit: 'mm', scale: 40 },
50
+ { key: 'antennas', index: 0, label: 'Left Antenna', unit: '°', scale: 90 },
51
+ { key: 'antennas', index: 1, label: 'Right Antenna', unit: '°', scale: 90 },
52
+ ];
53
+
54
+ function debounce(fn, delay) {
55
+ let timer;
56
+ return (...args) => {
57
+ clearTimeout(timer);
58
+ timer = setTimeout(() => fn(...args), delay);
59
+ };
60
+ }
61
+
62
+ async function apiRequest(path, options = {}) {
63
+ const settings = { method: options.method || 'GET', headers: options.headers ? { ...options.headers } : {} };
64
+ if (options.body !== undefined) {
65
+ settings.body = options.body;
66
+ settings.headers['Content-Type'] = settings.headers['Content-Type'] || 'application/json';
67
+ }
68
+ const res = await fetch(buildApiUrl(path), settings);
69
+ if (!res.ok) {
70
+ throw new Error(`HTTP ${res.status}`);
71
+ }
72
+ const text = await res.text();
73
+ return text ? JSON.parse(text) : {};
74
+ }
75
+
76
+ function setStatus(text) {
77
+ statusLine.textContent = text;
78
+ }
79
+
80
+ function showToast(message) {
81
+ toast.textContent = message;
82
+ toast.classList.add('visible');
83
+ setTimeout(() => toast.classList.remove('visible'), 2500);
84
+ }
85
+
86
+ function updateSavedChip(ts) {
87
+ state.lastSaved = ts || null;
88
+ if (!ts) {
89
+ chipSaved.textContent = 'Not saved';
90
+ chipSaved.classList.add('alert');
91
+ return;
92
+ }
93
+ chipSaved.classList.remove('alert');
94
+ const date = new Date(ts);
95
+ const label = Number.isNaN(date.getTime()) ? 'Saved' : `Saved ${date.toLocaleTimeString()}`;
96
+ chipSaved.textContent = label;
97
+ }
98
+
99
+ function updatePlayingChip(isPlaying) {
100
+ chipPlaying.textContent = isPlaying ? 'Playing' : 'Idle';
101
+ chipPlaying.classList.toggle('playing', isPlaying);
102
+ }
103
+
104
+ function updateToggleButton() {
105
+ toggleBtn.textContent = state.playing ? 'Pause Dance' : 'Start Dancing';
106
+ toggleBtn.classList.toggle('is-active', state.playing);
107
+ }
108
+
109
+ function renderDanceOptions() {
110
+ danceSelect.innerHTML = '';
111
+ state.dances.forEach((dance) => {
112
+ const option = document.createElement('option');
113
+ option.value = dance.name;
114
+ option.textContent = `${dance.label} (${dance.default_duration_beats} beats)`;
115
+ danceSelect.appendChild(option);
116
+ });
117
+ if (state.selected) {
118
+ danceSelect.value = state.selected;
119
+ }
120
+ }
121
+
122
+ function renderParams(specs) {
123
+ paramsContainer.innerHTML = '';
124
+ if (!specs || !specs.length) {
125
+ const msg = document.createElement('div');
126
+ msg.className = 'empty';
127
+ msg.textContent = 'No parameters exposed for this dance.';
128
+ paramsContainer.appendChild(msg);
129
+ return;
130
+ }
131
+ specs.forEach((spec) => {
132
+ const card = document.createElement('div');
133
+ card.className = 'param-card';
134
+
135
+ const title = document.createElement('h4');
136
+ title.textContent = spec.label;
137
+ card.appendChild(title);
138
+
139
+ const meta = document.createElement('div');
140
+ meta.className = 'param-meta';
141
+ meta.textContent = spec.unit ? `Unit: ${spec.unit}` : 'Unitless';
142
+ card.appendChild(meta);
143
+
144
+ if (spec.type === 'select') {
145
+ const select = document.createElement('select');
146
+ (spec.options || []).forEach((opt) => {
147
+ const option = document.createElement('option');
148
+ option.value = opt;
149
+ option.textContent = opt;
150
+ select.appendChild(option);
151
+ });
152
+ select.value = spec.value;
153
+ select.addEventListener('change', () => queueParamUpdate(spec.name, select.value));
154
+ const wrapper = document.createElement('div');
155
+ wrapper.className = 'param-inputs';
156
+ wrapper.appendChild(select);
157
+ card.appendChild(wrapper);
158
+ } else {
159
+ const wrapper = document.createElement('div');
160
+ wrapper.className = 'param-inputs';
161
+
162
+ const slider = document.createElement('input');
163
+ slider.type = 'range';
164
+ slider.min = spec.min;
165
+ slider.max = spec.max;
166
+ slider.step = spec.step;
167
+ slider.value = spec.value;
168
+
169
+ const number = document.createElement('input');
170
+ number.type = 'number';
171
+ number.min = spec.min;
172
+ number.max = spec.max;
173
+ number.step = spec.step;
174
+ number.value = Number(spec.value).toFixed(3);
175
+
176
+ const syncValue = (val) => {
177
+ slider.value = val;
178
+ number.value = Number(val).toFixed(3);
179
+ };
180
+
181
+ slider.addEventListener('input', () => {
182
+ const val = parseFloat(slider.value);
183
+ syncValue(val);
184
+ queueParamUpdate(spec.name, val);
185
+ });
186
+
187
+ number.addEventListener('change', () => {
188
+ const val = parseFloat(number.value);
189
+ if (Number.isNaN(val)) {
190
+ number.value = slider.value;
191
+ return;
192
+ }
193
+ const clamped = Math.min(Math.max(val, spec.min), spec.max);
194
+ syncValue(clamped);
195
+ queueParamUpdate(spec.name, clamped);
196
+ });
197
+
198
+ wrapper.appendChild(slider);
199
+ wrapper.appendChild(number);
200
+ card.appendChild(wrapper);
201
+ }
202
+
203
+ paramsContainer.appendChild(card);
204
+ });
205
+ }
206
+
207
+ const flushParamUpdates = debounce(async () => {
208
+ if (!state.selected || !paramQueue.size) return;
209
+ const payload = {};
210
+ paramQueue.forEach((value, key) => {
211
+ payload[key] = value;
212
+ });
213
+ paramQueue.clear();
214
+ setStatus('Saving parameters…');
215
+ try {
216
+ const data = await apiRequest('api/params', {
217
+ method: 'POST',
218
+ body: JSON.stringify({ name: state.selected, params: payload }),
219
+ });
220
+ if (Array.isArray(data.param_specs)) {
221
+ state.paramSpecs = data.param_specs;
222
+ renderParams(state.paramSpecs);
223
+ }
224
+ if (data.last_saved) {
225
+ updateSavedChip(data.last_saved);
226
+ }
227
+ setStatus('Parameters synced.');
228
+ } catch (error) {
229
+ setStatus('Parameter sync failed.');
230
+ showToast('Could not save parameter changes.');
231
+ }
232
+ }, 220);
233
+
234
+ function queueParamUpdate(name, value) {
235
+ if (!state.selected) return;
236
+ paramQueue.set(name, value);
237
+ flushParamUpdates();
238
+ }
239
+
240
+ const saveGlobals = debounce(async () => {
241
+ const payload = {
242
+ bpm: parseFloat(bpmInput.value) || state.bpm,
243
+ amplitude_scale: parseFloat(ampInput.value) || state.amplitudeScale,
244
+ };
245
+ setStatus('Saving globals…');
246
+ try {
247
+ const data = await apiRequest('api/globals', {
248
+ method: 'POST',
249
+ body: JSON.stringify(payload),
250
+ });
251
+ state.bpm = data.bpm;
252
+ state.amplitudeScale = data.amplitude_scale;
253
+ if (data.last_saved) {
254
+ updateSavedChip(data.last_saved);
255
+ }
256
+ setStatus('Globals synced.');
257
+ updateGlobalsUI();
258
+ } catch (error) {
259
+ setStatus('Global sync failed.');
260
+ showToast('Failed to save global settings.');
261
+ }
262
+ }, 250);
263
+
264
+ function updateGlobalsUI() {
265
+ bpmRange.value = state.bpm;
266
+ bpmInput.value = state.bpm.toFixed(0);
267
+ bpmDisplay.textContent = `${Math.round(state.bpm)} BPM`;
268
+ ampRange.value = state.amplitudeScale;
269
+ ampInput.value = state.amplitudeScale.toFixed(2);
270
+ ampDisplay.textContent = `${state.amplitudeScale.toFixed(2)}×`;
271
+ vizBpm.textContent = `${Math.round(state.bpm)} BPM`;
272
+ }
273
+
274
+ function initVisualizationGrid() {
275
+ vizGrid.innerHTML = '';
276
+ vizNodes.length = 0;
277
+ vizConfig.forEach((cfg) => {
278
+ const node = document.createElement('div');
279
+ node.className = 'viz-node';
280
+ const orb = document.createElement('div');
281
+ orb.className = 'viz-orb';
282
+ node.appendChild(orb);
283
+ const title = document.createElement('h4');
284
+ title.textContent = cfg.label;
285
+ node.appendChild(title);
286
+ const value = document.createElement('div');
287
+ value.className = 'viz-value';
288
+ value.textContent = cfg.unit === 'mm' ? '0.0 mm' : '0.0°';
289
+ node.appendChild(value);
290
+ const pulse = document.createElement('div');
291
+ pulse.className = 'viz-pulse';
292
+ node.appendChild(pulse);
293
+ vizGrid.appendChild(node);
294
+ vizNodes.push({ cfg, node, valueEl: value, level: 0, targetLevel: 0, currentValue: 0, targetValue: 0 });
295
+ });
296
+ }
297
+
298
+ function formatVizValue(value, unit) {
299
+ if (unit === '°') {
300
+ return `${value.toFixed(1)}°`;
301
+ }
302
+ if (unit === 'mm') {
303
+ return `${value.toFixed(1)} mm`;
304
+ }
305
+ return value.toFixed(2);
306
+ }
307
+
308
+ function updateVisualizationTargets(offsets) {
309
+ vizNodes.forEach((node) => {
310
+ const values = offsets[node.cfg.key] || [];
311
+ const raw = Number(values[node.cfg.index] || 0);
312
+ let display = raw;
313
+ if (node.cfg.unit === '°') {
314
+ display = raw * (180 / Math.PI);
315
+ } else if (node.cfg.unit === 'mm') {
316
+ display = raw * 1000;
317
+ }
318
+ node.targetValue = display;
319
+ node.targetLevel = Math.min(Math.abs(display) / node.cfg.scale, 1);
320
+ });
321
+ }
322
+
323
+ function animateVisualization() {
324
+ vizNodes.forEach((node) => {
325
+ node.level += (node.targetLevel - node.level) * 0.08;
326
+ node.currentValue += (node.targetValue - node.currentValue) * 0.12;
327
+ node.node.style.setProperty('--level', node.level.toFixed(3));
328
+ node.node.dataset.level = node.level.toFixed(2);
329
+ node.valueEl.textContent = formatVizValue(node.currentValue, node.cfg.unit);
330
+ });
331
+ requestAnimationFrame(animateVisualization);
332
+ }
333
+
334
+ async function pollVisualization() {
335
+ try {
336
+ const data = await apiRequest('api/visualization');
337
+ if (data && data.offsets) {
338
+ updateVisualizationTargets(data.offsets);
339
+ }
340
+ if (typeof data?.playing === 'boolean') {
341
+ updatePlayingChip(data.playing);
342
+ }
343
+ if (typeof data?.bpm === 'number') {
344
+ vizBpm.textContent = `${Math.round(data.bpm)} BPM`;
345
+ }
346
+ } catch (error) {
347
+ // Swallow visualization errors to avoid noisy UI.
348
+ } finally {
349
+ setTimeout(pollVisualization, 250);
350
+ }
351
+ }
352
+
353
+ async function loadDances() {
354
+ try {
355
+ const data = await apiRequest('api/dances');
356
+ state.dances = data.dances || [];
357
+ renderDanceOptions();
358
+ } catch (error) {
359
+ showToast('Unable to load dance list.');
360
+ }
361
+ }
362
+
363
+ async function refreshState() {
364
+ try {
365
+ const data = await apiRequest('api/state');
366
+ state.selected = data.selected;
367
+ state.playing = Boolean(data.playing);
368
+ state.bpm = data.bpm;
369
+ state.amplitudeScale = data.amplitude_scale;
370
+ state.paramSpecs = data.param_specs || [];
371
+ state.description = data.description || '';
372
+ updateSavedChip(data.last_saved);
373
+ renderDanceOptions();
374
+ renderParams(state.paramSpecs);
375
+ descriptionEl.textContent = state.description || 'This dance is ready to jam.';
376
+ danceSelect.value = state.selected;
377
+ updateGlobalsUI();
378
+ updatePlayingChip(state.playing);
379
+ updateToggleButton();
380
+ setStatus('Ready.');
381
+ } catch (error) {
382
+ setStatus('Failed to load state.');
383
+ showToast('Cannot reach the backend API.');
384
+ }
385
+ }
386
+
387
+ async function selectDance(name) {
388
+ setStatus('Loading dance…');
389
+ try {
390
+ const data = await apiRequest('api/select', {
391
+ method: 'POST',
392
+ body: JSON.stringify({ name }),
393
+ });
394
+ state.selected = data.selected;
395
+ state.paramSpecs = data.param_specs || [];
396
+ state.description = data.description || '';
397
+ renderDanceOptions();
398
+ renderParams(state.paramSpecs);
399
+ descriptionEl.textContent = state.description || 'Customizable move.';
400
+ setStatus('Dance loaded.');
401
+ } catch (error) {
402
+ setStatus('Unable to load dance.');
403
+ showToast('Dance selection failed.');
404
+ }
405
+ }
406
+
407
+ async function togglePlayback() {
408
+ try {
409
+ const targetState = !state.playing;
410
+ const data = await apiRequest('api/toggle', {
411
+ method: 'POST',
412
+ body: JSON.stringify({ playing: targetState }),
413
+ });
414
+ state.playing = Boolean(data.playing);
415
+ updatePlayingChip(state.playing);
416
+ updateToggleButton();
417
+ } catch (error) {
418
+ showToast('Playback toggle failed.');
419
+ }
420
+ }
421
+
422
+ async function resetDance() {
423
+ if (!state.selected) return;
424
+ setStatus('Resetting dance…');
425
+ try {
426
+ const data = await apiRequest('api/reset', {
427
+ method: 'POST',
428
+ body: JSON.stringify({ name: state.selected }),
429
+ });
430
+ state.paramSpecs = data.param_specs || [];
431
+ state.description = data.description || state.description;
432
+ updateSavedChip(data.last_saved);
433
+ renderParams(state.paramSpecs);
434
+ descriptionEl.textContent = state.description;
435
+ setStatus('Dance reset to defaults.');
436
+ } catch (error) {
437
+ setStatus('Reset failed.');
438
+ showToast('Could not reset dance.');
439
+ }
440
+ }
441
+
442
+ function attachEventListeners() {
443
+ danceSelect.addEventListener('change', () => {
444
+ const name = danceSelect.value;
445
+ if (name) {
446
+ selectDance(name);
447
+ }
448
+ });
449
+
450
+ resetDanceBtn.addEventListener('click', resetDance);
451
+ toggleBtn.addEventListener('click', togglePlayback);
452
+
453
+ bpmRange.addEventListener('input', () => {
454
+ state.bpm = parseFloat(bpmRange.value);
455
+ updateGlobalsUI();
456
+ saveGlobals();
457
+ });
458
+ bpmInput.addEventListener('change', () => {
459
+ const value = parseFloat(bpmInput.value);
460
+ if (Number.isNaN(value)) {
461
+ bpmInput.value = state.bpm.toFixed(0);
462
+ return;
463
+ }
464
+ state.bpm = Math.min(Math.max(value, 40), 180);
465
+ updateGlobalsUI();
466
+ saveGlobals();
467
+ });
468
+
469
+ ampRange.addEventListener('input', () => {
470
+ state.amplitudeScale = parseFloat(ampRange.value);
471
+ updateGlobalsUI();
472
+ saveGlobals();
473
+ });
474
+ ampInput.addEventListener('change', () => {
475
+ const value = parseFloat(ampInput.value);
476
+ if (Number.isNaN(value)) {
477
+ ampInput.value = state.amplitudeScale.toFixed(2);
478
+ return;
479
+ }
480
+ state.amplitudeScale = Math.min(Math.max(value, 0), 2);
481
+ updateGlobalsUI();
482
+ saveGlobals();
483
+ });
484
+ }
485
+
486
+ async function init() {
487
+ setStatus('Booting UI…');
488
+ initVisualizationGrid();
489
+ attachEventListeners();
490
+ await loadDances();
491
+ await refreshState();
492
+ pollVisualization();
493
+ requestAnimationFrame(animateVisualization);
494
+ }
495
+
496
+ init();
SimpleDances/static/style.css ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
3
+ color: #f7f7fb;
4
+ background-color: #05060a;
5
+ --card-bg: rgba(14, 16, 26, 0.6);
6
+ --card-border: rgba(255, 255, 255, 0.08);
7
+ --accent: #ff8ba7;
8
+ --accent-strong: #ff5f6d;
9
+ --accent-secondary: #87f1ff;
10
+ --text-muted: rgba(247, 247, 251, 0.7);
11
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ background: radial-gradient(circle at top, rgba(255, 95, 109, 0.25), transparent 50%),
21
+ radial-gradient(circle at 20% 20%, rgba(135, 241, 255, 0.18), transparent 55%),
22
+ #05060a;
23
+ color: #fff;
24
+ }
25
+
26
+ .background-glow {
27
+ position: fixed;
28
+ inset: 0;
29
+ background: radial-gradient(circle at 80% 10%, rgba(255, 206, 134, 0.4), transparent 60%),
30
+ radial-gradient(circle at 10% 80%, rgba(96, 88, 255, 0.35), transparent 70%);
31
+ filter: blur(90px);
32
+ opacity: 0.6;
33
+ pointer-events: none;
34
+ }
35
+
36
+ .app-shell {
37
+ position: relative;
38
+ max-width: 1200px;
39
+ margin: 0 auto;
40
+ padding: 48px 24px 80px;
41
+ }
42
+
43
+ .hero {
44
+ display: flex;
45
+ flex-wrap: wrap;
46
+ justify-content: space-between;
47
+ gap: 24px;
48
+ margin-bottom: 32px;
49
+ }
50
+
51
+ .eyebrow {
52
+ text-transform: uppercase;
53
+ font-size: 0.75rem;
54
+ letter-spacing: 0.25rem;
55
+ color: var(--text-muted);
56
+ margin: 0 0 8px;
57
+ }
58
+
59
+ .hero h1 {
60
+ margin: 0;
61
+ font-size: clamp(2.2rem, 4vw, 3.2rem);
62
+ }
63
+
64
+ .subtitle {
65
+ margin-top: 8px;
66
+ color: var(--text-muted);
67
+ max-width: 600px;
68
+ }
69
+
70
+ .hero-meta {
71
+ display: flex;
72
+ gap: 12px;
73
+ align-items: flex-start;
74
+ }
75
+
76
+ .panel-grid {
77
+ display: grid;
78
+ gap: 24px;
79
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
80
+ margin-bottom: 24px;
81
+ }
82
+
83
+ .card {
84
+ background: var(--card-bg);
85
+ border: 1px solid var(--card-border);
86
+ border-radius: 18px;
87
+ padding: 24px;
88
+ box-shadow: 0 25px 70px rgba(0, 0, 0, 0.35);
89
+ backdrop-filter: blur(16px);
90
+ }
91
+
92
+ .card h2 {
93
+ margin: 0 0 6px;
94
+ font-size: 1.4rem;
95
+ }
96
+
97
+ .card p {
98
+ margin: 0;
99
+ color: var(--text-muted);
100
+ }
101
+
102
+ .card-header {
103
+ display: flex;
104
+ align-items: flex-start;
105
+ justify-content: space-between;
106
+ gap: 16px;
107
+ margin-bottom: 16px;
108
+ }
109
+
110
+ .field {
111
+ margin-top: 12px;
112
+ }
113
+
114
+ .field-heading {
115
+ display: flex;
116
+ justify-content: space-between;
117
+ align-items: baseline;
118
+ font-size: 0.9rem;
119
+ color: var(--text-muted);
120
+ }
121
+
122
+ .select-wrapper {
123
+ position: relative;
124
+ }
125
+
126
+ select {
127
+ width: 100%;
128
+ padding: 12px 16px;
129
+ border-radius: 12px;
130
+ border: 1px solid rgba(255, 255, 255, 0.2);
131
+ background: rgba(255, 255, 255, 0.05);
132
+ color: #fff;
133
+ font-size: 1rem;
134
+ appearance: none;
135
+ }
136
+
137
+ select:focus {
138
+ outline: 2px solid var(--accent-secondary);
139
+ border-color: transparent;
140
+ }
141
+
142
+ .chevron {
143
+ position: absolute;
144
+ right: 16px;
145
+ top: 50%;
146
+ transform: translateY(-50%);
147
+ pointer-events: none;
148
+ color: var(--text-muted);
149
+ }
150
+
151
+ .description {
152
+ margin-top: 16px;
153
+ line-height: 1.4;
154
+ }
155
+
156
+ .slider-row {
157
+ display: grid;
158
+ grid-template-columns: 1fr 90px;
159
+ gap: 12px;
160
+ margin-top: 8px;
161
+ }
162
+
163
+ input[type="range"] {
164
+ width: 100%;
165
+ accent-color: var(--accent);
166
+ }
167
+
168
+ input[type="number"] {
169
+ width: 100%;
170
+ padding: 10px;
171
+ border-radius: 10px;
172
+ border: 1px solid rgba(255, 255, 255, 0.2);
173
+ background: rgba(255, 255, 255, 0.05);
174
+ color: #fff;
175
+ }
176
+
177
+ input[type="number"]:focus {
178
+ outline: 1px solid var(--accent-secondary);
179
+ }
180
+
181
+ .primary {
182
+ width: 100%;
183
+ padding: 14px;
184
+ border-radius: 999px;
185
+ border: none;
186
+ font-size: 1rem;
187
+ font-weight: 600;
188
+ background: linear-gradient(120deg, var(--accent), var(--accent-secondary));
189
+ color: #05060a;
190
+ cursor: pointer;
191
+ transition: transform 0.2s ease, filter 0.2s ease;
192
+ }
193
+
194
+ .primary:hover {
195
+ transform: translateY(-2px);
196
+ filter: brightness(1.1);
197
+ }
198
+
199
+ .primary.is-active {
200
+ background: linear-gradient(120deg, #49e9a6, #7bffda);
201
+ color: #041314;
202
+ }
203
+
204
+ .ghost {
205
+ background: transparent;
206
+ border: 1px solid rgba(255, 255, 255, 0.3);
207
+ border-radius: 999px;
208
+ color: #fff;
209
+ padding: 8px 16px;
210
+ cursor: pointer;
211
+ }
212
+
213
+ .status-line {
214
+ margin-top: 16px;
215
+ text-align: center;
216
+ font-size: 0.95rem;
217
+ color: var(--text-muted);
218
+ }
219
+
220
+ .chip {
221
+ display: inline-flex;
222
+ align-items: center;
223
+ padding: 6px 12px;
224
+ border-radius: 999px;
225
+ border: 1px solid rgba(255, 255, 255, 0.3);
226
+ font-size: 0.85rem;
227
+ color: #fff;
228
+ background: rgba(255, 255, 255, 0.1);
229
+ }
230
+
231
+ .chip.playing {
232
+ border-color: rgba(73, 233, 166, 0.8);
233
+ background: rgba(73, 233, 166, 0.15);
234
+ }
235
+
236
+ .chip.alert {
237
+ border-color: rgba(255, 143, 178, 0.8);
238
+ background: rgba(255, 143, 178, 0.15);
239
+ }
240
+
241
+ .viz-card {
242
+ margin-bottom: 24px;
243
+ }
244
+
245
+ .viz-grid {
246
+ display: grid;
247
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
248
+ gap: 16px;
249
+ margin-top: 16px;
250
+ }
251
+
252
+ .viz-node {
253
+ position: relative;
254
+ padding: 16px;
255
+ border-radius: 16px;
256
+ background: rgba(255, 255, 255, 0.02);
257
+ border: 1px solid rgba(255, 255, 255, 0.08);
258
+ overflow: hidden;
259
+ }
260
+
261
+ .viz-node h4 {
262
+ margin: 0 0 6px;
263
+ font-size: 0.95rem;
264
+ color: var(--text-muted);
265
+ }
266
+
267
+ .viz-value {
268
+ font-size: 1.4rem;
269
+ font-weight: 600;
270
+ }
271
+
272
+ .viz-pulse {
273
+ position: absolute;
274
+ inset: auto auto 0 0;
275
+ width: 100%;
276
+ height: 4px;
277
+ background: linear-gradient(90deg, var(--accent), var(--accent-secondary));
278
+ transform-origin: left center;
279
+ transform: scaleX(0);
280
+ opacity: 0.9;
281
+ transition: transform 0.3s ease;
282
+ }
283
+
284
+ .viz-orb {
285
+ position: absolute;
286
+ width: 120px;
287
+ height: 120px;
288
+ border-radius: 50%;
289
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0));
290
+ filter: blur(20px);
291
+ opacity: 0.5;
292
+ left: 50%;
293
+ top: 50%;
294
+ transform: translate(-50%, -50%) scale(0.4);
295
+ transition: transform 0.3s ease, opacity 0.3s ease;
296
+ pointer-events: none;
297
+ }
298
+
299
+ .viz-node[data-level] .viz-pulse {
300
+ transform: scaleX(var(--level, 0));
301
+ }
302
+
303
+ .viz-node[data-level] .viz-orb {
304
+ opacity: calc(0.2 + var(--level, 0) * 0.8);
305
+ transform: translate(-50%, -50%) scale(calc(0.35 + var(--level, 0) * 0.65));
306
+ }
307
+
308
+ .params-grid {
309
+ display: grid;
310
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
311
+ gap: 20px;
312
+ }
313
+
314
+ .param-card {
315
+ padding: 16px;
316
+ border-radius: 16px;
317
+ background: rgba(255, 255, 255, 0.03);
318
+ border: 1px solid rgba(255, 255, 255, 0.08);
319
+ }
320
+
321
+ .param-card h4 {
322
+ margin: 0 0 6px;
323
+ font-size: 1rem;
324
+ }
325
+
326
+ .param-meta {
327
+ font-size: 0.85rem;
328
+ color: var(--text-muted);
329
+ margin-bottom: 12px;
330
+ }
331
+
332
+ .param-inputs {
333
+ display: flex;
334
+ gap: 10px;
335
+ align-items: center;
336
+ }
337
+
338
+ .param-inputs input[type="range"] {
339
+ flex: 1;
340
+ }
341
+
342
+ .param-inputs input[type="number"],
343
+ .param-inputs select {
344
+ width: 90px;
345
+ padding: 8px;
346
+ border-radius: 10px;
347
+ border: 1px solid rgba(255, 255, 255, 0.2);
348
+ background: rgba(255, 255, 255, 0.05);
349
+ color: #fff;
350
+ }
351
+
352
+ .param-inputs select {
353
+ width: 100%;
354
+ }
355
+
356
+ .params-card .empty {
357
+ text-align: center;
358
+ padding: 24px;
359
+ color: var(--text-muted);
360
+ }
361
+
362
+ .toast {
363
+ position: fixed;
364
+ bottom: 32px;
365
+ right: 32px;
366
+ padding: 14px 18px;
367
+ background: rgba(0, 0, 0, 0.8);
368
+ border-radius: 12px;
369
+ color: #fff;
370
+ opacity: 0;
371
+ pointer-events: none;
372
+ transition: opacity 0.3s ease, transform 0.3s ease;
373
+ transform: translateY(10px);
374
+ }
375
+
376
+ .toast.visible {
377
+ opacity: 1;
378
+ transform: translateY(0);
379
+ }
380
+
381
+ @media (max-width: 720px) {
382
+ .slider-row {
383
+ grid-template-columns: 1fr;
384
+ }
385
+
386
+ input[type="number"] {
387
+ width: 100%;
388
+ }
389
+ }
index.html ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Simpledances </title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div class="hero">
13
+ <div class="hero-content">
14
+ <div class="app-icon">🤖⚡</div>
15
+ <h1> Simpledances </h1>
16
+ <p class="tagline">Enter your tagline here</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="app-preview">
23
+ <div class="preview-image">
24
+ <div class="camera-feed">🛠️</div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="download-section">
31
+ <div class="download-card">
32
+ <h2>Install This App</h2>
33
+
34
+ <div class="dashboard-config">
35
+ <label for="dashboardUrl">Your Reachy Dashboard URL:</label>
36
+ <input type="url" id="dashboardUrl" value="http://localhost:8000"
37
+ placeholder="http://your-reachy-ip:8000" />
38
+ </div>
39
+
40
+ <button id="installBtn" class="install-btn primary">
41
+ <span class="btn-icon">📥</span>
42
+ Install Simpledances to Reachy Mini
43
+ </button>
44
+
45
+ <div id="installStatus" class="install-status"></div>
46
+
47
+ </div>
48
+ </div>
49
+
50
+ <div class="footer">
51
+ <p>
52
+ 🤖 Simpledances •
53
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
54
+ <a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank">Browse More
55
+ Apps</a>
56
+ </p>
57
+ </div>
58
+ </div>
59
+
60
+ <script>
61
+ // Get the current Hugging Face Space URL as the repository URL
62
+ function getCurrentSpaceUrl() {
63
+ // Get current page URL and convert to repository format
64
+ const currentUrl = window.location.href;
65
+
66
+ // Remove any trailing slashes and query parameters
67
+ const cleanUrl = currentUrl.split('?')[0].replace(/\/$/, '');
68
+
69
+ return cleanUrl;
70
+ }
71
+
72
+ // Parse TOML content to extract project name
73
+ function parseTomlProjectName(tomlContent) {
74
+ try {
75
+ const lines = tomlContent.split('\n');
76
+ let inProjectSection = false;
77
+
78
+ for (const line of lines) {
79
+ const trimmedLine = line.trim();
80
+
81
+ // Check if we're entering the [project] section
82
+ if (trimmedLine === '[project]') {
83
+ inProjectSection = true;
84
+ continue;
85
+ }
86
+
87
+ // Check if we're entering a different section
88
+ if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
89
+ inProjectSection = false;
90
+ continue;
91
+ }
92
+
93
+ // If we're in the project section, look for the name field
94
+ if (inProjectSection && trimmedLine.startsWith('name')) {
95
+ const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
96
+ if (match) {
97
+ // Convert to lowercase and replace invalid characters for app naming
98
+ return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
99
+ }
100
+ }
101
+ }
102
+
103
+ throw new Error('Project name not found in pyproject.toml');
104
+ } catch (error) {
105
+ console.error('Error parsing pyproject.toml:', error);
106
+ return 'unknown-app';
107
+ }
108
+ }
109
+
110
+ // Fetch and parse pyproject.toml from the current space
111
+ async function getAppNameFromCurrentSpace() {
112
+ try {
113
+ // Fetch pyproject.toml from the current space
114
+ const response = await fetch('./pyproject.toml');
115
+ if (!response.ok) {
116
+ throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
117
+ }
118
+
119
+ const tomlContent = await response.text();
120
+ return parseTomlProjectName(tomlContent);
121
+ } catch (error) {
122
+ console.error('Error fetching app name from current space:', error);
123
+ // Fallback to extracting from URL if pyproject.toml is not accessible
124
+ const url = getCurrentSpaceUrl();
125
+ const parts = url.split('/');
126
+ const spaceName = parts[parts.length - 1];
127
+ return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
128
+ }
129
+ }
130
+
131
+ async function installToReachy() {
132
+ const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
133
+ const statusDiv = document.getElementById('installStatus');
134
+ const installBtn = document.getElementById('installBtn');
135
+
136
+ if (!dashboardUrl) {
137
+ showStatus('error', 'Please enter your Reachy dashboard URL');
138
+ return;
139
+ }
140
+
141
+ try {
142
+ installBtn.disabled = true;
143
+ installBtn.innerHTML = '<span class="btn-icon">⏳</span>Installing...';
144
+ showStatus('loading', 'Connecting to your Reachy dashboard...');
145
+
146
+ // Test connection
147
+ const testResponse = await fetch(`${dashboardUrl}/api/status`, {
148
+ method: 'GET',
149
+ mode: 'cors',
150
+ });
151
+
152
+ if (!testResponse.ok) {
153
+ throw new Error('Cannot connect to dashboard. Make sure the URL is correct and the dashboard is running.');
154
+ }
155
+
156
+ showStatus('loading', 'Reading app configuration...');
157
+
158
+ // Get app name from pyproject.toml in current space
159
+ const appName = await getAppNameFromCurrentSpace();
160
+
161
+ // Get current space URL as repository URL
162
+ const repoUrl = getCurrentSpaceUrl();
163
+
164
+ showStatus('loading', `Starting installation of "${appName}"...`);
165
+
166
+ // Start installation
167
+ const installResponse = await fetch(`${dashboardUrl}/api/install`, {
168
+ method: 'POST',
169
+ mode: 'cors',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ },
173
+ body: JSON.stringify({
174
+ url: repoUrl,
175
+ name: appName
176
+ })
177
+ });
178
+
179
+ const result = await installResponse.json();
180
+
181
+ if (installResponse.ok) {
182
+ showStatus('success', `✅ Installation started for "${appName}"! Check your dashboard for progress.`);
183
+ setTimeout(() => {
184
+ showStatus('info', `Open your dashboard at ${dashboardUrl} to see the installed app.`);
185
+ }, 3000);
186
+ } else {
187
+ throw new Error(result.detail || 'Installation failed');
188
+ }
189
+
190
+ } catch (error) {
191
+ console.error('Installation error:', error);
192
+ showStatus('error', `❌ ${error.message}`);
193
+ } finally {
194
+ installBtn.disabled = false;
195
+ installBtn.innerHTML = '<span class="btn-icon">📥</span>Install App to Reachy';
196
+ }
197
+ }
198
+
199
+ function showStatus(type, message) {
200
+ const statusDiv = document.getElementById('installStatus');
201
+ statusDiv.className = `install-status ${type}`;
202
+ statusDiv.textContent = message;
203
+ statusDiv.style.display = 'block';
204
+ }
205
+
206
+ function copyToClipboard() {
207
+ const repoUrl = document.getElementById('repoUrl').textContent;
208
+ navigator.clipboard.writeText(repoUrl).then(() => {
209
+ showStatus('success', '📋 Repository URL copied to clipboard!');
210
+ }).catch(() => {
211
+ showStatus('error', 'Failed to copy URL. Please copy manually.');
212
+ });
213
+ }
214
+
215
+ // Update the displayed repository URL on page load
216
+ document.addEventListener('DOMContentLoaded', () => {
217
+ // Auto-detect local dashboard
218
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
219
+ if (isLocalhost) {
220
+ document.getElementById('dashboardUrl').value = 'http://localhost:8000';
221
+ }
222
+
223
+ // Update the repository URL display if element exists
224
+ const repoUrlElement = document.getElementById('repoUrl');
225
+ if (repoUrlElement) {
226
+ repoUrlElement.textContent = getCurrentSpaceUrl();
227
+ }
228
+ });
229
+
230
+ // Event listeners
231
+ document.getElementById('installBtn').addEventListener('click', installToReachy);
232
+ </script>
233
+ </body>
234
+
235
+ </html>
plan.txt ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Plan for SimpleDances reachy mini app (v1)
2
+ =========================================
3
+
4
+ 0. Context & goals
5
+ ------------------
6
+ - Reuse the structure of `reachy_mini_radio` to build a Reachy Mini app with a custom UI hosted from `SimpleDances/SimpleDances/static`.
7
+ - Drive actual motions using `reachy_mini_dances_library`, exposing *all* available moves with tunable parameters.
8
+ - Provide a clean web UI with dance selection, parameter sliders + numeric inputs, start/stop toggle, BPM + amplitude sliders, reset-to-default per dance, and a rhythmic visualization tied to all 8 DoF (6 head + 2 antenna) using live data from the control loop.
9
+ - Persist parameter tweaks so each dance remembers its last settings between runs.
10
+
11
+ 1. Backend design (SimpleDances/SimpleDances/main.py)
12
+ ----------------------------------------------------
13
+ - Import `AVAILABLE_MOVES` + antenna metadata from the dance library, and pre-compute a catalog: name, friendly label (title case), description, default params, metadata (duration, etc.).
14
+ - Build helpers to:
15
+ * Load/save persisted parameters (JSON file adjacent to main.py). Structure: `{ "globals": {"bpm": float, "amplitude_scale": float}, "dances": {dance_name: {param: value}} }`.
16
+ * Merge saved params with defaults per dance.
17
+ * Derive UI specs for each param (type, slider min/max/step, unit, display multiplier) using heuristics based on the param name (e.g. `_rad` => degrees slider ±(default*3 capped), `_m` => centimeters range, `subcycles` => 0-4, `phase_offset` => 0-1, enumerations for waveform/antenna moves).
18
+ * Convert between internal units and UI units when serving values + when updates arrive.
19
+ - Shared runtime state guarded by a `threading.Lock`: selected dance name, per-dance params, BPM, amplitude scale, playing flag, timestamps, last computed offsets for visualization, file persistence timestamps, etc.
20
+ - Settings API endpoints on `self.settings_app`:
21
+ * `GET /api/dances` -> list of dances (name, label, description).
22
+ * `GET /api/state` -> current selection, playing flag, bpm, amplitude scale, parameter specs for the selected dance (values already converted for UI), and last-saved timestamp.
23
+ * `POST /api/select` -> pick a dance (payload: `{name}`), responds with updated param specs + selection.
24
+ * `POST /api/params` -> update parameters for the (selected) dance; payload includes `name` + `params` dictionary in UI units; convert, persist, respond with new specs.
25
+ * `POST /api/globals` -> update BPM and amplitude scale with validation + persistence.
26
+ * `POST /api/toggle` -> set playing flag (payload `{"playing": bool}`) and resets timing when toggling on.
27
+ * `POST /api/reset` -> reset a dance back to defaults (payload `{"name": ...}`) with save + updated specs.
28
+ * `GET /api/visualization` -> return most recent normalized offsets (position/orientation/antennas arrays) for front-end animation plus playing + bpm (read-only, polled at ~10–15 Hz by UI).
29
+ - Control loop logic:
30
+ * On `run`, load ReachyMini, ensure motors enabled as needed (maybe only when playing), instantiate start time.
31
+ * Inside loop (50–100 Hz), read `state` snapshot; if playing and a dance is selected, compute beat time using global BPM, call the corresponding move function with stored params, scale offsets by amplitude scale, convert to head pose with `create_head_pose`, and send `reachy_mini.set_target(head=head_pose, antennas=...)`. If stopped, gradually go to neutral pose (or hold last command) to avoid abrupt freeze.
32
+ * Update shared `latest_offsets` for visualization each cycle, store for `/api/visualization`.
33
+ * Sleep for ~0.02s.
34
+ - Persist JSON whenever params/globals change (debounce by writing immediately under lock).
35
+ - Handle graceful shutdown (stop flag already provided); ensure motors disabled on exit.
36
+
37
+ 2. Front-end (SimpleDances/SimpleDances/static/{index.html,style.css,main.js})
38
+ -----------------------------------------------------------------------------
39
+ - Layout: split page with header, global controls, dance selector, parameter panel, start/stop button, and visualization canvas/bars.
40
+ - Use CSS with pastel gradient, glassmorphism cards, responsive grid for parameter controls.
41
+ - JS boot flow:
42
+ * Fetch `/api/dances` once for the list (populate `<select>` or tiles).
43
+ * Fetch `/api/state` and render: selection (pre-select from list), global slider values, playing state, params grid.
44
+ * On selection change -> POST `/api/select` then update UI.
45
+ * Parameter controls: for each spec (value + slider + numeric). Both inputs stay in sync and `debounce` POST `/api/params` updates; show “saved” indicator and disable controls while saving to avoid jitter.
46
+ * Reset button -> POST `/api/reset`.
47
+ * Start/stop toggle button -> POST `/api/toggle`.
48
+ * Global BPM + amplitude sliders with numeric inputs -> POST `/api/globals` when changed (debounced) with validation (BPM 40–180, amplitude 0–2.0 as slider requirements hint).
49
+ - Visualization: create `<canvas>` or `<svg>` area that animates arcs/waves based on telemetry from `/api/visualization`. Poll every ~150 ms and animate smoothly using requestAnimationFrame. Map the 8 DoF to color-coded bars/rings (e.g., 3 orientation lines, 3 translation pulses, 2 antenna bars); amplitude drives height/opacity to satisfy “each DoF has an impact”. Provide fallback animation even when not playing (dim, idle oscillation) to keep it “beautiful and simple”.
50
+
51
+ 3. Persistence + utilities
52
+ --------------------------
53
+ - JSON file path: `SimpleDances/SimpleDances/dance_params.json` (auto-created). On failure to load parseable JSON, fall back to defaults but keep file for next writes.
54
+ - Provide helper to sanitize payloads and ensure only known params are stored.
55
+ - On each save, update `state["last_saved"]` so UI can display “Saved at …”.
56
+
57
+ 4. Testing / validation
58
+ -----------------------
59
+ - Manual: lint via running `python -m compileall` or `uv run`? (Not necessary yet but consider). Without hardware, run `python SimpleDances/SimpleDances/main.py` in dry-run to ensure no import errors and that endpoints accept sample requests.
60
+ - UI: open `index.html` via app host after hooking to CLI environment (can't do now, but ensure markup includes helpful fallback text + `aria` labels).
61
+
62
+ Open questions / assumptions
63
+ ----------------------------
64
+ - Slider heuristics rely on parameter names; confirm tomorrow whether ranges feel right. Might need fine-tuning later.
65
+ - Visualization currently planned as polling endpoint; if future requirement wants streaming/websocket, architecture allows swap.
pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "SimpleDances"
8
+ version = "0.1.0"
9
+ description = "Add your description here"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini",
14
+ "reachy_mini_dances_library",
15
+ ]
16
+ keywords = ["reachy-mini-app"]
17
+
18
+ [project.entry-points."reachy_mini_apps"]
19
+ SimpleDances = "SimpleDances.main:Simpledances"
20
+
21
+ [tool.setuptools]
22
+ package-dir = { "" = "." }
23
+ include-package-data = true
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["."]
27
+
28
+ [tool.setuptools.package-data]
29
+ SimpleDances = ["**/*"] # Also include all non-.py files
style.css ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ line-height: 1.6;
10
+ color: #333;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ min-height: 100vh;
13
+ }
14
+
15
+ .hero {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ color: white;
18
+ padding: 4rem 2rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .hero-content {
23
+ max-width: 800px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .app-icon {
28
+ font-size: 4rem;
29
+ margin-bottom: 1rem;
30
+ display: inline-block;
31
+ }
32
+
33
+ .hero h1 {
34
+ font-size: 3rem;
35
+ font-weight: 700;
36
+ margin-bottom: 1rem;
37
+ background: linear-gradient(45deg, #fff, #f0f9ff);
38
+ background-clip: text;
39
+ -webkit-background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ }
42
+
43
+ .tagline {
44
+ font-size: 1.25rem;
45
+ opacity: 0.9;
46
+ max-width: 600px;
47
+ margin: 0 auto;
48
+ }
49
+
50
+ .container {
51
+ max-width: 1200px;
52
+ margin: 0 auto;
53
+ padding: 0 2rem;
54
+ position: relative;
55
+ z-index: 2;
56
+ }
57
+
58
+ .main-card {
59
+ background: white;
60
+ border-radius: 20px;
61
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
62
+ margin-top: -2rem;
63
+ overflow: hidden;
64
+ margin-bottom: 3rem;
65
+ }
66
+
67
+ .app-preview {
68
+ background: linear-gradient(135deg, #1e3a8a, #3b82f6);
69
+ padding: 3rem;
70
+ color: white;
71
+ text-align: center;
72
+ position: relative;
73
+ }
74
+
75
+ .preview-image {
76
+ background: #000;
77
+ border-radius: 15px;
78
+ padding: 2rem;
79
+ max-width: 500px;
80
+ margin: 0 auto;
81
+ position: relative;
82
+ overflow: hidden;
83
+ }
84
+
85
+ .camera-feed {
86
+ font-size: 4rem;
87
+ margin-bottom: 1rem;
88
+ opacity: 0.7;
89
+ }
90
+
91
+ .detection-overlay {
92
+ position: absolute;
93
+ top: 50%;
94
+ left: 50%;
95
+ transform: translate(-50%, -50%);
96
+ width: 100%;
97
+ }
98
+
99
+ .bbox {
100
+ background: rgba(34, 197, 94, 0.9);
101
+ color: white;
102
+ padding: 0.5rem 1rem;
103
+ border-radius: 8px;
104
+ font-size: 0.9rem;
105
+ font-weight: 600;
106
+ margin: 0.5rem;
107
+ display: inline-block;
108
+ border: 2px solid #22c55e;
109
+ }
110
+
111
+ .app-details {
112
+ padding: 3rem;
113
+ }
114
+
115
+ .app-details h2 {
116
+ font-size: 2rem;
117
+ color: #1e293b;
118
+ margin-bottom: 2rem;
119
+ text-align: center;
120
+ }
121
+
122
+ .template-info {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
125
+ gap: 2rem;
126
+ margin-bottom: 3rem;
127
+ }
128
+
129
+ .info-box {
130
+ background: #f0f9ff;
131
+ border: 2px solid #e0f2fe;
132
+ border-radius: 12px;
133
+ padding: 2rem;
134
+ }
135
+
136
+ .info-box h3 {
137
+ color: #0c4a6e;
138
+ margin-bottom: 1rem;
139
+ font-size: 1.2rem;
140
+ }
141
+
142
+ .info-box p {
143
+ color: #0369a1;
144
+ line-height: 1.6;
145
+ }
146
+
147
+ .how-to-use {
148
+ background: #fefce8;
149
+ border: 2px solid #fde047;
150
+ border-radius: 12px;
151
+ padding: 2rem;
152
+ margin-top: 3rem;
153
+ }
154
+
155
+ .how-to-use h3 {
156
+ color: #a16207;
157
+ margin-bottom: 1.5rem;
158
+ font-size: 1.3rem;
159
+ text-align: center;
160
+ }
161
+
162
+ .steps {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 1.5rem;
166
+ }
167
+
168
+ .step {
169
+ display: flex;
170
+ align-items: flex-start;
171
+ gap: 1rem;
172
+ }
173
+
174
+ .step-number {
175
+ background: #eab308;
176
+ color: white;
177
+ width: 2rem;
178
+ height: 2rem;
179
+ border-radius: 50%;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-weight: bold;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .step h4 {
188
+ color: #a16207;
189
+ margin-bottom: 0.5rem;
190
+ font-size: 1.1rem;
191
+ }
192
+
193
+ .step p {
194
+ color: #ca8a04;
195
+ }
196
+
197
+ .download-card {
198
+ background: white;
199
+ border-radius: 20px;
200
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
201
+ padding: 3rem;
202
+ text-align: center;
203
+ }
204
+
205
+ .download-card h2 {
206
+ font-size: 2rem;
207
+ color: #1e293b;
208
+ margin-bottom: 1rem;
209
+ }
210
+
211
+ .download-card>p {
212
+ color: #64748b;
213
+ font-size: 1.1rem;
214
+ margin-bottom: 2rem;
215
+ }
216
+
217
+ .dashboard-config {
218
+ margin-bottom: 2rem;
219
+ text-align: left;
220
+ max-width: 400px;
221
+ margin-left: auto;
222
+ margin-right: auto;
223
+ }
224
+
225
+ .dashboard-config label {
226
+ display: block;
227
+ color: #374151;
228
+ font-weight: 600;
229
+ margin-bottom: 0.5rem;
230
+ }
231
+
232
+ .dashboard-config input {
233
+ width: 100%;
234
+ padding: 0.75rem 1rem;
235
+ border: 2px solid #e5e7eb;
236
+ border-radius: 8px;
237
+ font-size: 0.95rem;
238
+ transition: border-color 0.2s;
239
+ }
240
+
241
+ .dashboard-config input:focus {
242
+ outline: none;
243
+ border-color: #667eea;
244
+ }
245
+
246
+ .install-btn {
247
+ background: linear-gradient(135deg, #667eea, #764ba2);
248
+ color: white;
249
+ border: none;
250
+ padding: 1.25rem 3rem;
251
+ border-radius: 16px;
252
+ font-size: 1.2rem;
253
+ font-weight: 700;
254
+ cursor: pointer;
255
+ transition: all 0.3s ease;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 0.75rem;
259
+ margin-bottom: 2rem;
260
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
261
+ }
262
+
263
+ .install-btn:hover:not(:disabled) {
264
+ transform: translateY(-3px);
265
+ box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
266
+ }
267
+
268
+ .install-btn:disabled {
269
+ opacity: 0.7;
270
+ cursor: not-allowed;
271
+ transform: none;
272
+ }
273
+
274
+ .manual-option {
275
+ background: #f8fafc;
276
+ border-radius: 12px;
277
+ padding: 2rem;
278
+ margin-top: 2rem;
279
+ }
280
+
281
+ .manual-option h3 {
282
+ color: #1e293b;
283
+ margin-bottom: 1rem;
284
+ font-size: 1.2rem;
285
+ }
286
+
287
+ .manual-option>p {
288
+ color: #64748b;
289
+ margin-bottom: 1rem;
290
+ }
291
+
292
+ .btn-icon {
293
+ font-size: 1.1rem;
294
+ }
295
+
296
+ .install-status {
297
+ padding: 1rem;
298
+ border-radius: 8px;
299
+ font-size: 0.9rem;
300
+ text-align: center;
301
+ display: none;
302
+ margin-top: 1rem;
303
+ }
304
+
305
+ .install-status.success {
306
+ background: #dcfce7;
307
+ color: #166534;
308
+ border: 1px solid #bbf7d0;
309
+ }
310
+
311
+ .install-status.error {
312
+ background: #fef2f2;
313
+ color: #dc2626;
314
+ border: 1px solid #fecaca;
315
+ }
316
+
317
+ .install-status.loading {
318
+ background: #dbeafe;
319
+ color: #1d4ed8;
320
+ border: 1px solid #bfdbfe;
321
+ }
322
+
323
+ .install-status.info {
324
+ background: #e0f2fe;
325
+ color: #0369a1;
326
+ border: 1px solid #7dd3fc;
327
+ }
328
+
329
+ .manual-install {
330
+ background: #1f2937;
331
+ border-radius: 8px;
332
+ padding: 1rem;
333
+ margin-bottom: 1rem;
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 1rem;
337
+ }
338
+
339
+ .manual-install code {
340
+ color: #10b981;
341
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
342
+ font-size: 0.85rem;
343
+ flex: 1;
344
+ overflow-x: auto;
345
+ }
346
+
347
+ .copy-btn {
348
+ background: #374151;
349
+ color: white;
350
+ border: none;
351
+ padding: 0.5rem 1rem;
352
+ border-radius: 6px;
353
+ font-size: 0.8rem;
354
+ cursor: pointer;
355
+ transition: background-color 0.2s;
356
+ }
357
+
358
+ .copy-btn:hover {
359
+ background: #4b5563;
360
+ }
361
+
362
+ .manual-steps {
363
+ color: #6b7280;
364
+ font-size: 0.9rem;
365
+ line-height: 1.8;
366
+ }
367
+
368
+ .footer {
369
+ text-align: center;
370
+ padding: 2rem;
371
+ color: white;
372
+ opacity: 0.8;
373
+ }
374
+
375
+ .footer a {
376
+ color: white;
377
+ text-decoration: none;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .footer a:hover {
382
+ text-decoration: underline;
383
+ }
384
+
385
+ /* Responsive Design */
386
+ @media (max-width: 768px) {
387
+ .hero {
388
+ padding: 2rem 1rem;
389
+ }
390
+
391
+ .hero h1 {
392
+ font-size: 2rem;
393
+ }
394
+
395
+ .container {
396
+ padding: 0 1rem;
397
+ }
398
+
399
+ .app-details,
400
+ .download-card {
401
+ padding: 2rem;
402
+ }
403
+
404
+ .features-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .download-options {
409
+ grid-template-columns: 1fr;
410
+ }
411
+ }