RemiFabre commited on
Commit
af83c92
·
1 Parent(s): 33de5de

Update app for official version

Browse files
README.md CHANGED
@@ -1,11 +1,11 @@
1
  ---
2
  title: Emotions
3
- emoji: 👋
4
  colorFrom: red
5
  colorTo: blue
6
  sdk: static
7
  pinned: false
8
- short_description: Trigger Reachy Mini's emotions!
9
  tags:
10
  - reachy_mini
11
  - reachy_mini_python_app
 
1
  ---
2
  title: Emotions
3
+ emoji: 🎡
4
  colorFrom: red
5
  colorTo: blue
6
  sdk: static
7
  pinned: false
8
+ short_description: Spin Reachy Mini's emotion wheel and trigger any behavior.
9
  tags:
10
  - reachy_mini
11
  - reachy_mini_python_app
build/lib/emotions/__init__.py ADDED
File without changes
build/lib/emotions/main.py ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import threading
5
+ import time
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import HTTPException
13
+ from pydantic import BaseModel, Field
14
+ from ruamel.yaml import YAML
15
+
16
+ from reachy_mini import ReachyMini, ReachyMiniApp
17
+ from reachy_mini.motion.recorded_move import RecordedMoves
18
+
19
+ EMOTIONS_DATASET = "pollen-robotics/reachy-mini-emotions-library"
20
+ RESOURCE_ROOT = Path(__file__).resolve().parent.parent / "ressources"
21
+ EMOTIONS_YAML_PATH = RESOURCE_ROOT / "emotions.yml"
22
+ COHERENCE_DURATION_TOLERANCE = 0.25
23
+ YAML_LOADER = YAML()
24
+ YAML_LOADER.preserve_quotes = True
25
+ YAML_LOADER.indent(mapping=2, sequence=4, offset=2)
26
+
27
+ DEFAULT_SECTOR_ORDER = {
28
+ "emotion": [
29
+ "Joy",
30
+ "Trust",
31
+ "Fear",
32
+ "Surprise",
33
+ "Sadness",
34
+ "Disgust",
35
+ "Anger",
36
+ "Anticipation",
37
+ ],
38
+ "behavior": [
39
+ "Agree",
40
+ "Disagree",
41
+ "Ask",
42
+ "Listen",
43
+ "Direct",
44
+ "Social",
45
+ "Play",
46
+ "Meta",
47
+ ],
48
+ }
49
+
50
+ WHEEL_NODE_LAYOUT = {
51
+ "emotion": {
52
+ "node_size": 96.0,
53
+ "padding": 30.0,
54
+ "inner_margin": 40.0,
55
+ "fallback_radius": 240.0,
56
+ "min_ratio": 0.35,
57
+ },
58
+ "behavior": {
59
+ "node_size": 96.0,
60
+ "padding": 30.0,
61
+ "inner_margin": 40.0,
62
+ "fallback_radius": 240.0,
63
+ "min_ratio": 0.35,
64
+ },
65
+ }
66
+
67
+
68
+ @dataclass
69
+ class WheelMove:
70
+ move_id: str
71
+ clean_name: str
72
+ description: str
73
+ move_type: str
74
+ wheel_sector: str
75
+ intensity: float
76
+ normalized_intensity: float
77
+ duration_seconds: float
78
+ duration_bucket: str
79
+ duration_color: str
80
+ sector_color: str
81
+ available: bool
82
+ recorded_duration: float | None
83
+ order: int
84
+ layout: dict[str, float] | None = None
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return {
88
+ "id": self.move_id,
89
+ "label": self.clean_name,
90
+ "description": self.description,
91
+ "type": self.move_type,
92
+ "wheel_sector": self.wheel_sector,
93
+ "intensity": self.intensity,
94
+ "normalized_intensity": self.normalized_intensity,
95
+ "duration_seconds": self.duration_seconds,
96
+ "duration_bucket": self.duration_bucket,
97
+ "duration_color": self.duration_color,
98
+ "sector_color": self.sector_color,
99
+ "available": self.available,
100
+ "recorded_duration": self.recorded_duration,
101
+ "order": self.order,
102
+ "layout": self.layout,
103
+ }
104
+
105
+
106
+ @dataclass
107
+ class WheelSector:
108
+ slug: str
109
+ label: str
110
+ color: str
111
+ note: str | None
112
+ moves: list[WheelMove] = field(default_factory=list)
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ return {
116
+ "id": self.slug,
117
+ "label": self.label,
118
+ "color": self.color,
119
+ "note": self.note,
120
+ "moves": [move.to_dict() for move in self.moves],
121
+ "count": len(self.moves),
122
+ }
123
+
124
+
125
+ @dataclass
126
+ class EmotionCatalog:
127
+ wheels: dict[str, list[WheelSector]]
128
+ duration_buckets: list[dict[str, Any]]
129
+ intensity_scale: dict[str, float]
130
+ wheel_layout: dict[str, dict[str, float]]
131
+ coherence_report: dict[str, Any]
132
+ move_lookup: dict[str, WheelMove]
133
+
134
+ def to_payload(self) -> dict[str, Any]:
135
+ def wheel_payload(key: str, label: str) -> dict[str, Any]:
136
+ sectors = self.wheels.get(key, [])
137
+ return {
138
+ "label": label,
139
+ "sectors": [sector.to_dict() for sector in sectors],
140
+ "total_moves": sum(len(sector.moves) for sector in sectors),
141
+ "layout": self.wheel_layout.get(key, {}),
142
+ }
143
+
144
+ return {
145
+ "wheels": {
146
+ "emotion": wheel_payload("emotion", "Emotions"),
147
+ "behavior": wheel_payload("behavior", "Behaviors"),
148
+ },
149
+ "duration_buckets": self.duration_buckets,
150
+ "intensity_scale": self.intensity_scale,
151
+ "wheel_layout": self.wheel_layout,
152
+ "coherence": self.coherence_report,
153
+ }
154
+
155
+
156
+ class PlayEmotionPayload(BaseModel):
157
+ move_name: str = Field(..., description="Exact key of the recorded move to play")
158
+
159
+
160
+ class MovePositionPayload(BaseModel):
161
+ angle: float = Field(..., description="Angle in degrees (0° at positive X axis)")
162
+ radius: float = Field(
163
+ ..., ge=0.0, description="Radius ratio relative to wheel max radius (values >1 allowed)"
164
+ )
165
+
166
+
167
+ class SaveLayoutPayload(BaseModel):
168
+ positions: dict[str, MovePositionPayload]
169
+
170
+
171
+ def slugify(value: str) -> str:
172
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
173
+ return slug or "sector"
174
+
175
+
176
+ def load_emotions_spec_tree() -> Any:
177
+ if not EMOTIONS_YAML_PATH.exists():
178
+ raise FileNotFoundError(f"Cannot locate emotions spec at {EMOTIONS_YAML_PATH}")
179
+ with EMOTIONS_YAML_PATH.open(encoding="utf-8") as handle:
180
+ return YAML_LOADER.load(handle)
181
+
182
+
183
+ def dump_emotions_spec_tree(tree: Any) -> None:
184
+ with EMOTIONS_YAML_PATH.open("w", encoding="utf-8") as handle:
185
+ YAML_LOADER.dump(tree, handle)
186
+
187
+
188
+ def clamp(value: float, lower: float, upper: float) -> float:
189
+ return max(lower, min(upper, value))
190
+
191
+
192
+ def normalize_intensity(value: float, scale: dict[str, float]) -> float:
193
+ min_val = float(scale.get("min", 0.0))
194
+ max_val = float(scale.get("max", 10.0))
195
+ if max_val <= min_val:
196
+ return 0.5
197
+ normalized = (value - min_val) / (max_val - min_val)
198
+ return max(0.0, min(1.0, normalized))
199
+
200
+
201
+ def classify_duration(duration: float, thresholds: dict[str, float]) -> str:
202
+ short_max = thresholds.get("short_max", 3.25)
203
+ medium_max = thresholds.get("medium_max", 6.5)
204
+ if duration <= short_max:
205
+ return "short"
206
+ if duration <= medium_max:
207
+ return "medium"
208
+ return "long"
209
+
210
+
211
+ def prepare_duration_buckets(
212
+ thresholds: dict[str, float], palette: dict[str, dict[str, Any]]
213
+ ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
214
+ short_max = float(thresholds.get("short_max", 3.25))
215
+ medium_max = float(thresholds.get("medium_max", 6.5))
216
+
217
+ def color_for(bucket: str) -> str:
218
+ return palette.get(bucket, {}).get("hex", "#ffffff")
219
+
220
+ buckets = [
221
+ {
222
+ "id": "short",
223
+ "label": f"Short (≤ {short_max:.2f}s)",
224
+ "max_seconds": short_max,
225
+ "color": color_for("short"),
226
+ },
227
+ {
228
+ "id": "medium",
229
+ "label": f"Medium ({short_max:.2f}s – {medium_max:.2f}s)",
230
+ "min_seconds": short_max,
231
+ "max_seconds": medium_max,
232
+ "color": color_for("medium"),
233
+ },
234
+ {
235
+ "id": "long",
236
+ "label": f"Long (> {medium_max:.2f}s)",
237
+ "min_seconds": medium_max,
238
+ "color": color_for("long"),
239
+ },
240
+ ]
241
+ lookup = {bucket["id"]: bucket for bucket in buckets}
242
+ return buckets, lookup
243
+
244
+
245
+ def build_wheel_sectors(
246
+ moves: list[WheelMove], palette: dict[str, dict[str, Any]]
247
+ ) -> list[WheelSector]:
248
+ sectors: list[WheelSector] = []
249
+ handled_labels: set[str] = set()
250
+
251
+ for label, style in palette.items():
252
+ filtered = [move for move in moves if move.wheel_sector == label]
253
+ if not filtered:
254
+ continue
255
+ sector = WheelSector(
256
+ slug=slugify(label),
257
+ label=label,
258
+ color=style.get("hex", "#ffffff"),
259
+ note=style.get("note"),
260
+ moves=filtered,
261
+ )
262
+ sectors.append(sector)
263
+ handled_labels.add(label)
264
+
265
+ extra_labels = {
266
+ move.wheel_sector for move in moves if move.wheel_sector not in handled_labels
267
+ }
268
+ for label in extra_labels:
269
+ filtered = [move for move in moves if move.wheel_sector == label]
270
+ if not filtered:
271
+ continue
272
+ sectors.append(
273
+ WheelSector(
274
+ slug=slugify(label),
275
+ label=label,
276
+ color="#a0a0a0",
277
+ note=None,
278
+ moves=filtered,
279
+ )
280
+ )
281
+ return sectors
282
+
283
+
284
+ def build_catalog(spec: dict[str, Any], recorded_emotions: RecordedMoves) -> EmotionCatalog:
285
+ meta = spec.get("meta", {})
286
+ intensity_scale = {
287
+ "min": float(meta.get("intensity_scale", {}).get("min", 0.0)),
288
+ "max": float(meta.get("intensity_scale", {}).get("max", 10.0)),
289
+ }
290
+ thresholds = {
291
+ "short_max": float(meta.get("duration_thresholds_seconds", {}).get("short_max", 3.25)),
292
+ "medium_max": float(meta.get("duration_thresholds_seconds", {}).get("medium_max", 6.5)),
293
+ }
294
+ wheel_layout_cfg = meta.get("wheel_layout", {})
295
+ wheel_layout = {
296
+ "emotion": {"radius": float(wheel_layout_cfg.get("emotion_radius", 240.0))},
297
+ "behavior": {"radius": float(wheel_layout_cfg.get("behavior_radius", 200.0))},
298
+ }
299
+ color_system = meta.get("color_system", {})
300
+ sector_palette: dict[str, dict[str, Any]] = color_system.get("wheel_sector_colors", {})
301
+ duration_palette: dict[str, dict[str, Any]] = color_system.get("duration_bucket_colors", {})
302
+ duration_buckets, duration_lookup = prepare_duration_buckets(thresholds, duration_palette)
303
+
304
+ dataset_move_names = set(recorded_emotions.list_moves())
305
+ moves_by_type: dict[str, list[WheelMove]] = {"emotion": [], "behavior": []}
306
+ move_lookup: dict[str, WheelMove] = {}
307
+
308
+ coherence = {
309
+ "yaml_missing_in_dataset": [],
310
+ "dataset_missing_in_yaml": [],
311
+ "duration_mismatches": [],
312
+ "sector_type_conflicts": [],
313
+ "unknown_sectors": [],
314
+ "unknown_types": [],
315
+ "intensity_out_of_range": [],
316
+ }
317
+ sector_type_map: dict[str, set[str]] = defaultdict(set)
318
+ spec_move_ids: set[str] = set()
319
+
320
+ for order, move_data in enumerate(spec.get("moves", [])):
321
+ move_id = move_data.get("id")
322
+ if not move_id:
323
+ continue
324
+ spec_move_ids.add(move_id)
325
+ move_type = (move_data.get("type") or "behavior").lower()
326
+ if move_type not in moves_by_type:
327
+ coherence["unknown_types"].append({"move": move_id, "type": move_type})
328
+ move_type = "behavior"
329
+
330
+ wheel_sector = move_data.get("wheel_sector", "Uncategorized")
331
+ sector_style = sector_palette.get(wheel_sector)
332
+ if not sector_style:
333
+ coherence["unknown_sectors"].append({"move": move_id, "sector": wheel_sector})
334
+ sector_color = (sector_style or {}).get("hex", "#a0a0a0")
335
+
336
+ intensity = float(move_data.get("intensity", intensity_scale["min"]))
337
+ normalized_intensity = normalize_intensity(intensity, intensity_scale)
338
+ if intensity < intensity_scale["min"] or intensity > intensity_scale["max"]:
339
+ coherence["intensity_out_of_range"].append(
340
+ {"move": move_id, "value": intensity}
341
+ )
342
+
343
+ duration_seconds = float(move_data.get("duration_seconds", 0.0))
344
+ bucket = classify_duration(duration_seconds, thresholds)
345
+ duration_color = duration_lookup[bucket]["color"]
346
+ description = move_data.get("description") or ""
347
+ clean_name = move_data.get("clean_name") or move_id
348
+
349
+ recorded_duration = None
350
+ available = move_id in dataset_move_names
351
+ if available:
352
+ dataset_move = recorded_emotions.get(move_id)
353
+ recorded_duration = float(dataset_move.duration)
354
+ diff = abs(recorded_duration - duration_seconds)
355
+ if diff > COHERENCE_DURATION_TOLERANCE:
356
+ coherence["duration_mismatches"].append(
357
+ {
358
+ "move": move_id,
359
+ "yaml_duration": duration_seconds,
360
+ "dataset_duration": recorded_duration,
361
+ "delta": diff,
362
+ }
363
+ )
364
+ else:
365
+ coherence["yaml_missing_in_dataset"].append(
366
+ {
367
+ "move": move_id,
368
+ "name": clean_name,
369
+ "type": move_type,
370
+ "sector": wheel_sector,
371
+ }
372
+ )
373
+
374
+ layout = None
375
+ position_data = move_data.get("position")
376
+ if isinstance(position_data, dict):
377
+ try:
378
+ angle = float(position_data.get("angle", 0.0)) % 360
379
+ radius_value = max(0.0, float(position_data.get("radius", 0.5)))
380
+ layout = {"angle": angle, "radius": radius_value}
381
+ except (TypeError, ValueError):
382
+ layout = None
383
+
384
+ sector_type_map[wheel_sector].add(move_type)
385
+ move = WheelMove(
386
+ move_id=move_id,
387
+ clean_name=clean_name,
388
+ description=description,
389
+ move_type=move_type,
390
+ wheel_sector=wheel_sector,
391
+ intensity=intensity,
392
+ normalized_intensity=normalized_intensity,
393
+ duration_seconds=duration_seconds,
394
+ duration_bucket=bucket,
395
+ duration_color=duration_color,
396
+ sector_color=sector_color,
397
+ available=available,
398
+ recorded_duration=recorded_duration,
399
+ order=order,
400
+ layout=layout,
401
+ )
402
+ moves_by_type[move_type].append(move)
403
+ move_lookup[move_id] = move
404
+
405
+ coherence["dataset_missing_in_yaml"] = sorted(dataset_move_names - spec_move_ids)
406
+ coherence["sector_type_conflicts"] = [
407
+ {"sector": sector, "types": sorted(types)}
408
+ for sector, types in sector_type_map.items()
409
+ if len(types) > 1
410
+ ]
411
+
412
+ wheels = {
413
+ wheel_type: build_wheel_sectors(moves, sector_palette)
414
+ for wheel_type, moves in moves_by_type.items()
415
+ }
416
+
417
+ return EmotionCatalog(
418
+ wheels=wheels,
419
+ duration_buckets=duration_buckets,
420
+ intensity_scale=intensity_scale,
421
+ wheel_layout=wheel_layout,
422
+ coherence_report=coherence,
423
+ move_lookup=move_lookup,
424
+ )
425
+
426
+
427
+ def determine_sector_order(wheel_type: str, sectors: set[str]) -> list[str]:
428
+ remaining = list(sectors)
429
+ ordered: list[str] = []
430
+ for label in DEFAULT_SECTOR_ORDER.get(wheel_type, []):
431
+ if label in remaining:
432
+ ordered.append(label)
433
+ remaining.remove(label)
434
+ ordered.extend(sorted(remaining))
435
+ return ordered
436
+
437
+
438
+ def apply_simple_wheel_layout(tree: Any) -> None:
439
+ meta = tree.get("meta", {})
440
+ wheel_layout_cfg = meta.get("wheel_layout", {})
441
+ intensity_scale = meta.get("intensity_scale", {"min": 0.0, "max": 10.0})
442
+ min_intensity = float(intensity_scale.get("min", 0.0))
443
+ max_intensity = float(intensity_scale.get("max", 10.0))
444
+
445
+ wheel_moves: dict[str, dict[str, list[dict[str, Any]]]] = {
446
+ "emotion": defaultdict(list),
447
+ "behavior": defaultdict(list),
448
+ }
449
+
450
+ moves_section = tree.get("moves", [])
451
+ for order, entry in enumerate(moves_section):
452
+ if not isinstance(entry, dict):
453
+ continue
454
+ move_id = entry.get("id")
455
+ if not move_id:
456
+ continue
457
+ move_type = (entry.get("type") or "behavior").lower()
458
+ if move_type not in wheel_moves:
459
+ move_type = "behavior"
460
+ sector = entry.get("wheel_sector") or "Uncategorized"
461
+ intensity = float(entry.get("intensity", min_intensity))
462
+ wheel_moves[move_type][sector].append(
463
+ {"entry": entry, "intensity": intensity, "order": order}
464
+ )
465
+
466
+ for wheel_type, sectors in wheel_moves.items():
467
+ if not sectors:
468
+ continue
469
+ settings = WHEEL_NODE_LAYOUT[wheel_type]
470
+ wheel_radius = float(
471
+ wheel_layout_cfg.get(f"{wheel_type}_radius", settings["fallback_radius"])
472
+ )
473
+ node_size = settings["node_size"]
474
+ padding = settings["padding"]
475
+ inner_margin = settings["inner_margin"]
476
+ ordered_sectors = determine_sector_order(wheel_type, set(sectors.keys()))
477
+ span = 360.0 / len(ordered_sectors)
478
+
479
+ for index, sector in enumerate(ordered_sectors):
480
+ moves = sectors.get(sector, [])
481
+ if not moves:
482
+ continue
483
+ center_angle = (index * span + span / 2) % 360.0
484
+ sorted_moves = sorted(
485
+ moves, key=lambda item: (item["intensity"], item["order"])
486
+ )
487
+
488
+ base_offset = inner_margin + node_size + padding
489
+ step = node_size + padding
490
+ min_ratio = settings.get("min_ratio", 0.25)
491
+
492
+ for position_index, move in enumerate(sorted_moves):
493
+ radius_px = base_offset + position_index * step
494
+ intensity_ratio = (
495
+ (move["intensity"] - min_intensity)
496
+ / (max_intensity - min_intensity or 1.0)
497
+ )
498
+ intensity_boost = (node_size * 0.35) * intensity_ratio
499
+ radius_px = radius_px + intensity_boost
500
+ radius_ratio = max(radius_px / wheel_radius, min_ratio)
501
+ move["entry"]["position"] = {
502
+ "angle": round(center_angle, 6),
503
+ "radius": round(radius_ratio, 6),
504
+ }
505
+
506
+
507
+ class Emotions(ReachyMiniApp):
508
+ """Reachy Mini app that plays recorded emotions with a YAML-driven UI."""
509
+
510
+ custom_app_url: str | None = "http://0.0.0.0:8042"
511
+ request_media_backend: str | None = None
512
+
513
+ def __init__(self) -> None:
514
+ super().__init__()
515
+ self._recorded_emotions = RecordedMoves(EMOTIONS_DATASET)
516
+ self._spec_tree = load_emotions_spec_tree()
517
+ self._catalog = build_catalog(self._spec_tree, self._recorded_emotions)
518
+ self._move_lookup = self._catalog.move_lookup
519
+ self._lock = threading.Lock()
520
+ self._config_lock = threading.Lock()
521
+ self._pending_move: str | None = None
522
+ self._current_move: str | None = None
523
+ self._last_completed: float | None = None
524
+ self._register_routes()
525
+
526
+ def _refresh_catalog(self, tree: Any | None = None) -> None:
527
+ if tree is None:
528
+ tree = load_emotions_spec_tree()
529
+ self._spec_tree = tree
530
+ self._catalog = build_catalog(tree, self._recorded_emotions)
531
+ self._move_lookup = self._catalog.move_lookup
532
+
533
+ def _save_positions(self, positions: dict[str, MovePositionPayload]) -> None:
534
+ with self._config_lock:
535
+ tree = load_emotions_spec_tree()
536
+ moves_section = tree.get("moves", [])
537
+ for entry in moves_section:
538
+ if not isinstance(entry, dict):
539
+ continue
540
+ move_id = entry.get("id")
541
+ if not move_id:
542
+ continue
543
+ payload = positions.get(move_id)
544
+ if payload is None:
545
+ continue
546
+ angle = float(payload.angle) % 360
547
+ radius = max(0.0, float(payload.radius))
548
+ entry["position"] = {"angle": angle, "radius": radius}
549
+
550
+ dump_emotions_spec_tree(tree)
551
+ self._refresh_catalog(tree)
552
+
553
+ def _reset_positions(self) -> None:
554
+ with self._config_lock:
555
+ tree = load_emotions_spec_tree()
556
+ apply_simple_wheel_layout(tree)
557
+ dump_emotions_spec_tree(tree)
558
+ self._refresh_catalog(tree)
559
+
560
+ def _register_routes(self) -> None:
561
+ @self.settings_app.get("/api/emotions")
562
+ def list_emotions() -> dict[str, Any]:
563
+ return self._catalog.to_payload()
564
+
565
+ @self.settings_app.get("/api/visualization")
566
+ def visualization() -> dict[str, Any]:
567
+ # Return the same payload for compatibility with dashboards expecting this endpoint.
568
+ return {
569
+ "wheels": self._catalog.to_payload()["wheels"],
570
+ "updated_at": datetime.utcnow().isoformat(),
571
+ }
572
+
573
+ @self.settings_app.get("/api/state")
574
+ def get_state() -> dict[str, Any]:
575
+ with self._lock:
576
+ current = self._current_move
577
+ pending = self._pending_move
578
+ last_completed = (
579
+ datetime.fromtimestamp(self._last_completed).isoformat()
580
+ if self._last_completed
581
+ else None
582
+ )
583
+
584
+ return {
585
+ "is_playing": current is not None,
586
+ "current_move": current,
587
+ "pending_move": pending,
588
+ "last_completed_at": last_completed,
589
+ }
590
+
591
+ @self.settings_app.post("/api/play")
592
+ def play_emotion(payload: PlayEmotionPayload) -> dict[str, Any]:
593
+ move_name = payload.move_name
594
+ move_meta = self._move_lookup.get(move_name)
595
+ if move_meta is None:
596
+ raise HTTPException(status_code=404, detail=f"Unknown move {move_name}")
597
+ if not move_meta.available:
598
+ raise HTTPException(
599
+ status_code=409,
600
+ detail=f"Move {move_name} is missing from the recorded dataset.",
601
+ )
602
+
603
+ with self._lock:
604
+ if self._current_move or self._pending_move:
605
+ return {"accepted": False, "reason": "busy"}
606
+ self._pending_move = move_name
607
+ return {
608
+ "accepted": True,
609
+ "queued_move": move_name,
610
+ "display_label": move_meta.clean_name,
611
+ }
612
+
613
+ @self.settings_app.post("/api/layout/save")
614
+ def save_layout(payload: SaveLayoutPayload) -> dict[str, Any]:
615
+ self._save_positions(payload.positions)
616
+ return {"status": "ok"}
617
+
618
+ @self.settings_app.post("/api/layout/reset")
619
+ def reset_layout() -> dict[str, Any]:
620
+ self._reset_positions()
621
+ return {"status": "ok"}
622
+
623
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
624
+ while not stop_event.is_set():
625
+ move_to_play: str | None = None
626
+ with self._lock:
627
+ if self._pending_move:
628
+ move_to_play = self._pending_move
629
+ self._pending_move = None
630
+ self._current_move = move_to_play
631
+
632
+ if move_to_play:
633
+ move = self._recorded_emotions.get(move_to_play)
634
+ reachy_mini.play_move(move, initial_goto_duration=0.7)
635
+ with self._lock:
636
+ self._current_move = None
637
+ self._last_completed = time.time()
638
+ else:
639
+ stop_event.wait(0.01)
640
+
641
+
642
+ if __name__ == "__main__":
643
+ app = Emotions()
644
+ try:
645
+ app.wrapped_run()
646
+ except KeyboardInterrupt:
647
+ app.stop()
build/lib/emotions/static/index.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0">
7
+ <title>Reachy Mini • Emotions Wheel</title>
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ </head>
10
+
11
+ <body>
12
+ <div class="background-halo"></div>
13
+ <main class="app-shell">
14
+ <header class="hero">
15
+ <div>
16
+ <p class="eyebrow">Reachy Mini Companion</p>
17
+ <h1>Wheel of Emotions & Behaviors</h1>
18
+ <p class="subtitle">
19
+ Hover to explore every recorded emotion, then click the behavior ring to play it on Reachy Mini.
20
+ Each slice inherits a Plutchik-inspired color, the badge hue shows duration, and distance from the
21
+ heart of the wheel still encodes intensity.
22
+ </p>
23
+ </div>
24
+ <div class="hero-status">
25
+ <span class="chip" id="statusChip">Syncing…</span>
26
+ <span class="chip soft">Tooltips include the original dataset name + duration.</span>
27
+ </div>
28
+ </header>
29
+
30
+ <section class="layout-controls">
31
+ <button id="layoutModeBtn" class="control-btn">Manual layout</button>
32
+ <button id="layoutSaveBtn" class="control-btn" disabled>Save layout</button>
33
+ <button id="layoutResetBtn" class="control-btn secondary">Reset layout</button>
34
+ </section>
35
+
36
+ <section class="wheel-stage">
37
+ <div class="wheel-block">
38
+ <div class="wheel-header">
39
+ <h2>Emotion Wheel</h2>
40
+ </div>
41
+ <div class="wheel" id="emotionWheel">
42
+ <div class="ring outer"></div>
43
+ <div class="ring inner"></div>
44
+ <div class="wheel-center">
45
+ <p>emotions</p>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="wheel-block">
51
+ <div class="wheel-header">
52
+ <h2>Behavior Wheel</h2>
53
+ </div>
54
+ <div class="wheel" id="behaviorWheel">
55
+ <div class="ring outer"></div>
56
+ <div class="ring inner"></div>
57
+ <div class="wheel-center">
58
+ <p>behaviors</p>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </section>
63
+
64
+ <section class="coherence-panel" id="coherencePanel">
65
+ <strong>Sync diagnostics</strong>
66
+ <p>Loading…</p>
67
+ </section>
68
+ </main>
69
+
70
+ <div id="tooltip" class="tooltip" role="status" aria-live="polite"></div>
71
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
72
+ <script type="module" src="/static/main.js"></script>
73
+ </body>
74
+
75
+ </html>
build/lib/emotions/static/main.js ADDED
@@ -0,0 +1,855 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const wheelElements = {
2
+ emotion: document.getElementById("emotionWheel"),
3
+ behavior: document.getElementById("behaviorWheel"),
4
+ };
5
+ const wheelStage = document.querySelector(".wheel-stage");
6
+ const statusChip = document.getElementById("statusChip");
7
+ const tooltip = document.getElementById("tooltip");
8
+ const toast = document.getElementById("toast");
9
+ const coherencePanel = document.getElementById("coherencePanel");
10
+ const layoutModeBtn = document.getElementById("layoutModeBtn");
11
+ const layoutSaveBtn = document.getElementById("layoutSaveBtn");
12
+ const layoutResetBtn = document.getElementById("layoutResetBtn");
13
+
14
+ const WHEEL_PADDING = 60;
15
+ const MAX_LANES = 9;
16
+ const WHEEL_SETTINGS = {
17
+ default: { nodeSize: 84, baseInnerRatio: 0.22, movesPerLane: 5 },
18
+ emotion: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
19
+ behavior: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
20
+ };
21
+
22
+ const appState = {
23
+ wheels: {},
24
+ durationBuckets: [],
25
+ intensityScale: { min: 0, max: 10 },
26
+ coherence: {},
27
+ moveIndex: new Map(),
28
+ wheelLayout: {},
29
+ wheelGeometry: {},
30
+ positions: new Map(),
31
+ busy: true,
32
+ currentMove: null,
33
+ manualMode: false,
34
+ positionsDirty: false,
35
+ layoutOverrides: new Map(),
36
+ };
37
+ const manualState = {
38
+ drag: null,
39
+ };
40
+
41
+ const currentUrl = new URL(window.location.href);
42
+ if (!currentUrl.pathname.endsWith("/")) {
43
+ currentUrl.pathname += "/";
44
+ }
45
+ currentUrl.search = "";
46
+ currentUrl.hash = "";
47
+
48
+ const buildApiUrl = (path) => {
49
+ const clean = path.startsWith("/") ? path.slice(1) : path;
50
+ return new URL(clean, currentUrl).toString();
51
+ };
52
+
53
+ async function fetchJson(path, options = {}) {
54
+ const config = { method: options.method || "GET", headers: { ...(options.headers || {}) } };
55
+ if (options.body) {
56
+ config.body = JSON.stringify(options.body);
57
+ config.headers["Content-Type"] = "application/json";
58
+ }
59
+ const response = await fetch(buildApiUrl(path), config);
60
+ if (!response.ok) {
61
+ throw new Error(`HTTP ${response.status}`);
62
+ }
63
+ if (response.status === 204) {
64
+ return {};
65
+ }
66
+ return response.json();
67
+ }
68
+
69
+ function clamp(value, min, max) {
70
+ return Math.min(max, Math.max(min, value));
71
+ }
72
+
73
+ function hexToRgba(hex, alpha) {
74
+ if (!hex) {
75
+ return `rgba(255, 255, 255, ${alpha})`;
76
+ }
77
+ const normalized = hex.replace("#", "");
78
+ const bigint = parseInt(normalized.length === 3 ? normalized.repeat(2) : normalized, 16);
79
+ // eslint-disable-next-line no-bitwise
80
+ const r = (bigint >> 16) & 255;
81
+ // eslint-disable-next-line no-bitwise
82
+ const g = (bigint >> 8) & 255;
83
+ // eslint-disable-next-line no-bitwise
84
+ const b = bigint & 255;
85
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
86
+ }
87
+
88
+ function applySectorColor(element, color, { backgroundAlpha = 0.9, borderAlpha = 0.95 } = {}) {
89
+ const base = hexToRgba(color, backgroundAlpha);
90
+ const fade = hexToRgba(color, 0.45);
91
+ element.style.background = `linear-gradient(135deg, ${base}, ${fade})`;
92
+ element.style.borderColor = hexToRgba(color, borderAlpha);
93
+ element.style.boxShadow = `0 15px 35px ${hexToRgba(color, 0.35)}`;
94
+ }
95
+
96
+ function showToast(message) {
97
+ toast.textContent = message;
98
+ toast.classList.add("visible");
99
+ setTimeout(() => toast.classList.remove("visible"), 2600);
100
+ }
101
+
102
+ function updateStatusChip(label, playing) {
103
+ statusChip.textContent = label;
104
+ statusChip.classList.toggle("playing", playing);
105
+ }
106
+
107
+ function setBusyState(isBusy, moveName) {
108
+ appState.busy = isBusy;
109
+ appState.currentMove = moveName || null;
110
+ const label = isBusy && moveName ? `Playing ${formatMoveName(moveName)}` : "Idle";
111
+ updateStatusChip(label, isBusy);
112
+ document.querySelectorAll(".wheel-node").forEach((node) => {
113
+ const available = node.dataset.available !== "false";
114
+ node.classList.toggle("busy", isBusy);
115
+ const shouldDisable = !appState.manualMode && (!available || isBusy);
116
+ node.disabled = shouldDisable;
117
+ });
118
+ }
119
+
120
+ function formatMoveName(moveId) {
121
+ if (!moveId) return "";
122
+ const move = appState.moveIndex.get(moveId);
123
+ if (!move) return moveId;
124
+ return move.label;
125
+ }
126
+
127
+ function buildStructures(data) {
128
+ appState.wheels = data.wheels || {};
129
+ appState.durationBuckets = data.duration_buckets || [];
130
+ appState.intensityScale = data.intensity_scale || { min: 0, max: 10 };
131
+ appState.coherence = data.coherence || {};
132
+ appState.wheelLayout = data.wheel_layout || {};
133
+ appState.moveIndex = new Map();
134
+ appState.wheelGeometry = {};
135
+ appState.positions = new Map();
136
+ appState.positionsDirty = false;
137
+ appState.layoutOverrides = new Map();
138
+
139
+ Object.entries(appState.wheels).forEach(([, wheel]) => {
140
+ (wheel.sectors || []).forEach((sector) => {
141
+ (sector.moves || []).forEach((move) => {
142
+ appState.moveIndex.set(move.id, move);
143
+ });
144
+ });
145
+ });
146
+ }
147
+
148
+ function getWheelSetting(key) {
149
+ return { ...WHEEL_SETTINGS.default, ...(WHEEL_SETTINGS[key] || {}) };
150
+ }
151
+
152
+ function buildWheelGradient(sectors) {
153
+ if (!sectors?.length) {
154
+ return "rgba(255,255,255,0.08)";
155
+ }
156
+ const span = 360 / sectors.length;
157
+ const parts = sectors.map((sector, index) => {
158
+ const start = index * span;
159
+ const end = start + span;
160
+ return `${sector.color} ${start}deg ${end}deg`;
161
+ });
162
+ return `conic-gradient(${parts.join(",")})`;
163
+ }
164
+
165
+ function applyGradient(wheelEl, gradient) {
166
+ wheelEl.style.setProperty("--wheel-gradient", gradient);
167
+ }
168
+
169
+ function clearNodes(selector) {
170
+ document.querySelectorAll(selector).forEach((node) => node.remove());
171
+ }
172
+
173
+ function positionNode(node, angleDeg, radius) {
174
+ const radians = (angleDeg * Math.PI) / 180;
175
+ const x = Math.cos(radians) * radius;
176
+ const y = Math.sin(radians) * radius;
177
+ node.style.left = "50%";
178
+ node.style.top = "50%";
179
+ node.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
180
+ }
181
+
182
+ function polarToCartesian(angleDeg, radius) {
183
+ const radians = (angleDeg * Math.PI) / 180;
184
+ return {
185
+ x: Math.cos(radians) * radius,
186
+ y: Math.sin(radians) * radius,
187
+ };
188
+ }
189
+
190
+ function createNodeElement(move, wheelKey) {
191
+ const node = document.createElement("button");
192
+ node.className = `wheel-node ${wheelKey}-node`;
193
+ node.dataset.move = move.id;
194
+ node.dataset.sector = move.wheel_sector;
195
+ node.dataset.wheel = wheelKey;
196
+ node.dataset.available = move.available ? "true" : "false";
197
+ node.style.setProperty("--duration-color", move.duration_color);
198
+ applySectorColor(node, move.sector_color, { backgroundAlpha: 0.85, borderAlpha: 0.9 });
199
+ node.innerHTML = `
200
+ <span class="node-title">${move.label}</span>
201
+ <span class="node-meta">${move.duration_bucket.toUpperCase()}</span>
202
+ `;
203
+
204
+ if (!move.available) {
205
+ node.classList.add("unavailable");
206
+ node.disabled = true;
207
+ }
208
+
209
+ node.addEventListener("mouseenter", () => {
210
+ showTooltip(move);
211
+ highlightSector(move.wheel_sector, true);
212
+ });
213
+ node.addEventListener("mousemove", (event) => moveTooltip(event));
214
+ node.addEventListener("mouseleave", () => {
215
+ hideTooltip();
216
+ highlightSector(move.wheel_sector, false);
217
+ });
218
+ node.addEventListener("click", () => triggerPlay(move.id));
219
+ return node;
220
+ }
221
+
222
+ function renderWheel(wheelKey) {
223
+ const wheelData = appState.wheels[wheelKey];
224
+ const wheelEl = wheelElements[wheelKey];
225
+ if (!wheelData || !wheelEl) return;
226
+
227
+ clearNodes(`.${wheelKey}-node`);
228
+ clearNodes(`.${wheelKey}-sector-label-layer`);
229
+
230
+ const rect = wheelEl.getBoundingClientRect();
231
+ if (!rect.width) return;
232
+
233
+ const sectors = wheelData.sectors || [];
234
+ const gradient = buildWheelGradient(sectors);
235
+ applyGradient(wheelEl, gradient);
236
+
237
+ if (!sectors.length) {
238
+ return;
239
+ }
240
+
241
+ const maxDrawableRadius = rect.width / 2 - WHEEL_PADDING;
242
+ const layoutRadiusSetting = Number(wheelData.layout?.radius) || maxDrawableRadius;
243
+ const drawRadius = Math.min(layoutRadiusSetting, maxDrawableRadius);
244
+ const wheelSettings = getWheelSetting(wheelKey);
245
+ const span = 360 / sectors.length;
246
+ appState.wheelGeometry[wheelKey] = {
247
+ drawRadius,
248
+ element: wheelEl,
249
+ };
250
+ renderSectorLabels(wheelEl, sectors, drawRadius, span, wheelKey);
251
+
252
+ sectors.forEach((sector, index) => {
253
+ const moves = sector.moves || [];
254
+ const sectorCenter = index * span + span / 2;
255
+
256
+ const manualMoves = [];
257
+ const autoMoves = [];
258
+
259
+ moves.forEach((move) => {
260
+ const layout = resolveLayout(move);
261
+ if (layout) {
262
+ manualMoves.push({ move, layout });
263
+ } else {
264
+ autoMoves.push(move);
265
+ }
266
+ });
267
+
268
+ manualMoves.forEach(({ move, layout }) => {
269
+ const ratio = Number(layout.radius) || 0;
270
+ const radiusPx = ratio * drawRadius;
271
+ const angle =
272
+ typeof layout.angle === "number"
273
+ ? (Number(layout.angle) + 360) % 360
274
+ : sectorCenter;
275
+ const node = createNodeElement(move, wheelKey);
276
+ attachManualHandlers(node, move.id, wheelKey);
277
+ positionNode(node, angle, radiusPx);
278
+ recordPosition(move.id, wheelKey, angle, radiusPx);
279
+ wheelEl.appendChild(node);
280
+ });
281
+
282
+ const lanes = buildSectorLanes(autoMoves, drawRadius, span, wheelSettings);
283
+ lanes.forEach((lane) => {
284
+ const bucket = lane.items;
285
+ if (!bucket.length) return;
286
+ const availableSpread = span * 0.85;
287
+ const laneOffset =
288
+ lanes.length > 1 ? ((lane.index / (lanes.length - 1)) - 0.5) * (span * 0.08) : 0;
289
+
290
+ if (bucket.length === 1) {
291
+ const move = bucket[0];
292
+ const node = createNodeElement(move, wheelKey);
293
+ attachManualHandlers(node, move.id, wheelKey);
294
+ positionNode(node, sectorCenter + laneOffset, lane.radius);
295
+ recordPosition(move.id, wheelKey, sectorCenter + laneOffset, lane.radius);
296
+ wheelEl.appendChild(node);
297
+ return;
298
+ }
299
+
300
+ const nodeSize = wheelSettings.nodeSize;
301
+ const minAngleStep = (nodeSize / lane.radius) * (180 / Math.PI) * 1.15;
302
+ let step = Math.max(minAngleStep, availableSpread / (bucket.length - 1));
303
+ if (step * (bucket.length - 1) > availableSpread) {
304
+ step = availableSpread / (bucket.length - 1);
305
+ }
306
+ const totalSpan = step * (bucket.length - 1);
307
+ const start = sectorCenter - totalSpan / 2 + laneOffset;
308
+
309
+ bucket.forEach((move, idx) => {
310
+ const angle = start + step * idx;
311
+ const node = createNodeElement(move, wheelKey);
312
+ attachManualHandlers(node, move.id, wheelKey);
313
+ positionNode(node, angle, lane.radius);
314
+ recordPosition(move.id, wheelKey, angle, lane.radius);
315
+ wheelEl.appendChild(node);
316
+ });
317
+ });
318
+ });
319
+ }
320
+
321
+ function renderAllWheels() {
322
+ if (manualState.drag) {
323
+ finishDrag();
324
+ }
325
+ appState.positions = new Map();
326
+ renderWheel("emotion");
327
+ renderWheel("behavior");
328
+ updateWheelStageSpacing();
329
+ }
330
+
331
+ function updateWheelStageSpacing() {
332
+ if (!wheelStage) return;
333
+ const geometries = Object.entries(appState.wheelGeometry);
334
+ if (!geometries.length) return;
335
+ const radii = geometries.map(([, geo]) => geo.drawRadius || 0);
336
+ const maxRadius = Math.max(...radii);
337
+ const haloBuffer = WHEEL_PADDING * 1.4;
338
+ const emotionRadius = appState.wheelGeometry.emotion?.drawRadius ?? maxRadius;
339
+ const behaviorRadius = appState.wheelGeometry.behavior?.drawRadius ?? maxRadius;
340
+ const gapBase = emotionRadius + behaviorRadius + haloBuffer * 2;
341
+ const gap = Math.max(220, Math.round(gapBase));
342
+ const topPadding = Math.max(100, Math.round(maxRadius + haloBuffer));
343
+ const bottomPadding = Math.max(
344
+ 180,
345
+ Math.round((behaviorRadius || maxRadius) + haloBuffer * 1.6)
346
+ );
347
+ wheelStage.style.setProperty("--wheel-gap", `${gap}px`);
348
+ wheelStage.style.setProperty("--wheel-padding-top", `${topPadding}px`);
349
+ wheelStage.style.setProperty("--wheel-padding-bottom", `${bottomPadding}px`);
350
+ }
351
+
352
+ function renderSectorLabels(wheelEl, sectors, outerRadius, span, wheelKey) {
353
+ const existing = wheelEl.querySelector(`.sector-label-layer.${wheelKey}-sector-label-layer`);
354
+ if (existing) {
355
+ existing.remove();
356
+ }
357
+ if (!sectors.length) {
358
+ return;
359
+ }
360
+ const labelRadius = outerRadius + 200;
361
+ const layer = document.createElement("div");
362
+ layer.classList.add("sector-label-layer", `${wheelKey}-sector-label-layer`);
363
+ layer.setAttribute("aria-hidden", "true");
364
+ layer.style.position = "absolute";
365
+ layer.style.left = "50%";
366
+ layer.style.top = "50%";
367
+ layer.style.width = "0";
368
+ layer.style.height = "0";
369
+ layer.style.transform = "translate(-50%, -50%)";
370
+
371
+ sectors.forEach((sector, index) => {
372
+ const padding = 6;
373
+ const startAngle = index * span + padding;
374
+ const endAngle = (index + 1) * span - padding;
375
+ const centerAngle = (startAngle + endAngle) / 2;
376
+ const coords = polarToCartesian(centerAngle, labelRadius);
377
+ const label = document.createElement("span");
378
+ label.classList.add("sector-label");
379
+ label.textContent = sector.label;
380
+ label.style.transform = `translate(${coords.x}px, ${coords.y}px) translate(-50%, -50%)`;
381
+ const normalizedCenter = ((centerAngle % 360) + 360) % 360;
382
+ if (Math.cos((normalizedCenter * Math.PI) / 180) < 0) {
383
+ label.dataset.side = "left";
384
+ } else {
385
+ label.dataset.side = "right";
386
+ }
387
+ layer.appendChild(label);
388
+ });
389
+ wheelEl.appendChild(layer);
390
+ }
391
+
392
+ function resolveLayout(move) {
393
+ if (appState.layoutOverrides.has(move.id)) {
394
+ return appState.layoutOverrides.get(move.id);
395
+ }
396
+ return move.layout || null;
397
+ }
398
+
399
+ function buildSectorLanes(moves, outerRadius, span, settings) {
400
+ if (!moves.length) {
401
+ return [];
402
+ }
403
+ const nodeSize = settings.nodeSize;
404
+ const movesPerLane = settings.movesPerLane;
405
+ const baseInnerRadius = outerRadius * settings.baseInnerRatio;
406
+ const laneCount = clamp(Math.ceil(moves.length / movesPerLane), 1, MAX_LANES);
407
+ const radii = computeLaneRadii(laneCount, outerRadius, baseInnerRadius, nodeSize);
408
+
409
+ const capacities = radii.map((radius) => {
410
+ const arcLength = ((span * 0.9) * Math.PI) / 180 * radius;
411
+ return Math.max(2, Math.floor(arcLength / (nodeSize * 1.35)));
412
+ });
413
+ const lanes = radii.map((radius, index) => ({ radius, items: [], index }));
414
+ const sortedMoves = [...moves].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
415
+
416
+ sortedMoves.forEach((move) => {
417
+ const prefLane = clamp(
418
+ Math.round(move.normalized_intensity * (laneCount - 1)),
419
+ 0,
420
+ laneCount - 1
421
+ );
422
+ let laneIndex = prefLane;
423
+ if (lanes[laneIndex].items.length >= capacities[laneIndex]) {
424
+ let offset = 1;
425
+ while (offset < laneCount) {
426
+ const left = prefLane - offset;
427
+ const right = prefLane + offset;
428
+ if (left >= 0 && lanes[left].items.length < capacities[left]) {
429
+ laneIndex = left;
430
+ break;
431
+ }
432
+ if (right < laneCount && lanes[right].items.length < capacities[right]) {
433
+ laneIndex = right;
434
+ break;
435
+ }
436
+ offset += 1;
437
+ }
438
+ }
439
+ lanes[laneIndex].items.push(move);
440
+ });
441
+
442
+ return lanes;
443
+ }
444
+
445
+ function computeLaneRadii(laneCount, outerRadius, baseInnerRadius, nodeSize) {
446
+ if (laneCount <= 1) {
447
+ return [Math.max(outerRadius * 0.4, outerRadius - nodeSize * 1.2)];
448
+ }
449
+ const minGap = nodeSize * 1.05;
450
+ let innerRadius = baseInnerRadius;
451
+ let span = outerRadius - innerRadius;
452
+ const neededSpan = minGap * (laneCount - 1);
453
+ if (span < neededSpan) {
454
+ innerRadius = Math.max(outerRadius - neededSpan, 40);
455
+ span = outerRadius - innerRadius;
456
+ }
457
+ const gap = span / (laneCount - 1);
458
+ return Array.from({ length: laneCount }, (_, idx) => innerRadius + gap * idx);
459
+ }
460
+
461
+ function recordPosition(moveId, wheelKey, angle, radiusPx) {
462
+ const geometry = appState.wheelGeometry[wheelKey];
463
+ if (!geometry || !geometry.drawRadius) {
464
+ return;
465
+ }
466
+ const normalizedAngle = ((angle % 360) + 360) % 360;
467
+ const ratio = geometry.drawRadius ? radiusPx / geometry.drawRadius : 0;
468
+ appState.positions.set(moveId, { angle: normalizedAngle, radius: Math.max(0, ratio) });
469
+ }
470
+
471
+ function attachManualHandlers(node, moveId, wheelKey) {
472
+ node.addEventListener("pointerdown", (event) => {
473
+ if (!appState.manualMode) {
474
+ return;
475
+ }
476
+ event.preventDefault();
477
+ try {
478
+ node.setPointerCapture(event.pointerId);
479
+ } catch (error) {
480
+ // ignore
481
+ }
482
+ node.classList.add("dragging");
483
+ manualState.drag = {
484
+ node,
485
+ moveId,
486
+ wheelKey,
487
+ pointerId: event.pointerId,
488
+ };
489
+ });
490
+
491
+ node.addEventListener("pointermove", (event) => {
492
+ if (!manualState.drag || manualState.drag.pointerId !== event.pointerId) {
493
+ return;
494
+ }
495
+ updateDragPosition(event);
496
+ });
497
+
498
+ const endDrag = (event) => {
499
+ if (!manualState.drag || manualState.drag.pointerId !== event.pointerId) {
500
+ return;
501
+ }
502
+ finishDrag();
503
+ };
504
+
505
+ node.addEventListener("pointerup", endDrag);
506
+ node.addEventListener("pointercancel", endDrag);
507
+ }
508
+
509
+ function updateDragPosition(event) {
510
+ const drag = manualState.drag;
511
+ if (!drag) return;
512
+ const geometry = appState.wheelGeometry[drag.wheelKey];
513
+ if (!geometry) return;
514
+ const rect = geometry.element.getBoundingClientRect();
515
+ const centerX = rect.left + rect.width / 2;
516
+ const centerY = rect.top + rect.height / 2;
517
+ const dx = event.clientX - centerX;
518
+ const dy = event.clientY - centerY;
519
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
520
+ if (angle < 0) angle += 360;
521
+ const radiusPx = Math.sqrt(dx * dx + dy * dy);
522
+ positionNode(drag.node, angle, radiusPx);
523
+ appState.layoutOverrides.set(drag.moveId, {
524
+ angle,
525
+ radius: geometry.drawRadius ? radiusPx / geometry.drawRadius : 0,
526
+ });
527
+ recordPosition(drag.moveId, drag.wheelKey, angle, radiusPx);
528
+ setPositionsDirty(true);
529
+ }
530
+
531
+ function finishDrag() {
532
+ const drag = manualState.drag;
533
+ if (!drag) return;
534
+ drag.node.classList.remove("dragging");
535
+ try {
536
+ drag.node.releasePointerCapture(drag.pointerId);
537
+ } catch (error) {
538
+ // ignore
539
+ }
540
+ manualState.drag = null;
541
+ }
542
+
543
+ function moveTooltip(event) {
544
+ tooltip.style.left = `${event.clientX}px`;
545
+ tooltip.style.top = `${event.clientY - 20}px`;
546
+ }
547
+
548
+ function showTooltip(move) {
549
+ tooltip.classList.add("visible");
550
+ const duration = `${move.duration_seconds.toFixed(2)}s`;
551
+ const availability = move.available ? "" : "<span class=\"warning\">Missing in dataset</span>";
552
+ const hasRecorded = Number.isFinite(move.recorded_duration);
553
+ const datasetId =
554
+ typeof move.id === "string" ? move.id.toLowerCase() : String(move.id ?? "");
555
+ const recorded =
556
+ hasRecorded && Math.abs(move.recorded_duration - move.duration_seconds) > 0.01
557
+ ? `<br><small>Recorded duration: ${move.recorded_duration.toFixed(2)}s</small>`
558
+ : "";
559
+ tooltip.innerHTML = `
560
+ <strong>${move.label}</strong>
561
+ <small class="muted">dataset: ${datasetId}</small>
562
+ <span>${move.wheel_sector} • ${duration} • ${move.duration_bucket.toUpperCase()} ${availability}</span>
563
+ <p>${move.description || "No description yet."}</p>
564
+ <small>Intensity ${move.intensity.toFixed(1)}${recorded}</small>
565
+ `;
566
+ }
567
+
568
+ function hideTooltip() {
569
+ tooltip.classList.remove("visible");
570
+ }
571
+
572
+ function highlightSector(sector, enabled) {
573
+ if (!sector) return;
574
+ document.querySelectorAll(`.wheel-node[data-sector="${sector}"]`).forEach((node) => {
575
+ node.classList.toggle("highlight", enabled);
576
+ });
577
+ }
578
+
579
+ function summarizeList(items, formatter) {
580
+ if (!items?.length) {
581
+ return "";
582
+ }
583
+ const preview = items.slice(0, 3).map(formatter).join("");
584
+ const remaining = items.length - 3;
585
+ const more = remaining > 0 ? `<span class="more">+${remaining} more</span>` : "";
586
+ return `<div class="issue-list">${preview}${more}</div>`;
587
+ }
588
+
589
+ function updateCoherencePanel(coherence) {
590
+ if (!coherencePanel) return;
591
+ const hasIssues = Object.values(coherence || {}).some(
592
+ (value) => Array.isArray(value) && value.length
593
+ );
594
+ if (!hasIssues) {
595
+ coherencePanel.classList.add("clean");
596
+ coherencePanel.innerHTML = `
597
+ <strong>Sync diagnostics</strong>
598
+ <p>YAML and recorded dataset are in sync.</p>
599
+ `;
600
+ return;
601
+ }
602
+
603
+ coherencePanel.classList.remove("clean");
604
+ const blocks = [];
605
+
606
+ if (coherence.yaml_missing_in_dataset?.length) {
607
+ blocks.push(`
608
+ <article>
609
+ <h4>Missing in dataset (${coherence.yaml_missing_in_dataset.length})</h4>
610
+ ${summarizeList(
611
+ coherence.yaml_missing_in_dataset,
612
+ (item) =>
613
+ `<span>${item.move} <small>${item.sector}</small></span>`
614
+ )}
615
+ </article>
616
+ `);
617
+ }
618
+
619
+ if (coherence.dataset_missing_in_yaml?.length) {
620
+ blocks.push(`
621
+ <article>
622
+ <h4>Dataset only (${coherence.dataset_missing_in_yaml.length})</h4>
623
+ ${summarizeList(
624
+ coherence.dataset_missing_in_yaml,
625
+ (item) => `<span>${item}</span>`
626
+ )}
627
+ </article>
628
+ `);
629
+ }
630
+
631
+ if (coherence.duration_mismatches?.length) {
632
+ blocks.push(`
633
+ <article>
634
+ <h4>Duration mismatches (${coherence.duration_mismatches.length})</h4>
635
+ ${summarizeList(
636
+ coherence.duration_mismatches,
637
+ (item) =>
638
+ `<span>${item.move} <small>${Number.isFinite(item.dataset_duration) ? item.dataset_duration.toFixed(
639
+ 2
640
+ ) : "?"}s vs ${item.yaml_duration.toFixed(2)}s</small></span>`
641
+ )}
642
+ </article>
643
+ `);
644
+ }
645
+
646
+ if (coherence.sector_type_conflicts?.length) {
647
+ blocks.push(`
648
+ <article>
649
+ <h4>Sector conflicts</h4>
650
+ ${summarizeList(
651
+ coherence.sector_type_conflicts,
652
+ (item) => `<span>${item.sector} <small>${item.types.join(" & ")}</small></span>`
653
+ )}
654
+ </article>
655
+ `);
656
+ }
657
+
658
+ if (coherence.unknown_sectors?.length) {
659
+ blocks.push(`
660
+ <article>
661
+ <h4>Unknown sectors</h4>
662
+ ${summarizeList(
663
+ coherence.unknown_sectors,
664
+ (item) => `<span>${item.move} <small>${item.sector || "?"}</small></span>`
665
+ )}
666
+ </article>
667
+ `);
668
+ }
669
+
670
+ if (coherence.unknown_types?.length) {
671
+ blocks.push(`
672
+ <article>
673
+ <h4>Unknown types</h4>
674
+ ${summarizeList(
675
+ coherence.unknown_types,
676
+ (item) => `<span>${item.move} <small>${item.type}</small></span>`
677
+ )}
678
+ </article>
679
+ `);
680
+ }
681
+
682
+ if (coherence.intensity_out_of_range?.length) {
683
+ blocks.push(`
684
+ <article>
685
+ <h4>Intensity out of scale</h4>
686
+ ${summarizeList(
687
+ coherence.intensity_out_of_range,
688
+ (item) => `<span>${item.move} <small>${item.value}</small></span>`
689
+ )}
690
+ </article>
691
+ `);
692
+ }
693
+
694
+ if (blocks.length === 0) {
695
+ coherencePanel.innerHTML = `
696
+ <strong>Sync diagnostics</strong>
697
+ <p>YAML and recorded dataset are in sync.</p>
698
+ `;
699
+ return;
700
+ }
701
+
702
+ coherencePanel.innerHTML = `
703
+ <strong>Sync diagnostics</strong>
704
+ <div class="issue-grid">
705
+ ${blocks.join("")}
706
+ </div>
707
+ `;
708
+ }
709
+
710
+ function updateManualControls() {
711
+ document.body.classList.toggle("manual-mode", appState.manualMode);
712
+ if (layoutModeBtn) {
713
+ layoutModeBtn.textContent = appState.manualMode ? "Exit layout" : "Manual layout";
714
+ }
715
+ if (layoutSaveBtn) {
716
+ layoutSaveBtn.disabled = !appState.positionsDirty;
717
+ }
718
+ }
719
+
720
+ function setPositionsDirty(flag) {
721
+ appState.positionsDirty = flag;
722
+ updateManualControls();
723
+ }
724
+
725
+ function toggleManualMode(force) {
726
+ const nextState = typeof force === "boolean" ? force : !appState.manualMode;
727
+ if (!nextState && manualState.drag) {
728
+ finishDrag();
729
+ }
730
+ appState.manualMode = nextState;
731
+ updateManualControls();
732
+ setBusyState(appState.busy, appState.currentMove);
733
+ }
734
+
735
+ async function saveLayout() {
736
+ try {
737
+ const payload = {};
738
+ appState.positions.forEach((value, key) => {
739
+ payload[key] = { angle: value.angle, radius: value.radius };
740
+ });
741
+ if (!Object.keys(payload).length) {
742
+ showToast("No moves available to save.");
743
+ return;
744
+ }
745
+ await fetchJson("/api/layout/save", { method: "POST", body: { positions: payload } });
746
+ appState.layoutOverrides.clear();
747
+ setPositionsDirty(false);
748
+ showToast("Layout saved.");
749
+ await loadAndRender();
750
+ toggleManualMode(false);
751
+ } catch (error) {
752
+ console.error(error);
753
+ showToast("Unable to save layout.");
754
+ }
755
+ }
756
+
757
+ async function resetLayout() {
758
+ try {
759
+ await fetchJson("/api/layout/reset", { method: "POST" });
760
+ appState.layoutOverrides.clear();
761
+ setPositionsDirty(false);
762
+ showToast("Layout reset.");
763
+ await loadAndRender();
764
+ toggleManualMode(false);
765
+ } catch (error) {
766
+ console.error(error);
767
+ showToast("Unable to reset layout.");
768
+ }
769
+ }
770
+
771
+ async function loadAndRender() {
772
+ const data = await fetchJson("/api/emotions");
773
+ buildStructures(data);
774
+ renderAllWheels();
775
+ updateCoherencePanel(appState.coherence);
776
+ setPositionsDirty(false);
777
+ updateManualControls();
778
+ setBusyState(appState.busy, appState.currentMove);
779
+ }
780
+
781
+ async function triggerPlay(moveId) {
782
+ if (appState.manualMode) {
783
+ showToast("Exit layout mode to play moves.");
784
+ return;
785
+ }
786
+ const move = appState.moveIndex.get(moveId);
787
+ if (!move) {
788
+ showToast("Unknown move.");
789
+ return;
790
+ }
791
+ if (!move.available) {
792
+ showToast("This move is missing in the recorded dataset.");
793
+ return;
794
+ }
795
+ if (appState.busy) {
796
+ showToast("Reachy Mini is already playing a move.");
797
+ return;
798
+ }
799
+ try {
800
+ const res = await fetchJson("/api/play", { method: "POST", body: { move_name: moveId } });
801
+ if (res.accepted) {
802
+ setBusyState(true, res.queued_move);
803
+ showToast(`Playing ${formatMoveName(res.queued_move)}`);
804
+ } else {
805
+ showToast("Reachy Mini is still busy with the previous move.");
806
+ }
807
+ } catch (error) {
808
+ console.error(error);
809
+ showToast("Cannot reach the backend right now.");
810
+ }
811
+ }
812
+
813
+ async function pollState() {
814
+ try {
815
+ const data = await fetchJson("/api/state");
816
+ const busy = Boolean(data.is_playing || data.pending_move);
817
+ setBusyState(busy, data.current_move || data.pending_move);
818
+ } catch (error) {
819
+ console.warn("State polling failed", error);
820
+ } finally {
821
+ setTimeout(pollState, 1400);
822
+ }
823
+ }
824
+
825
+ async function init() {
826
+ try {
827
+ await loadAndRender();
828
+ setBusyState(false, null);
829
+ pollState();
830
+ } catch (error) {
831
+ console.error(error);
832
+ updateStatusChip("Failed to load data", false);
833
+ showToast("Unable to fetch the emotions list.");
834
+ }
835
+ }
836
+
837
+ init();
838
+
839
+ window.addEventListener("resize", () => {
840
+ if (!appState.wheels.emotion && !appState.wheels.behavior) {
841
+ return;
842
+ }
843
+ renderAllWheels();
844
+ setBusyState(appState.busy, appState.currentMove);
845
+ });
846
+
847
+ layoutModeBtn?.addEventListener("click", () => toggleManualMode());
848
+ layoutSaveBtn?.addEventListener("click", () => {
849
+ if (!appState.positionsDirty) {
850
+ showToast("Adjust positions before saving.");
851
+ return;
852
+ }
853
+ saveLayout();
854
+ });
855
+ layoutResetBtn?.addEventListener("click", () => resetLayout());
build/lib/emotions/static/style.css ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #070b16;
4
+ --surface: rgba(20, 24, 37, 0.85);
5
+ --surface-border: rgba(255, 255, 255, 0.08);
6
+ --text-strong: #f5f7ff;
7
+ --text-soft: rgba(245, 247, 255, 0.7);
8
+ --text-muted: rgba(245, 247, 255, 0.5);
9
+ --chip-bg: rgba(255, 255, 255, 0.08);
10
+ --wheel-size: min(760px, 95vw);
11
+ --emotion-node-size: 96px;
12
+ --behavior-node-size: 96px;
13
+ --wheel-gap: 96px;
14
+ --wheel-padding-top: 16px;
15
+ --wheel-padding-bottom: 64px;
16
+ }
17
+
18
+ *,
19
+ *::before,
20
+ *::after {
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ body {
25
+ margin: 0;
26
+ font-family: "Space Grotesk", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27
+ background:
28
+ radial-gradient(circle at 20% -10%, rgba(255, 255, 255, 0.08), transparent 55%),
29
+ radial-gradient(circle at 80% -20%, rgba(155, 132, 255, 0.18), transparent 45%),
30
+ conic-gradient(from 90deg at 50% 0%, rgba(255, 149, 128, 0.15), rgba(103, 126, 247, 0.15), rgba(255, 149, 128, 0.15)),
31
+ var(--bg);
32
+ min-height: 100vh;
33
+ color: var(--text-strong);
34
+ }
35
+
36
+ .background-halo {
37
+ position: fixed;
38
+ inset: 0;
39
+ background:
40
+ radial-gradient(circle at 50% 10%, rgba(255, 255, 255, 0.12), transparent 45%),
41
+ radial-gradient(circle at 80% 0%, rgba(255, 195, 113, 0.25), transparent 40%);
42
+ filter: blur(50px);
43
+ z-index: 0;
44
+ pointer-events: none;
45
+ }
46
+
47
+ .background-halo::after {
48
+ content: "";
49
+ position: absolute;
50
+ inset: -10%;
51
+ background: conic-gradient(from -90deg, rgba(252, 214, 112, 0.2), rgba(255, 111, 145, 0.15), rgba(98, 178, 255, 0.2), rgba(252, 214, 112, 0.2));
52
+ mix-blend-mode: screen;
53
+ opacity: 0.4;
54
+ }
55
+
56
+ .app-shell {
57
+ position: relative;
58
+ z-index: 1;
59
+ max-width: 1200px;
60
+ margin: 0 auto;
61
+ padding: 56px 24px 96px;
62
+ }
63
+
64
+ .hero {
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 24px;
68
+ margin-bottom: 40px;
69
+ }
70
+
71
+ .eyebrow {
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.3em;
74
+ font-size: 0.8rem;
75
+ color: var(--text-muted);
76
+ margin-bottom: 8px;
77
+ }
78
+
79
+ .hero h1 {
80
+ margin: 0;
81
+ font-size: clamp(2.5rem, 6vw, 3.5rem);
82
+ }
83
+
84
+ .subtitle {
85
+ margin: 0;
86
+ color: var(--text-soft);
87
+ max-width: 720px;
88
+ line-height: 1.6;
89
+ }
90
+
91
+ .hero-status {
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ gap: 12px;
95
+ }
96
+
97
+ .layout-controls {
98
+ display: flex;
99
+ flex-wrap: wrap;
100
+ gap: 12px;
101
+ margin-bottom: 32px;
102
+ }
103
+
104
+ .control-btn {
105
+ border: 1px solid rgba(255, 255, 255, 0.15);
106
+ background: rgba(0, 0, 0, 0.35);
107
+ color: var(--text-strong);
108
+ padding: 10px 18px;
109
+ border-radius: 999px;
110
+ font-size: 0.8rem;
111
+ text-transform: uppercase;
112
+ letter-spacing: 0.3em;
113
+ cursor: pointer;
114
+ transition: border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
115
+ }
116
+
117
+ .control-btn.secondary {
118
+ border-color: rgba(255, 255, 255, 0.08);
119
+ color: var(--text-muted);
120
+ }
121
+
122
+ .control-btn:disabled {
123
+ opacity: 0.4;
124
+ cursor: not-allowed;
125
+ }
126
+
127
+ .chip {
128
+ padding: 6px 18px;
129
+ border-radius: 999px;
130
+ background: var(--chip-bg);
131
+ color: var(--text-strong);
132
+ font-size: 0.9rem;
133
+ }
134
+
135
+ .chip.soft {
136
+ color: var(--text-soft);
137
+ }
138
+
139
+ .chip.playing {
140
+ background: rgba(255, 155, 102, 0.2);
141
+ }
142
+
143
+ .wheel-stage {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: var(--wheel-gap);
147
+ align-items: center;
148
+ padding: var(--wheel-padding-top) 0 var(--wheel-padding-bottom);
149
+ }
150
+
151
+ .wheel-block {
152
+ width: 100%;
153
+ max-width: 900px;
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 8px;
157
+ align-items: center;
158
+ text-align: center;
159
+ }
160
+
161
+ .wheel-header h2 {
162
+ margin: 0;
163
+ font-weight: 600;
164
+ letter-spacing: 0.35em;
165
+ text-transform: uppercase;
166
+ }
167
+
168
+ .wheel {
169
+ width: min(70vw, 520px);
170
+ height: min(70vw, 520px);
171
+ position: relative;
172
+ margin: 32px auto 0;
173
+ overflow: visible;
174
+ isolation: isolate;
175
+ }
176
+
177
+ .ring {
178
+ display: none;
179
+ }
180
+
181
+ .wheel::before {
182
+ content: "";
183
+ position: absolute;
184
+ inset: -55%;
185
+ border-radius: 50%;
186
+ background: var(--wheel-gradient, rgba(255, 255, 255, 0.08));
187
+ opacity: 0.7;
188
+ filter: blur(55px);
189
+ z-index: 0;
190
+ pointer-events: none;
191
+ mix-blend-mode: screen;
192
+ mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.95) 40%, rgba(0, 0, 0, 0.7) 65%, rgba(0, 0, 0, 0.4) 80%, transparent 95%);
193
+ }
194
+
195
+ .wheel::after {
196
+ content: "";
197
+ position: absolute;
198
+ inset: -45%;
199
+ border-radius: 50%;
200
+ background: radial-gradient(circle, rgba(7, 11, 22, 0.25), transparent 70%);
201
+ z-index: 0;
202
+ pointer-events: none;
203
+ }
204
+
205
+ .wheel-center {
206
+ position: absolute;
207
+ inset: 35%;
208
+ border-radius: 50%;
209
+ border: 1px solid rgba(255, 255, 255, 0.08);
210
+ background: rgba(7, 11, 22, 0.8);
211
+ display: flex;
212
+ flex-direction: column;
213
+ align-items: center;
214
+ justify-content: center;
215
+ text-transform: uppercase;
216
+ font-size: 0.85rem;
217
+ letter-spacing: 0.3em;
218
+ text-align: center;
219
+ padding: 16px;
220
+ color: var(--text-muted);
221
+ }
222
+
223
+ .wheel-center small {
224
+ letter-spacing: normal;
225
+ text-transform: none;
226
+ margin-top: 12px;
227
+ color: var(--text-soft);
228
+ }
229
+
230
+ .wheel::before,
231
+ .wheel::after,
232
+ .wheel-center,
233
+ .sector-label-layer .sector-label,
234
+ .wheel-node {
235
+ transition: opacity 0.4s ease, filter 0.4s ease;
236
+ }
237
+
238
+ .sector-label-layer {
239
+ pointer-events: none;
240
+ position: absolute;
241
+ left: 50%;
242
+ top: 50%;
243
+ width: 0;
244
+ height: 0;
245
+ transform: translate(-50%, -50%);
246
+ z-index: 2;
247
+ }
248
+
249
+
250
+ .sector-label-layer .sector-label {
251
+ position: absolute;
252
+ left: 0;
253
+ top: 0;
254
+ font-size: 1.2rem;
255
+ letter-spacing: 0.55em;
256
+ text-transform: uppercase;
257
+ color: rgba(255, 255, 255, 0.9);
258
+ font-weight: 600;
259
+ text-shadow: 0 6px 16px rgba(0, 0, 0, 0.85);
260
+ white-space: nowrap;
261
+ transform-origin: center;
262
+ text-align: center;
263
+ }
264
+
265
+ .sector-label-layer .sector-label[data-side="left"] {
266
+ text-align: right;
267
+ }
268
+
269
+ .wheel-node {
270
+ position: absolute;
271
+ border-radius: 50%;
272
+ border: 1px solid rgba(255, 255, 255, 0.18);
273
+ color: var(--text-strong);
274
+ padding: 16px 10px;
275
+ text-align: center;
276
+ display: flex;
277
+ flex-direction: column;
278
+ justify-content: center;
279
+ gap: 6px;
280
+ cursor: pointer;
281
+ transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
282
+ background: rgba(7, 11, 22, 0.85);
283
+ z-index: 1;
284
+ }
285
+
286
+ .manual-mode .wheel-node {
287
+ cursor: grab;
288
+ }
289
+
290
+ .manual-mode .wheel-node.dragging {
291
+ cursor: grabbing;
292
+ opacity: 0.85;
293
+ }
294
+
295
+ .wheel-node.emotion-node {
296
+ width: var(--emotion-node-size);
297
+ height: var(--emotion-node-size);
298
+ font-size: 0.95rem;
299
+ }
300
+
301
+ .wheel-node.behavior-node {
302
+ width: var(--behavior-node-size);
303
+ height: var(--behavior-node-size);
304
+ font-size: 0.85rem;
305
+ }
306
+
307
+ .wheel-node:hover {
308
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
309
+ }
310
+
311
+ .wheel-node.highlight {
312
+ box-shadow: 0 0 35px rgba(255, 255, 255, 0.25);
313
+ }
314
+
315
+ .wheel-node.busy {
316
+ opacity: 0.3;
317
+ pointer-events: none;
318
+ }
319
+
320
+ .wheel-node.unavailable {
321
+ opacity: 0.45;
322
+ border-style: dashed;
323
+ }
324
+
325
+ .node-title {
326
+ font-weight: 600;
327
+ text-transform: capitalize;
328
+ }
329
+
330
+ .node-meta {
331
+ font-size: 0.7rem;
332
+ letter-spacing: 0.05em;
333
+ color: var(--text-muted);
334
+ }
335
+
336
+ .node-meta:empty {
337
+ display: none;
338
+ }
339
+
340
+ .coherence-panel {
341
+ margin: 180px auto 0;
342
+ padding: 16px 0;
343
+ max-width: 720px;
344
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
345
+ color: var(--text-muted);
346
+ font-size: 0.85rem;
347
+ }
348
+
349
+ .coherence-panel strong {
350
+ display: block;
351
+ color: var(--text-soft);
352
+ margin-bottom: 6px;
353
+ text-transform: uppercase;
354
+ letter-spacing: 0.2em;
355
+ }
356
+
357
+ .coherence-panel.clean {
358
+ border-color: rgba(122, 201, 136, 0.35);
359
+ }
360
+
361
+ .issue-grid {
362
+ display: grid;
363
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
364
+ gap: 12px;
365
+ }
366
+
367
+ .issue-grid article {
368
+ padding: 12px;
369
+ border-radius: 16px;
370
+ background: rgba(255, 255, 255, 0.04);
371
+ border: 1px solid rgba(255, 255, 255, 0.05);
372
+ }
373
+
374
+ .issue-grid h4 {
375
+ margin: 0 0 6px;
376
+ font-size: 0.85rem;
377
+ color: var(--text-strong);
378
+ }
379
+
380
+ .issue-list {
381
+ display: flex;
382
+ flex-direction: column;
383
+ gap: 4px;
384
+ font-size: 0.85rem;
385
+ }
386
+
387
+ .issue-list span {
388
+ display: flex;
389
+ justify-content: space-between;
390
+ gap: 8px;
391
+ }
392
+
393
+ .issue-list small {
394
+ color: var(--text-muted);
395
+ }
396
+
397
+ .issue-list .more {
398
+ color: var(--text-muted);
399
+ font-style: italic;
400
+ }
401
+
402
+ .tooltip {
403
+ position: fixed;
404
+ pointer-events: none;
405
+ background: rgba(7, 11, 22, 0.95);
406
+ padding: 14px 20px;
407
+ border-radius: 14px;
408
+ border: 1px solid rgba(255, 255, 255, 0.08);
409
+ box-shadow: 0 20px 45px rgba(0, 0, 0, 0.4);
410
+ color: var(--text-strong);
411
+ font-size: 0.85rem;
412
+ max-width: 280px;
413
+ opacity: 0;
414
+ transform: translate(-50%, calc(-100% - 16px));
415
+ transition: opacity 0.2s ease;
416
+ z-index: 10;
417
+ }
418
+
419
+ .tooltip.visible {
420
+ opacity: 1;
421
+ }
422
+
423
+ .tooltip strong {
424
+ display: block;
425
+ margin-bottom: 4px;
426
+ }
427
+
428
+ .tooltip .muted {
429
+ display: block;
430
+ color: var(--text-muted);
431
+ font-size: 0.75rem;
432
+ letter-spacing: 0.08em;
433
+ text-transform: none;
434
+ }
435
+
436
+ .tooltip p {
437
+ margin: 0;
438
+ color: var(--text-soft);
439
+ line-height: 1.4;
440
+ }
441
+
442
+ .tooltip .warning {
443
+ color: #f9a826;
444
+ font-weight: 600;
445
+ margin-left: 6px;
446
+ }
447
+
448
+ .toast {
449
+ position: fixed;
450
+ bottom: 32px;
451
+ right: 32px;
452
+ background: rgba(7, 11, 22, 0.95);
453
+ padding: 16px 22px;
454
+ border-radius: 999px;
455
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
456
+ opacity: 0;
457
+ transform: translateY(20px);
458
+ transition: opacity 0.25s ease, transform 0.25s ease;
459
+ font-size: 0.95rem;
460
+ color: var(--text-soft);
461
+ }
462
+
463
+ .toast.visible {
464
+ opacity: 1;
465
+ transform: translateY(0);
466
+ }
467
+
468
+ @media (max-width: 640px) {
469
+ .card {
470
+ padding: 24px;
471
+ }
472
+
473
+ .hero-status {
474
+ flex-direction: column;
475
+ }
476
+
477
+ .wheel-center {
478
+ font-size: 0.75rem;
479
+ letter-spacing: 0.2em;
480
+ }
481
+ }
build/lib/ressources/emotions_overview.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Print the metadata of each recorded emotion from the Reachy Mini dataset."""
2
+
3
+ from reachy_mini.motion.recorded_move import RecordedMoves
4
+
5
+ EMOTIONS_DATASET = "pollen-robotics/reachy-mini-emotions-library"
6
+
7
+
8
+ def main() -> None:
9
+ """Display name, description, and duration for each recorded emotion."""
10
+ recorded_emotions = RecordedMoves(EMOTIONS_DATASET)
11
+
12
+ for move_name in sorted(recorded_emotions.list_moves()):
13
+ move = recorded_emotions.get(move_name)
14
+ duration = move.duration # seconds, already included in the dataset
15
+ print(f"{move_name}: {move.description} (duration: {duration:.2f}s)")
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
build/lib/ressources/recorded_moves_example.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Demonstrate and play all available moves from a dataset for Reachy Mini.
2
+
3
+ Run :
4
+
5
+ python3 recorded_moves_example.py -l [dance, emotions]
6
+ """
7
+
8
+ import argparse
9
+
10
+ from reachy_mini import ReachyMini
11
+ from reachy_mini.motion.recorded_move import RecordedMove, RecordedMoves
12
+
13
+
14
+ def main(dataset_path: str) -> None:
15
+ """Connect to Reachy and run the main demonstration loop."""
16
+ recorded_moves = RecordedMoves(dataset_path)
17
+
18
+ print("Connecting to Reachy Mini...")
19
+ with ReachyMini(use_sim=False, media_backend="no_media") as reachy:
20
+ print("Connection successful! Starting dance sequence...\n")
21
+ try:
22
+ while True:
23
+ for move_name in recorded_moves.list_moves():
24
+ move: RecordedMove = recorded_moves.get(move_name)
25
+ print(f"Playing move: {move_name}: {move.description}\n")
26
+ # print(f"params: {move.move_params}")
27
+ reachy.play_move(move, initial_goto_duration=1.0)
28
+
29
+ except KeyboardInterrupt:
30
+ print("\n Sequence interrupted by user. Shutting down.")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ parser = argparse.ArgumentParser(
35
+ description="Demonstrate and play all available dance moves for Reachy Mini."
36
+ )
37
+ parser.add_argument(
38
+ "-l", "--library", type=str, default="dance", choices=["dance", "emotions"]
39
+ )
40
+ args = parser.parse_args()
41
+
42
+ dataset_path = (
43
+ "pollen-robotics/reachy-mini-dances-library"
44
+ if args.library == "dance"
45
+ else "pollen-robotics/reachy-mini-emotions-library"
46
+ )
47
+ main(dataset_path)
index.html CHANGED
@@ -2,216 +2,273 @@
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 • Emotions Wheel</title>
8
- <link rel="stylesheet" href="style.css" />
9
  </head>
10
 
11
  <body>
12
- <div class="bg-gradient"></div>
13
- <header class="hero">
14
- <div class="hero-content">
15
- <p class="eyebrow">Reachy Mini Playground</p>
16
- <h1>Emotions Wheel</h1>
17
- <p>
18
- A dual wheel inspired by Plutchik’s flower. The outer ring shows emotion families, the inner ring stacks each
19
- recorded behavior. Hover for descriptions, click to play.
20
- </p>
21
- <div class="hero-tags">
22
- <span class="tag">Plutchik palette</span>
23
- <span class="tag">Duration crescents</span>
24
- <span class="tag">Stacked behaviors</span>
25
- </div>
26
- </div>
27
- <div class="hero-preview">
28
- <div class="wheel-ghost">
29
- <div class="spoke spoke-1"></div>
30
- <div class="spoke spoke-2"></div>
31
- <div class="spoke spoke-3"></div>
32
- <div class="spoke spoke-4"></div>
33
- <div class="inner-ghost">
34
- <div class="dot"></div>
35
- <div class="dot"></div>
36
- <div class="dot"></div>
37
- </div>
38
- </div>
39
- <p class="preview-caption">Color-coded families + duration crescents</p>
40
- </div>
41
- </header>
42
-
43
- <main class="content">
44
- <section class="feature-grid">
45
- <article class="feature-card">
46
- <h2>Outer wheel • emotions</h2>
47
- <p>One badge per emotion family. We strip digits, title-case the names, and show a subtle stack badge so you
48
- know how many behaviors hide behind each feeling.</p>
49
- </article>
50
- <article class="feature-card">
51
- <h2>Inner wheel behaviors</h2>
52
- <p>The numbered motions become small orbiting buttons. They inherit the same hue, display Roman numerals,
53
- and glow softly when you hover an associated emotion.</p>
54
- </article>
55
- <article class="feature-card">
56
- <h2>Duration crescents</h2>
57
- <p>Every behavior has a colored crescent: seafoam (short), amber (medium), rose (long). Tooltips show the
58
- exact duration in seconds.</p>
59
- </article>
60
- <article class="feature-card">
61
- <h2>Busy-guarded clicks</h2>
62
- <p>API clicks are ignored while Reachy is still moving, so you can’t overload the robot. The status chip
63
- updates as soon as the previous move ends.</p>
64
- </article>
65
- </section>
66
-
67
- <section class="plan-card">
68
- <div>
69
- <h2>How do you read the wheels?</h2>
70
- <p>Outer (emotions) shows the macro feeling, inner (behaviors) lists each recorded move.
71
- Radial distance encodes intensity, and colored crescents show short/medium/long durations.</p>
72
- </div>
73
- <div>
74
- <h2>Install on your Reachy Mini</h2>
75
- <p>Point to your dashboard URL and we’ll ask it to clone this Space.</p>
76
- <label for="dashboardUrl">Dashboard URL</label>
77
- <input type="url" id="dashboardUrl" value="http://localhost:8000" placeholder="http://reachy-mini:8000" />
78
- <button id="installBtn" class="install-btn">
79
- <span>📥</span>
80
- Install Emotions Wheel
81
- </button>
82
- <div id="installStatus" class="install-status"></div>
83
- </div>
84
- </section>
85
- </main>
86
-
87
- <footer class="footer">
88
- <p>Built with ❤️ by Pollen Robotics • Browse more apps on Hugging Face Spaces.</p>
89
- </footer>
90
-
91
- <script>
92
- function getCurrentSpaceUrl() {
93
- const currentUrl = window.location.href;
94
- return currentUrl.split('?')[0].replace(/\/$/, '');
95
- }
96
-
97
- function parseTomlProjectName(tomlContent) {
98
- try {
99
- const lines = tomlContent.split('\n');
100
- let inProjectSection = false;
101
-
102
- for (const line of lines) {
103
- const trimmedLine = line.trim();
104
-
105
- if (trimmedLine === '[project]') {
106
- inProjectSection = true;
107
- continue;
108
- }
109
-
110
- if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
111
- inProjectSection = false;
112
- continue;
113
- }
114
-
115
- if (inProjectSection && trimmedLine.startsWith('name')) {
116
- const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
117
- if (match) {
118
- return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
119
- }
120
- }
121
- }
122
-
123
- throw new Error('Project name not found in pyproject.toml');
124
- } catch (error) {
125
- console.error('Error parsing pyproject.toml:', error);
126
- return 'unknown-app';
127
- }
128
- }
129
-
130
- async function getAppNameFromCurrentSpace() {
131
- try {
132
- const response = await fetch('./pyproject.toml');
133
- if (!response.ok) {
134
- throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
135
- }
136
-
137
- const tomlContent = await response.text();
138
- return parseTomlProjectName(tomlContent);
139
- } catch (error) {
140
- console.error('Error fetching app name from current space:', error);
141
- const url = getCurrentSpaceUrl();
142
- const parts = url.split('/');
143
- const spaceName = parts[parts.length - 1];
144
- return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
145
- }
146
- }
147
-
148
- function showStatus(type, message) {
149
- const statusDiv = document.getElementById('installStatus');
150
- statusDiv.textContent = message;
151
- statusDiv.className = `install-status ${type}`;
152
- }
153
-
154
- async function installToReachy() {
155
- const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
156
- const installBtn = document.getElementById('installBtn');
157
-
158
- if (!dashboardUrl) {
159
- showStatus('error', 'Please enter your Reachy dashboard URL');
160
- return;
161
- }
162
-
163
- try {
164
- installBtn.disabled = true;
165
- installBtn.textContent = 'Installing…';
166
- showStatus('loading', 'Connecting to your Reachy dashboard…');
167
-
168
- const testResponse = await fetch(`${dashboardUrl}/api/status`, {
169
- method: 'GET',
170
- mode: 'cors',
171
- });
172
-
173
- if (!testResponse.ok) {
174
- throw new Error('Cannot connect to dashboard. Check the URL and make sure the dashboard is running.');
175
- }
176
-
177
- showStatus('loading', 'Reading app configuration…');
178
-
179
- const appName = await getAppNameFromCurrentSpace();
180
- const repoUrl = getCurrentSpaceUrl();
181
-
182
- showStatus('loading', `Starting installation of "${appName}"…`);
183
-
184
- const installResponse = await fetch(`${dashboardUrl}/api/install`, {
185
- method: 'POST',
186
- mode: 'cors',
187
- headers: {
188
- 'Content-Type': 'application/json',
189
- },
190
- body: JSON.stringify({
191
- url: repoUrl,
192
- name: appName
193
- })
194
- });
195
-
196
- const result = await installResponse.json();
197
-
198
- if (installResponse.ok) {
199
- showStatus('success', `Installation started for "${appName}". Check your dashboard for progress.`);
200
- } else {
201
- throw new Error(result.detail || 'Installation failed');
202
- }
203
-
204
- } catch (error) {
205
- console.error('Installation error:', error);
206
- showStatus('error', `❌ ${error.message}`);
207
- } finally {
208
- installBtn.disabled = false;
209
- installBtn.textContent = 'Install Emotions Wheel';
210
- }
211
- }
212
-
213
- document.getElementById('installBtn').addEventListener('click', installToReachy);
214
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </body>
216
 
217
  </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 • Emotions Wheel</title>
8
+ <link rel="stylesheet" href="style.css" />
9
  </head>
10
 
11
  <body>
12
+ <div class="bg-gradient"></div>
13
+ <main class="page">
14
+ <header class="hero">
15
+ <div class="hero-text">
16
+ <p class="eyebrow">Reachy Mini App</p>
17
+ <h1>Emotions Wheel</h1>
18
+ <p class="lede">
19
+ The landing page now mirrors the production UI: two synchronized rings (families outside, behaviors inside),
20
+ tooltips straight from the dataset metadata, manual layout controls, and coherence diagnostics so demos feel
21
+ the same as the robot runtime.
22
+ </p>
23
+ <div class="hero-meta">
24
+ <span class="chip">Linked wheels</span>
25
+ <span class="chip">Manual layout mode</span>
26
+ <span class="chip">Dataset-aware tooltips</span>
27
+ </div>
28
+ <div class="hero-cta">
29
+ <div>
30
+ <p class="label">Dataset</p>
31
+ <p class="value"><a href="https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library">pollen-robotics/reachy-mini-emotions-library</a></p>
32
+ </div>
33
+ <div>
34
+ <p class="label">Behaviors catalogued</p>
35
+ <p class="value">138 motions · 8 families</p>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <div class="hero-preview">
40
+ <div class="preview-wheel">
41
+ <div class="ring outer"></div>
42
+ <div class="ring inner"></div>
43
+ <div class="preview-label">
44
+ <span>emotion</span>
45
+ <span>behavior</span>
46
+ </div>
47
+ </div>
48
+ <div class="preview-controls">
49
+ <span class="chip accent">Manual layout</span>
50
+ <span class="chip soft">Save layout</span>
51
+ <span class="chip success">Reachy idle</span>
52
+ </div>
53
+ </div>
54
+ </header>
55
+
56
+ <section class="feature-grid">
57
+ <article class="feature-card">
58
+ <h2>Linked wheels + live tooltips</h2>
59
+ <p>
60
+ Hover the outer emotion ring to highlight its behaviors, then click a behavior badge to play the move on
61
+ Reachy Mini. Tooltips quote the dataset entry name, intensity, and duration so the wheel always stays in
62
+ sync with the YAML catalog.
63
+ </p>
64
+ </article>
65
+ <article class="feature-card">
66
+ <h2>Manual layout mode</h2>
67
+ <p>
68
+ Tapping <em>Manual layout</em> displays draggable handles for every move. Drop badges anywhere on the wheel,
69
+ press save, and the layout persists to disk so your museum demos, lessons, and videos stay consistent.
70
+ </p>
71
+ </article>
72
+ <article class="feature-card">
73
+ <h2>Duration & intensity cues</h2>
74
+ <p>
75
+ Distance from the center encodes emotion intensity, while the colored crescents show the duration bucket.
76
+ It is the same visual legend the runtime uses, so observers instantly grasp what Reachy is about to do.
77
+ </p>
78
+ </article>
79
+ <article class="feature-card">
80
+ <h2>Sync diagnostics panel</h2>
81
+ <p>
82
+ A small panel below the wheels compares the dataset, YAML spec, and recorded files. If something drifts,
83
+ the warning badge catches it before a visitor clicks a missing behavior.
84
+ </p>
85
+ </article>
86
+ </section>
87
+
88
+ <section class="details-grid">
89
+ <article class="details-card">
90
+ <h3>Color language</h3>
91
+ <p>Plutchik-inspired colors span the outer wheel. Labels stay inside tooltips so the UI remains minimal.</p>
92
+ <ul class="palette">
93
+ <li><span class="swatch" style="--swatch:#FF9F66"></span><div><strong>Joy</strong><small>warm coral</small></div></li>
94
+ <li><span class="swatch" style="--swatch:#5CC8D7"></span><div><strong>Trust</strong><small>lagoon teal</small></div></li>
95
+ <li><span class="swatch" style="--swatch:#4D7C8A"></span><div><strong>Fear</strong><small>noctilucent blue</small></div></li>
96
+ <li><span class="swatch" style="--swatch:#C084FC"></span><div><strong>Surprise</strong><small>lilac flare</small></div></li>
97
+ <li><span class="swatch" style="--swatch:#4F6DF5"></span><div><strong>Sadness</strong><small>dusk indigo</small></div></li>
98
+ <li><span class="swatch" style="--swatch:#58B368"></span><div><strong>Disgust</strong><small>mossy green</small></div></li>
99
+ <li><span class="swatch" style="--swatch:#E94F37"></span><div><strong>Anger</strong><small>ember red</small></div></li>
100
+ <li><span class="swatch" style="--swatch:#FFB347"></span><div><strong>Anticipation</strong><small>amber sunrise</small></div></li>
101
+ </ul>
102
+ </article>
103
+ <article class="details-card">
104
+ <h3>Duration crescents</h3>
105
+ <p>Badges keep the same three crescents the runtime overlays on every node.</p>
106
+ <ul class="duration-list">
107
+ <li><span class="swatch small" style="--swatch:#73E0A9"></span><div><strong>Seafoam</strong><small>short · ≤ 4 seconds</small></div></li>
108
+ <li><span class="swatch small" style="--swatch:#FFC857"></span><div><strong>Amber</strong><small>medium · 4–8 seconds</small></div></li>
109
+ <li><span class="swatch small" style="--swatch:#FF6B6B"></span><div><strong>Rose</strong><small>long · > 8 seconds</small></div></li>
110
+ </ul>
111
+ </article>
112
+ <article class="details-card">
113
+ <h3>Operator niceties</h3>
114
+ <ul class="feature-list">
115
+ <li>Status chip locks clicks while Reachy finishes a previous behavior.</li>
116
+ <li>Toast + tooltip copy include the raw move name so debugging stays easy.</li>
117
+ <li>Aside from layout saves, everything runs client-side, so the wheel feels instant.</li>
118
+ </ul>
119
+ </article>
120
+ </section>
121
+
122
+ <section class="install-card">
123
+ <div>
124
+ <h2>Install on your Reachy Mini</h2>
125
+ <p>
126
+ Point the installer at the dashboard that runs on your robot (default is
127
+ <code>http://localhost:8000</code>). We ping its health endpoint first, then call <code>/api/install</code>
128
+ with the Space URL so the dashboard clones this repository.
129
+ </p>
130
+ </div>
131
+ <div class="install-form">
132
+ <label for="dashboardUrl">Dashboard URL</label>
133
+ <input type="url" id="dashboardUrl" value="http://localhost:8000"
134
+ placeholder="http://reachy-mini:8000" />
135
+ <button id="installBtn" class="install-btn">
136
+ <span>📥</span>
137
+ Install Emotions Wheel
138
+ </button>
139
+ <div id="installStatus" class="install-status"></div>
140
+ </div>
141
+ </section>
142
+ </main>
143
+
144
+ <footer class="footer">
145
+ <p>Built with ❤️ by Pollen Robotics · Browse more Reachy Mini apps on Hugging Face Spaces.</p>
146
+ </footer>
147
+
148
+ <script>
149
+ function getCurrentSpaceUrl() {
150
+ const currentUrl = window.location.href;
151
+ return currentUrl.split('?')[0].replace(/\/$/, '');
152
+ }
153
+
154
+ function parseTomlProjectName(tomlContent) {
155
+ try {
156
+ const lines = tomlContent.split('\n');
157
+ let inProjectSection = false;
158
+
159
+ for (const line of lines) {
160
+ const trimmedLine = line.trim();
161
+
162
+ if (trimmedLine === '[project]') {
163
+ inProjectSection = true;
164
+ continue;
165
+ }
166
+
167
+ if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
168
+ inProjectSection = false;
169
+ continue;
170
+ }
171
+
172
+ if (inProjectSection && trimmedLine.startsWith('name')) {
173
+ const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
174
+ if (match) {
175
+ return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
176
+ }
177
+ }
178
+ }
179
+
180
+ throw new Error('Project name not found in pyproject.toml');
181
+ } catch (error) {
182
+ console.error('Error parsing pyproject.toml:', error);
183
+ return 'unknown-app';
184
+ }
185
+ }
186
+
187
+ async function getAppNameFromCurrentSpace() {
188
+ try {
189
+ const response = await fetch('./pyproject.toml');
190
+ if (!response.ok) {
191
+ throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
192
+ }
193
+
194
+ const tomlContent = await response.text();
195
+ return parseTomlProjectName(tomlContent);
196
+ } catch (error) {
197
+ console.error('Error fetching app name from current space:', error);
198
+ const url = getCurrentSpaceUrl();
199
+ const parts = url.split('/');
200
+ const spaceName = parts[parts.length - 1];
201
+ return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
202
+ }
203
+ }
204
+
205
+ function showStatus(type, message) {
206
+ const statusDiv = document.getElementById('installStatus');
207
+ statusDiv.textContent = message;
208
+ statusDiv.className = `install-status ${type}`;
209
+ }
210
+
211
+ async function installToReachy() {
212
+ const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
213
+ const installBtn = document.getElementById('installBtn');
214
+
215
+ if (!dashboardUrl) {
216
+ showStatus('error', 'Please enter your Reachy dashboard URL');
217
+ return;
218
+ }
219
+
220
+ try {
221
+ installBtn.disabled = true;
222
+ installBtn.textContent = 'Installing…';
223
+ showStatus('loading', 'Connecting to your Reachy dashboard…');
224
+
225
+ const testResponse = await fetch(`${dashboardUrl}/api/status`, {
226
+ method: 'GET',
227
+ mode: 'cors',
228
+ });
229
+
230
+ if (!testResponse.ok) {
231
+ throw new Error('Cannot connect to dashboard. Check the URL and make sure the dashboard is running.');
232
+ }
233
+
234
+ showStatus('loading', 'Reading app configuration…');
235
+
236
+ const appName = await getAppNameFromCurrentSpace();
237
+ const repoUrl = getCurrentSpaceUrl();
238
+
239
+ showStatus('loading', `Starting installation of "${appName}"…`);
240
+
241
+ const installResponse = await fetch(`${dashboardUrl}/api/install`, {
242
+ method: 'POST',
243
+ mode: 'cors',
244
+ headers: {
245
+ 'Content-Type': 'application/json',
246
+ },
247
+ body: JSON.stringify({
248
+ url: repoUrl,
249
+ name: appName
250
+ })
251
+ });
252
+
253
+ const result = await installResponse.json();
254
+
255
+ if (installResponse.ok) {
256
+ showStatus('success', `Installation started for "${appName}". Check your dashboard for progress.`);
257
+ } else {
258
+ throw new Error(result.detail || 'Installation failed');
259
+ }
260
+
261
+ } catch (error) {
262
+ console.error('Installation error:', error);
263
+ showStatus('error', `❌ ${error.message}`);
264
+ } finally {
265
+ installBtn.disabled = false;
266
+ installBtn.textContent = 'Install Emotions Wheel';
267
+ }
268
+ }
269
+
270
+ document.getElementById('installBtn').addEventListener('click', installToReachy);
271
+ </script>
272
  </body>
273
 
274
  </html>
style.css CHANGED
@@ -1,273 +1,409 @@
1
  :root {
2
- --bg: #04070f;
3
- --surface: rgba(17, 21, 33, 0.85);
4
- --border: rgba(255, 255, 255, 0.08);
5
- --text: #f7f8ff;
6
- --text-soft: rgba(247, 248, 255, 0.7);
7
- --accent: #ff9f66;
8
- --accent-soft: #ffc857;
9
- font-family: "Space Grotesk", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
10
- color: var(--text);
11
- background-color: var(--bg);
 
 
 
12
  }
13
 
14
  *,
15
  *::before,
16
  *::after {
17
- box-sizing: border-box;
18
  }
19
 
20
  body {
21
- margin: 0;
22
- min-height: 100vh;
23
- background:
24
- radial-gradient(circle at 20% 0%, rgba(255, 255, 255, 0.08), transparent 60%),
25
- radial-gradient(circle at 80% 0%, rgba(192, 132, 252, 0.2), transparent 45%),
26
- var(--bg);
27
- color: var(--text);
28
  }
29
 
30
  .bg-gradient {
31
- position: fixed;
32
- inset: 0;
33
- background:
34
- radial-gradient(circle at 50% -10%, rgba(255, 255, 255, 0.1), transparent 55%),
35
- radial-gradient(circle at 90% 10%, rgba(255, 159, 102, 0.2), transparent 40%);
36
- filter: blur(40px);
37
- z-index: 0;
38
- pointer-events: none;
39
  }
40
 
41
- .hero {
42
- position: relative;
43
- z-index: 1;
44
- max-width: 1200px;
45
- margin: 0 auto;
46
- padding: 80px 24px 32px;
47
- display: grid;
48
- grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
49
- gap: 32px;
50
- align-items: center;
51
  }
52
 
53
- .hero-content h1 {
54
- font-size: clamp(2.8rem, 6vw, 4rem);
55
- margin-bottom: 12px;
56
  }
57
 
58
- .hero-content p {
59
- color: var(--text-soft);
60
- line-height: 1.6;
 
 
 
 
 
 
61
  }
62
 
63
- .eyebrow {
64
- text-transform: uppercase;
65
- letter-spacing: 0.3em;
66
- color: rgba(255, 255, 255, 0.5);
67
- font-size: 0.85rem;
68
  }
69
 
70
- .hero-tags {
71
- margin-top: 16px;
72
- display: flex;
73
- flex-wrap: wrap;
74
- gap: 10px;
75
  }
76
 
77
- .tag {
78
- padding: 6px 16px;
79
- border-radius: 999px;
80
- background: rgba(255, 255, 255, 0.08);
81
- font-size: 0.9rem;
82
  }
83
 
84
- .hero-preview {
85
- background: var(--surface);
86
- border: 1px solid var(--border);
87
- border-radius: 32px;
88
- padding: 24px;
89
- box-shadow: 0 30px 70px rgba(0, 0, 0, 0.4);
90
- text-align: center;
91
  }
92
 
93
- .wheel-ghost {
94
- width: 280px;
95
- height: 280px;
96
- margin: 0 auto 16px;
97
- border-radius: 50%;
98
- border: 1px dashed rgba(255, 255, 255, 0.2);
99
- position: relative;
100
  }
101
 
102
- .spoke {
103
- position: absolute;
104
- top: 50%;
105
- left: 50%;
106
- width: 70%;
107
- height: 2px;
108
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
109
- transform-origin: left center;
 
110
  }
111
 
112
- .spoke-2 {
113
- transform: rotate(45deg);
 
114
  }
115
 
116
- .spoke-3 {
117
- transform: rotate(90deg);
 
118
  }
119
 
120
- .spoke-4 {
121
- transform: rotate(135deg);
 
122
  }
123
 
124
- .inner-ghost {
125
- position: absolute;
126
- inset: 25%;
127
- border-radius: 50%;
128
- border: 1px dashed rgba(255, 255, 255, 0.2);
129
- display: flex;
130
- justify-content: center;
131
- align-items: center;
132
- gap: 12px;
133
  }
134
 
135
- .inner-ghost .dot {
136
- width: 16px;
137
- height: 16px;
138
- border-radius: 50%;
139
- background: rgba(255, 255, 255, 0.4);
 
140
  }
141
 
142
- .preview-caption {
143
- color: var(--text-soft);
144
- margin: 0;
145
- font-size: 0.9rem;
146
  }
147
 
148
- .content {
149
- position: relative;
150
- z-index: 1;
151
- max-width: 1200px;
152
- margin: 0 auto;
153
- padding: 0 24px 96px;
154
- display: flex;
155
- flex-direction: column;
156
- gap: 32px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
 
159
  .feature-grid {
160
- display: grid;
161
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
162
- gap: 16px;
163
  }
164
 
165
  .feature-card {
166
- background: var(--surface);
167
- border: 1px solid var(--border);
168
- border-radius: 24px;
169
- padding: 24px;
170
- box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
 
171
  }
172
 
173
  .feature-card h2 {
174
- margin-top: 0;
175
- font-size: 1.2rem;
176
  }
177
 
178
  .feature-card p {
179
- color: var(--text-soft);
 
 
 
 
 
 
180
  }
181
 
182
- .plan-card {
183
- display: grid;
184
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
185
- gap: 24px;
186
- background: var(--surface);
187
- border: 1px solid var(--border);
188
- border-radius: 32px;
189
- padding: 32px;
190
- box-shadow: 0 30px 70px rgba(0, 0, 0, 0.4);
191
  }
192
 
193
- .plan-card h2 {
194
- margin: 0 0 12px;
 
 
 
 
195
  }
196
 
197
- .plan-card p {
198
- color: var(--text-soft);
199
- line-height: 1.6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  }
201
 
202
  label {
203
- display: flex;
204
- flex-direction: column;
205
- font-size: 0.9rem;
206
- color: var(--text-soft);
207
- margin-bottom: 8px;
208
  }
209
 
210
  input[type="url"] {
211
- width: 100%;
212
- padding: 12px 16px;
213
- border-radius: 16px;
214
- border: 1px solid rgba(255, 255, 255, 0.2);
215
- background: rgba(0, 0, 0, 0.3);
216
- color: var(--text);
217
- font-size: 1rem;
218
- margin-bottom: 12px;
219
  }
220
 
221
  .install-btn {
222
- display: inline-flex;
223
- align-items: center;
224
- gap: 8px;
225
- padding: 12px 20px;
226
- border: none;
227
- border-radius: 999px;
228
- background: linear-gradient(135deg, var(--accent), var(--accent-soft));
229
- color: #1a1329;
230
- font-weight: 600;
231
- cursor: pointer;
232
  }
233
 
234
  .install-btn:disabled {
235
- opacity: 0.6;
236
- cursor: not-allowed;
237
  }
238
 
239
  .install-status {
240
- margin-top: 10px;
241
- min-height: 20px;
242
- font-size: 0.9rem;
243
- color: var(--text-soft);
244
  }
245
 
246
  .install-status.success {
247
- color: #7bd389;
248
  }
249
 
250
  .install-status.error {
251
- color: #ff6b6b;
252
  }
253
 
254
  .install-status.loading {
255
- color: var(--accent-soft);
256
  }
257
 
258
  .footer {
259
- text-align: center;
260
- color: var(--text-soft);
261
- padding: 24px;
262
- font-size: 0.9rem;
263
  }
264
 
265
  @media (max-width: 600px) {
266
- .hero {
267
- padding-top: 64px;
268
- }
 
 
 
 
269
 
270
- .plan-card {
271
- padding: 24px;
272
- }
273
  }
 
1
  :root {
2
+ --bg: #030711;
3
+ --surface: rgba(9, 14, 29, 0.88);
4
+ --surface-soft: rgba(15, 23, 42, 0.72);
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --text: #f4f6ff;
7
+ --muted: rgba(244, 246, 255, 0.72);
8
+ --accent: #c084fc;
9
+ --accent-2: #64d6ff;
10
+ --success: #7bd389;
11
+ --danger: #ff6b6b;
12
+ font-family: "Space Grotesk", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13
+ color: var(--text);
14
+ background-color: var(--bg);
15
  }
16
 
17
  *,
18
  *::before,
19
  *::after {
20
+ box-sizing: border-box;
21
  }
22
 
23
  body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ background: radial-gradient(circle at 20% -10%, rgba(255, 255, 255, 0.12), transparent 50%),
27
+ radial-gradient(circle at 80% 0%, rgba(192, 132, 252, 0.2), transparent 45%),
28
+ radial-gradient(circle at 0% 60%, rgba(100, 214, 255, 0.12), transparent 40%),
29
+ var(--bg);
30
+ color: var(--text);
31
  }
32
 
33
  .bg-gradient {
34
+ position: fixed;
35
+ inset: 0;
36
+ background: radial-gradient(circle at 20% 20%, rgba(192, 132, 252, 0.25), transparent 50%),
37
+ radial-gradient(circle at 80% 30%, rgba(94, 234, 212, 0.12), transparent 45%);
38
+ filter: blur(80px);
39
+ opacity: 0.7;
40
+ pointer-events: none;
41
+ z-index: 0;
42
  }
43
 
44
+ a {
45
+ color: var(--accent-2);
 
 
 
 
 
 
 
 
46
  }
47
 
48
+ a:hover {
49
+ color: var(--accent);
 
50
  }
51
 
52
+ .page {
53
+ position: relative;
54
+ z-index: 1;
55
+ max-width: 1200px;
56
+ margin: 0 auto;
57
+ padding: 96px 24px 120px;
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 40px;
61
  }
62
 
63
+ .hero {
64
+ display: grid;
65
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
66
+ gap: 48px;
67
+ align-items: center;
68
  }
69
 
70
+ .hero-text h1 {
71
+ font-size: clamp(2.8rem, 5vw, 3.8rem);
72
+ margin: 0.1em 0 0.4em;
 
 
73
  }
74
 
75
+ .hero .lede {
76
+ color: var(--muted);
77
+ line-height: 1.65;
78
+ max-width: 52ch;
 
79
  }
80
 
81
+ .eyebrow {
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.3em;
84
+ font-size: 0.8rem;
85
+ color: rgba(255, 255, 255, 0.6);
 
 
86
  }
87
 
88
+ .hero-meta {
89
+ margin: 20px 0 0;
90
+ display: flex;
91
+ flex-wrap: wrap;
92
+ gap: 10px;
 
 
93
  }
94
 
95
+ .chip {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ gap: 6px;
99
+ padding: 8px 16px;
100
+ border-radius: 999px;
101
+ font-size: 0.85rem;
102
+ background: rgba(255, 255, 255, 0.08);
103
+ color: var(--text);
104
  }
105
 
106
+ .chip.accent {
107
+ background: rgba(192, 132, 252, 0.25);
108
+ color: #f5eeff;
109
  }
110
 
111
+ .chip.soft {
112
+ background: rgba(255, 255, 255, 0.1);
113
+ color: var(--muted);
114
  }
115
 
116
+ .chip.success {
117
+ background: rgba(123, 211, 137, 0.2);
118
+ color: var(--success);
119
  }
120
 
121
+ .hero-cta {
122
+ margin-top: 24px;
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
125
+ gap: 16px;
 
 
 
 
126
  }
127
 
128
+ .hero-cta .label {
129
+ text-transform: uppercase;
130
+ letter-spacing: 0.2em;
131
+ font-size: 0.72rem;
132
+ color: var(--muted);
133
+ margin: 0 0 4px;
134
  }
135
 
136
+ .hero-cta .value {
137
+ margin: 0;
138
+ font-size: 1.05rem;
139
+ color: var(--text);
140
  }
141
 
142
+ .hero-cta .value a {
143
+ text-decoration: none;
144
+ }
145
+
146
+ .hero-preview {
147
+ background: var(--surface);
148
+ border: 1px solid var(--border);
149
+ border-radius: 32px;
150
+ padding: 32px 32px 26px;
151
+ text-align: center;
152
+ box-shadow: 0 40px 80px rgba(2, 6, 23, 0.6);
153
+ }
154
+
155
+ .preview-wheel {
156
+ position: relative;
157
+ width: min(320px, 70vw);
158
+ height: min(320px, 70vw);
159
+ margin: 0 auto 24px;
160
+ border-radius: 50%;
161
+ background: conic-gradient(#ff9f66 0 45deg, #5cc8d7 45deg 90deg, #4d7c8a 90deg 135deg,
162
+ #c084fc 135deg 180deg, #4f6df5 180deg 225deg, #58b368 225deg 270deg,
163
+ #e94f37 270deg 315deg, #ffb347 315deg 360deg);
164
+ filter: drop-shadow(0 15px 25px rgba(0, 0, 0, 0.5));
165
+ }
166
+
167
+ .preview-wheel .ring {
168
+ position: absolute;
169
+ inset: 14px;
170
+ border-radius: 50%;
171
+ border: 2px solid rgba(255, 255, 255, 0.35);
172
+ }
173
+
174
+ .preview-wheel .ring.inner {
175
+ inset: 25%;
176
+ border-color: rgba(244, 246, 255, 0.55);
177
+ }
178
+
179
+ .preview-label {
180
+ position: absolute;
181
+ inset: 0;
182
+ display: flex;
183
+ flex-direction: column;
184
+ align-items: center;
185
+ justify-content: center;
186
+ gap: 6px;
187
+ font-size: 0.9rem;
188
+ text-transform: uppercase;
189
+ letter-spacing: 0.3em;
190
+ color: rgba(255, 255, 255, 0.85);
191
+ }
192
+
193
+ .preview-controls {
194
+ display: flex;
195
+ justify-content: center;
196
+ flex-wrap: wrap;
197
+ gap: 10px;
198
  }
199
 
200
  .feature-grid {
201
+ display: grid;
202
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
203
+ gap: 18px;
204
  }
205
 
206
  .feature-card {
207
+ background: var(--surface);
208
+ border: 1px solid var(--border);
209
+ border-radius: 24px;
210
+ padding: 28px;
211
+ line-height: 1.6;
212
+ box-shadow: 0 25px 60px rgba(3, 7, 18, 0.55);
213
  }
214
 
215
  .feature-card h2 {
216
+ margin: 0 0 0.8em;
217
+ font-size: 1.3rem;
218
  }
219
 
220
  .feature-card p {
221
+ margin: 0;
222
+ color: var(--muted);
223
+ }
224
+
225
+ .feature-card em {
226
+ color: var(--accent);
227
+ font-style: normal;
228
  }
229
 
230
+ .details-grid {
231
+ display: grid;
232
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
233
+ gap: 18px;
 
 
 
 
 
234
  }
235
 
236
+ .details-card {
237
+ background: var(--surface-soft);
238
+ border: 1px solid var(--border);
239
+ border-radius: 24px;
240
+ padding: 28px;
241
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
242
  }
243
 
244
+ .details-card h3 {
245
+ margin: 0 0 0.6em;
246
+ font-size: 1.1rem;
247
+ }
248
+
249
+ .details-card p {
250
+ color: var(--muted);
251
+ margin-top: 0;
252
+ }
253
+
254
+ .palette,
255
+ .duration-list,
256
+ .feature-list {
257
+ list-style: none;
258
+ padding: 0;
259
+ margin: 16px 0 0;
260
+ display: flex;
261
+ flex-direction: column;
262
+ gap: 12px;
263
+ }
264
+
265
+ .palette li,
266
+ .duration-list li {
267
+ display: grid;
268
+ grid-template-columns: auto 1fr;
269
+ gap: 12px;
270
+ align-items: center;
271
+ }
272
+
273
+ .palette strong,
274
+ .duration-list strong {
275
+ display: block;
276
+ font-size: 0.95rem;
277
+ }
278
+
279
+ .palette small,
280
+ .duration-list small {
281
+ color: var(--muted);
282
+ }
283
+
284
+ .swatch {
285
+ width: 26px;
286
+ height: 26px;
287
+ border-radius: 8px;
288
+ background: var(--swatch, #fff);
289
+ border: 1px solid rgba(255, 255, 255, 0.15);
290
+ }
291
+
292
+ .swatch.small {
293
+ border-radius: 999px;
294
+ }
295
+
296
+ .feature-list li {
297
+ position: relative;
298
+ padding-left: 18px;
299
+ color: var(--muted);
300
+ }
301
+
302
+ .feature-list li::before {
303
+ content: "•";
304
+ position: absolute;
305
+ left: 0;
306
+ color: var(--accent-2);
307
+ }
308
+
309
+ .install-card {
310
+ display: grid;
311
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
312
+ gap: 32px;
313
+ background: var(--surface);
314
+ border: 1px solid var(--border);
315
+ border-radius: 32px;
316
+ padding: 36px;
317
+ box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
318
+ }
319
+
320
+ .install-card h2 {
321
+ margin-top: 0;
322
+ }
323
+
324
+ .install-card p {
325
+ color: var(--muted);
326
+ line-height: 1.6;
327
+ }
328
+
329
+ .install-form {
330
+ display: flex;
331
+ flex-direction: column;
332
  }
333
 
334
  label {
335
+ text-transform: uppercase;
336
+ letter-spacing: 0.2em;
337
+ font-size: 0.72rem;
338
+ color: var(--muted);
339
+ margin-bottom: 6px;
340
  }
341
 
342
  input[type="url"] {
343
+ width: 100%;
344
+ padding: 14px 18px;
345
+ border-radius: 18px;
346
+ border: 1px solid rgba(255, 255, 255, 0.2);
347
+ background: rgba(0, 0, 0, 0.35);
348
+ color: var(--text);
349
+ font-size: 1rem;
350
+ margin-bottom: 16px;
351
  }
352
 
353
  .install-btn {
354
+ display: inline-flex;
355
+ align-items: center;
356
+ gap: 10px;
357
+ padding: 14px 22px;
358
+ border-radius: 999px;
359
+ border: none;
360
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
361
+ color: #0b0415;
362
+ font-weight: 600;
363
+ cursor: pointer;
364
  }
365
 
366
  .install-btn:disabled {
367
+ opacity: 0.6;
368
+ cursor: not-allowed;
369
  }
370
 
371
  .install-status {
372
+ min-height: 22px;
373
+ margin-top: 10px;
374
+ font-size: 0.9rem;
375
+ color: var(--muted);
376
  }
377
 
378
  .install-status.success {
379
+ color: var(--success);
380
  }
381
 
382
  .install-status.error {
383
+ color: var(--danger);
384
  }
385
 
386
  .install-status.loading {
387
+ color: var(--accent-2);
388
  }
389
 
390
  .footer {
391
+ text-align: center;
392
+ color: var(--muted);
393
+ font-size: 0.9rem;
394
+ padding: 32px 24px 64px;
395
  }
396
 
397
  @media (max-width: 600px) {
398
+ .page {
399
+ padding-top: 72px;
400
+ }
401
+
402
+ .hero-preview {
403
+ padding: 24px;
404
+ }
405
 
406
+ .install-card {
407
+ padding: 28px;
408
+ }
409
  }