RemiFabre commited on
Commit
d4694ba
·
1 Parent(s): 03317af

First iteration, still very WIP

Browse files
Files changed (7) hide show
  1. README.md +39 -2
  2. emotions/main.py +309 -50
  3. emotions/static/index.html +53 -13
  4. emotions/static/main.js +270 -33
  5. emotions/static/style.css +385 -15
  6. index.html +89 -107
  7. style.css +175 -313
README.md CHANGED
@@ -5,8 +5,45 @@ colorFrom: red
5
  colorTo: blue
6
  sdk: static
7
  pinned: false
8
- short_description: Write your description here
9
  tags:
10
  - reachy_mini
11
  - reachy_mini_python_app
12
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  colorTo: blue
6
  sdk: static
7
  pinned: false
8
+ short_description: Trigger Reachy Mini's recorded emotions with a Plutchik-inspired wheel
9
  tags:
10
  - reachy_mini
11
  - reachy_mini_python_app
12
+ ---
13
+
14
+ The Emotions Wheel is a playful control board that lets you browse every recorded feeling for Reachy Mini.
15
+ Each emotion family appears once on the outer wheel while the prerecorded behaviors (the numbered variations)
16
+ rest inside the inner wheel. Click one of the behaviors to make Reachy re-enact the motion and its matching
17
+ sound, and hover any slice to get the original dataset name, duration, and description.
18
+
19
+ ## Color families
20
+
21
+ To echo Plutchik's flower, we tinted each family but kept the labels out of the GUI. Here's the mapping:
22
+
23
+ - Joy — warm coral (`#FF9F66`)
24
+ - Trust — lagoon teal (`#5CC8D7`)
25
+ - Fear — noctilucent blue (`#4D7C8A`)
26
+ - Surprise — lilac flare (`#C084FC`)
27
+ - Sadness — dusk indigo (`#4F6DF5`)
28
+ - Disgust — mossy green (`#58B368`)
29
+ - Anger — ember red (`#E94F37`)
30
+ - Anticipation — amber sunrise (`#FFB347`)
31
+
32
+ ## Duration badges
33
+
34
+ Behaviors carry a tiny colored crescent to hint how long the move lasts:
35
+
36
+ - Seafoam (`#73E0A9`) — short (≤ 4 s)
37
+ - Amber (`#FFC857`) — medium (between 4 s and 8 s)
38
+ - Rose (`#FF6B6B`) — long (> 8 s)
39
+
40
+ ## Missing emotions we'd love to record
41
+
42
+ - hopeful
43
+ - jealous
44
+ - playful
45
+ - apologetic
46
+ - embarrassed
47
+ - nostalgic
48
+ - proud_shy_combo
49
+ - confident
emotions/main.py CHANGED
@@ -1,69 +1,328 @@
 
 
 
1
  import threading
2
- from reachy_mini import ReachyMini, ReachyMiniApp
3
- from reachy_mini.utils import create_head_pose
4
- import numpy as np
5
  import time
6
- from pydantic import BaseModel
 
 
7
 
 
 
8
 
9
- class Emotions(ReachyMiniApp):
10
- # Optional: URL to a custom configuration page for the app
11
- # eg. "http://localhost:8042"
12
- custom_app_url: str | None = "http://0.0.0.0:8042"
13
- # Optional: specify a media backend ("gstreamer", "default", etc.)
14
- request_media_backend: str | None = None
15
 
16
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
17
- t0 = time.time()
18
 
19
- antennas_enabled = True
20
- sound_play_requested = False
 
 
 
21
 
22
- # You can ignore this part if you don't want to add settings to your app. If you set custom_app_url to None, you have to remove this part as well.
23
- # === vvv ===
24
- class AntennaState(BaseModel):
25
- enabled: bool
 
 
 
 
 
 
 
26
 
27
- @self.settings_app.post("/antennas")
28
- def update_antennas_state(state: AntennaState):
29
- nonlocal antennas_enabled
30
- antennas_enabled = state.enabled
31
- return {"antennas_enabled": antennas_enabled}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- @self.settings_app.post("/play_sound")
34
- def request_sound_play():
35
- nonlocal sound_play_requested
36
- sound_play_requested = True
37
-
38
- # === ^^^ ===
 
 
 
 
39
 
40
- # Main control loop
41
- while not stop_event.is_set():
42
- t = time.time() - t0
43
 
44
- yaw_deg = 30.0 * np.sin(2.0 * np.pi * 0.2 * t)
45
- head_pose = create_head_pose(yaw=yaw_deg, degrees=True)
 
 
 
 
 
 
 
 
46
 
47
- if antennas_enabled:
48
- amp_deg = 25.0
49
- a = amp_deg * np.sin(2.0 * np.pi * 0.5 * t)
50
- antennas_deg = np.array([a, -a])
51
- else:
52
- antennas_deg = np.array([0.0, 0.0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- if sound_play_requested:
55
- print("Playing sound...")
56
- reachy_mini.media.play_sound("wake_up.wav")
57
- sound_play_requested = False
58
 
59
- antennas_rad = np.deg2rad(antennas_deg)
 
60
 
61
- reachy_mini.set_target(
62
- head=head_pose,
63
- antennas=antennas_rad,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
- time.sleep(0.02)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  if __name__ == "__main__":
@@ -71,4 +330,4 @@ if __name__ == "__main__":
71
  try:
72
  app.wrapped_run()
73
  except KeyboardInterrupt:
74
- app.stop()
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
  import threading
 
 
 
5
  import time
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Any
9
 
10
+ from fastapi import HTTPException
11
+ from pydantic import BaseModel, Field
12
 
13
+ from reachy_mini import ReachyMini, ReachyMiniApp
14
+ from reachy_mini.motion.recorded_move import RecordedMoves
 
 
 
 
15
 
16
+ EMOTIONS_DATASET = "pollen-robotics/reachy-mini-emotions-library"
 
17
 
18
+ DURATION_BANDS: dict[str, dict[str, Any]] = {
19
+ "short": {"label": "Short (<4s)", "max": 4.0, "color": "#73E0A9"},
20
+ "medium": {"label": "Medium (4‑8s)", "max": 8.0, "color": "#FFC857"},
21
+ "long": {"label": "Long (>8s)", "max": None, "color": "#FF6B6B"},
22
+ }
23
 
24
+ CATEGORY_STYLES: dict[str, dict[str, str]] = {
25
+ "joy": {"label": "Joy", "color": "#FF9F66", "accent": "#FFE0C7"},
26
+ "trust": {"label": "Trust", "color": "#5CC8D7", "accent": "#B6F2FF"},
27
+ "fear": {"label": "Fear", "color": "#4D7C8A", "accent": "#A8D5E3"},
28
+ "surprise": {"label": "Surprise", "color": "#C084FC", "accent": "#EBD4FF"},
29
+ "sadness": {"label": "Sadness", "color": "#4F6DF5", "accent": "#B6C5FF"},
30
+ "disgust": {"label": "Disgust", "color": "#58B368", "accent": "#C6F3C8"},
31
+ "anger": {"label": "Anger", "color": "#E94F37", "accent": "#F7B6A4"},
32
+ "anticipation": {"label": "Anticipation", "color": "#FFB347", "accent": "#FFE0A3"},
33
+ "neutral": {"label": "Neutral", "color": "#8E9AAF", "accent": "#E0E6F4"},
34
+ }
35
 
36
+ # Manual assignment of each emotion family to a Plutchik-inspired category
37
+ CATEGORY_ASSIGNMENTS: dict[str, str] = {
38
+ "amazed": "surprise",
39
+ "anxiety": "fear",
40
+ "attentive": "trust",
41
+ "boredom": "sadness",
42
+ "calming": "trust",
43
+ "cheerful": "joy",
44
+ "come": "trust",
45
+ "confused": "surprise",
46
+ "contempt": "disgust",
47
+ "curious": "anticipation",
48
+ "dance": "joy",
49
+ "disgusted": "disgust",
50
+ "displeased": "anger",
51
+ "downcast": "sadness",
52
+ "dying": "sadness",
53
+ "electric": "surprise",
54
+ "enthusiastic": "joy",
55
+ "exhausted": "sadness",
56
+ "fear": "fear",
57
+ "frustrated": "anger",
58
+ "furious": "anger",
59
+ "go_away": "disgust",
60
+ "grateful": "joy",
61
+ "helpful": "trust",
62
+ "impatient": "anticipation",
63
+ "incomprehensible": "surprise",
64
+ "indifferent": "disgust",
65
+ "inquiring": "anticipation",
66
+ "irritated": "anger",
67
+ "laughing": "joy",
68
+ "lonely": "sadness",
69
+ "lost": "sadness",
70
+ "loving": "joy",
71
+ "no": "anger",
72
+ "no_excited": "anger",
73
+ "no_sad": "sadness",
74
+ "oops": "surprise",
75
+ "proud": "joy",
76
+ "rage": "anger",
77
+ "relief": "trust",
78
+ "reprimand": "anger",
79
+ "resigned": "sadness",
80
+ "sad": "sadness",
81
+ "scared": "fear",
82
+ "serenity": "trust",
83
+ "shy": "fear",
84
+ "sleep": "sadness",
85
+ "success": "joy",
86
+ "surprised": "surprise",
87
+ "thoughtful": "anticipation",
88
+ "tired": "sadness",
89
+ "uncertain": "fear",
90
+ "uncomfortable": "fear",
91
+ "understanding": "trust",
92
+ "welcoming": "trust",
93
+ "yes": "joy",
94
+ "yes_sad": "sadness",
95
+ }
96
 
97
+ MISSING_EMOTIONS = [
98
+ "hopeful",
99
+ "jealous",
100
+ "playful",
101
+ "apologetic",
102
+ "embarrassed",
103
+ "nostalgic",
104
+ "proud_shy_combo",
105
+ "confident",
106
+ ]
107
 
 
 
 
108
 
109
+ @dataclass
110
+ class EmotionVariant:
111
+ move_name: str
112
+ base_name: str
113
+ description: str
114
+ duration: float
115
+ ordinal: int
116
+ duration_band: str
117
+ display_label: str
118
+ roman_label: str
119
 
120
+ @property
121
+ def duration_color(self) -> str:
122
+ return DURATION_BANDS[self.duration_band]["color"]
123
+
124
+
125
+ @dataclass
126
+ class EmotionGroup:
127
+ base_name: str
128
+ category: str
129
+ display_label: str
130
+ variants: list[EmotionVariant] = field(default_factory=list)
131
+
132
+ @property
133
+ def average_duration(self) -> float:
134
+ if not self.variants:
135
+ return 0.0
136
+ return sum(v.duration for v in self.variants) / len(self.variants)
137
+
138
+ def to_dict(self) -> dict[str, Any]:
139
+ return {
140
+ "base_name": self.base_name,
141
+ "label": self.display_label,
142
+ "category": self.category,
143
+ "average_duration": self.average_duration,
144
+ "stack_size": len(self.variants),
145
+ "variants": [
146
+ {
147
+ "move_name": v.move_name,
148
+ "description": v.description,
149
+ "duration": v.duration,
150
+ "duration_band": v.duration_band,
151
+ "duration_color": v.duration_color,
152
+ "display_label": v.display_label,
153
+ "roman_label": v.roman_label,
154
+ }
155
+ for v in self.variants
156
+ ],
157
+ }
158
+
159
+
160
+ class PlayEmotionPayload(BaseModel):
161
+ move_name: str = Field(..., description="Exact key of the recorded move to play")
162
+
163
+
164
+ def duration_band(duration: float) -> str:
165
+ if duration <= DURATION_BANDS["short"]["max"]:
166
+ return "short"
167
+ if duration <= DURATION_BANDS["medium"]["max"]:
168
+ return "medium"
169
+ return "long"
170
+
171
+
172
+ def roman_from_int(value: int) -> str:
173
+ symbols = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V"}
174
+ return symbols.get(value, str(value))
175
+
176
+
177
+ def strip_suffix(move_name: str) -> tuple[str, int]:
178
+ match = re.match(r"([a-z_]+?)(\\d+)?$", move_name)
179
+ if not match:
180
+ return move_name, 1
181
+ base = match.group(1)
182
+ ordinal = int(match.group(2)) if match.group(2) else 1
183
+ return base, ordinal
184
 
 
 
 
 
185
 
186
+ def display_label(slug: str) -> str:
187
+ return slug.replace("_", " ").title()
188
 
189
+
190
+ def build_catalog(recorded_emotions: RecordedMoves) -> tuple[list[EmotionGroup], dict[str, EmotionVariant]]:
191
+ groups: dict[str, EmotionGroup] = {}
192
+ variant_lookup: dict[str, EmotionVariant] = {}
193
+
194
+ for move_name in sorted(recorded_emotions.list_moves()):
195
+ move = recorded_emotions.get(move_name)
196
+ base, ordinal = strip_suffix(move_name)
197
+ category = CATEGORY_ASSIGNMENTS.get(base, "neutral")
198
+ label = display_label(base)
199
+ band = duration_band(move.duration)
200
+ variant = EmotionVariant(
201
+ move_name=move_name,
202
+ base_name=base,
203
+ description=move.description or "",
204
+ duration=move.duration,
205
+ ordinal=ordinal,
206
+ duration_band=band,
207
+ display_label=label,
208
+ roman_label=roman_from_int(ordinal),
209
+ )
210
+ variant_lookup[move_name] = variant
211
+
212
+ if base not in groups:
213
+ groups[base] = EmotionGroup(
214
+ base_name=base,
215
+ category=category,
216
+ display_label=label,
217
+ variants=[variant],
218
  )
219
+ else:
220
+ groups[base].variants.append(variant)
221
+
222
+ ordered_groups = sorted(groups.values(), key=lambda g: (g.category, g.display_label))
223
+ for group in ordered_groups:
224
+ group.variants.sort(key=lambda v: v.ordinal)
225
+ return ordered_groups, variant_lookup
226
+
227
+
228
+ class Emotions(ReachyMiniApp):
229
+ """Reachy Mini app that plays recorded emotions with a wheel-inspired UI."""
230
+
231
+ custom_app_url: str | None = "http://0.0.0.0:8042"
232
+ request_media_backend: str | None = None
233
 
234
+ def __init__(self) -> None:
235
+ super().__init__()
236
+ self._recorded_emotions = RecordedMoves(EMOTIONS_DATASET)
237
+ self._groups, self._variant_map = build_catalog(self._recorded_emotions)
238
+ self._lock = threading.Lock()
239
+ self._pending_move: str | None = None
240
+ self._current_move: str | None = None
241
+ self._last_completed: float | None = None
242
+ self._register_routes()
243
+
244
+ def _register_routes(self) -> None:
245
+ @self.settings_app.get("/api/emotions")
246
+ def list_emotions() -> dict[str, Any]:
247
+ category_blocks: list[dict[str, Any]] = []
248
+ for category_id, style in CATEGORY_STYLES.items():
249
+ members = [
250
+ group.to_dict()
251
+ for group in self._groups
252
+ if group.category == category_id
253
+ ]
254
+ if not members:
255
+ continue
256
+ category_blocks.append(
257
+ {
258
+ "id": category_id,
259
+ "color": style["color"],
260
+ "accent": style["accent"],
261
+ "emotions": members,
262
+ }
263
+ )
264
+
265
+ return {
266
+ "categories": category_blocks,
267
+ "duration_bands": [
268
+ {"id": key, **value} for key, value in DURATION_BANDS.items()
269
+ ],
270
+ "missing_emotions": MISSING_EMOTIONS,
271
+ }
272
+
273
+ @self.settings_app.get("/api/state")
274
+ def get_state() -> dict[str, Any]:
275
+ with self._lock:
276
+ current = self._current_move
277
+ pending = self._pending_move
278
+ last_completed = (
279
+ datetime.fromtimestamp(self._last_completed).isoformat()
280
+ if self._last_completed
281
+ else None
282
+ )
283
+
284
+ return {
285
+ "is_playing": current is not None,
286
+ "current_move": current,
287
+ "pending_move": pending,
288
+ "last_completed_at": last_completed,
289
+ }
290
+
291
+ @self.settings_app.post("/api/play")
292
+ def play_emotion(payload: PlayEmotionPayload) -> dict[str, Any]:
293
+ move_name = payload.move_name
294
+ variant = self._variant_map.get(move_name)
295
+ if variant is None:
296
+ raise HTTPException(status_code=404, detail=f"Unknown move {move_name}")
297
+
298
+ with self._lock:
299
+ if self._current_move or self._pending_move:
300
+ return {"accepted": False, "reason": "busy"}
301
+ self._pending_move = move_name
302
+ return {
303
+ "accepted": True,
304
+ "queued_move": move_name,
305
+ "display_label": variant.display_label,
306
+ "roman_label": variant.roman_label,
307
+ }
308
+
309
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
310
+ while not stop_event.is_set():
311
+ move_to_play: str | None = None
312
+ with self._lock:
313
+ if self._pending_move:
314
+ move_to_play = self._pending_move
315
+ self._pending_move = None
316
+ self._current_move = move_to_play
317
+
318
+ if move_to_play:
319
+ move = self._recorded_emotions.get(move_to_play)
320
+ reachy_mini.play_move(move, initial_goto_duration=0.7)
321
+ with self._lock:
322
+ self._current_move = None
323
+ self._last_completed = time.time()
324
+ else:
325
+ stop_event.wait(0.01)
326
 
327
 
328
  if __name__ == "__main__":
 
330
  try:
331
  app.wrapped_run()
332
  except KeyboardInterrupt:
333
+ app.stop()
emotions/static/index.html CHANGED
@@ -3,25 +3,65 @@
3
 
4
  <head>
5
  <meta charset="UTF-8">
6
- <title>Reachy Mini example app template</title>
7
- <meta name="viewport" content="width=device-width, initial-scale=1">
8
  <link rel="stylesheet" href="/static/style.css">
9
  </head>
10
 
11
  <body>
12
- <h1>Reachy Mini – Control Panel</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- <div id="controls">
15
- <label style="display:flex; align-items:center; gap:8px;">
16
- <input type="checkbox" id="antenna-checkbox" checked>
17
- Antennas
18
- </label>
 
 
 
 
 
 
 
 
 
 
19
 
20
- <button id="sound-btn">Play Sound</button>
21
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- <div id="status">Antennas status: running</div>
24
- <script src="/static/main.js"></script>
 
25
  </body>
26
 
27
- </html>
 
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, while the duration badge tells you how long the move
21
+ lasts.
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-grid">
31
+ <article class="card wheel-card">
32
+ <div class="wheel" id="emotionWheel">
33
+ <div class="ring outer" id="outerRing"></div>
34
+ <div class="ring inner" id="innerRing"></div>
35
+ <div class="wheel-center">
36
+ <p>behavior wheel</p>
37
+ <small>Clicks are ignored while a move is still running.</small>
38
+ </div>
39
+ </div>
40
+ <div class="legend-row">
41
+ <div class="legend" id="durationLegend"></div>
42
+ <div class="legend-note">Duration colors live on the little crescents around each behavior.</div>
43
+ </div>
44
+ </article>
45
 
46
+ <article class="card info-card">
47
+ <div class="info-header">
48
+ <h2>What’s not yet recorded?</h2>
49
+ <p>Ideas we’d love to capture next time the microphones are on.</p>
50
+ </div>
51
+ <ul class="missing-list" id="missingList"></ul>
52
+ <div class="info-footer">
53
+ <p>
54
+ Want to contribute another feeling? Log it in the plan or ping the team with timing ideas.
55
+ We can even add behaviors that branch from the same emotion stack.
56
+ </p>
57
+ </div>
58
+ </article>
59
+ </section>
60
+ </main>
61
 
62
+ <div id="tooltip" class="tooltip" role="status" aria-live="polite"></div>
63
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
64
+ <script type="module" src="/static/main.js"></script>
65
  </body>
66
 
67
+ </html>
emotions/static/main.js CHANGED
@@ -1,47 +1,284 @@
1
- let antennasEnabled = true;
 
 
 
 
 
 
 
2
 
3
- async function updateAntennasState(enabled) {
4
- try {
5
- const resp = await fetch("/antennas", {
6
- method: "POST",
7
- headers: { "Content-Type": "application/json" },
8
- body: JSON.stringify({ enabled }),
9
- });
10
- const data = await resp.json();
11
- antennasEnabled = data.antennas_enabled;
12
- updateUI();
13
- } catch (e) {
14
- document.getElementById("status").textContent = "Backend error";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
 
16
  }
17
 
18
- async function playSound() {
19
- try {
20
- await fetch("/play_sound", { method: "POST" });
21
- } catch (e) {
22
- console.error("Error triggering sound:", e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }
 
 
 
 
 
24
  }
25
 
26
- function updateUI() {
27
- const checkbox = document.getElementById("antenna-checkbox");
28
- const status = document.getElementById("status");
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- checkbox.checked = antennasEnabled;
 
 
 
 
 
 
 
31
 
32
- if (antennasEnabled) {
33
- status.textContent = "Antennas status: running";
34
- } else {
35
- status.textContent = "Antennas status: stopped";
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
 
39
- document.getElementById("antenna-checkbox").addEventListener("change", (e) => {
40
- updateAntennasState(e.target.checked);
41
- });
 
 
 
 
42
 
43
- document.getElementById("sound-btn").addEventListener("click", () => {
44
- playSound();
45
- });
 
 
46
 
47
- updateUI();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const outerRing = document.getElementById("outerRing");
2
+ const innerRing = document.getElementById("innerRing");
3
+ const durationLegend = document.getElementById("durationLegend");
4
+ const missingList = document.getElementById("missingList");
5
+ const statusChip = document.getElementById("statusChip");
6
+ const tooltip = document.getElementById("tooltip");
7
+ const toast = document.getElementById("toast");
8
+ const wheel = document.getElementById("emotionWheel");
9
 
10
+ const appState = {
11
+ categories: [],
12
+ durationBands: [],
13
+ missing: [],
14
+ variantLookup: new Map(),
15
+ busy: true,
16
+ currentMove: null,
17
+ };
18
+
19
+ const currentUrl = new URL(window.location.href);
20
+ if (!currentUrl.pathname.endsWith("/")) {
21
+ currentUrl.pathname += "/";
22
+ }
23
+ currentUrl.search = "";
24
+ currentUrl.hash = "";
25
+ const apiUrl = (path) => {
26
+ const clean = path.startsWith("/") ? path.slice(1) : path;
27
+ return new URL(clean, currentUrl).toString();
28
+ };
29
+
30
+ async function fetchJson(path, options = {}) {
31
+ const config = { method: options.method || "GET", headers: { ...(options.headers || {}) } };
32
+ if (options.body) {
33
+ config.body = JSON.stringify(options.body);
34
+ config.headers["Content-Type"] = "application/json";
35
+ }
36
+ const response = await fetch(apiUrl(path), config);
37
+ if (!response.ok) {
38
+ throw new Error(`HTTP ${response.status}`);
39
+ }
40
+ if (response.status === 204) {
41
+ return {};
42
  }
43
+ return response.json();
44
  }
45
 
46
+ function showToast(message) {
47
+ toast.textContent = message;
48
+ toast.classList.add("visible");
49
+ setTimeout(() => toast.classList.remove("visible"), 2800);
50
+ }
51
+
52
+ function updateStatusChip(label, playing) {
53
+ statusChip.textContent = label;
54
+ statusChip.classList.toggle("playing", playing);
55
+ }
56
+
57
+ function setBusyState(isBusy, moveName) {
58
+ appState.busy = isBusy;
59
+ appState.currentMove = moveName || null;
60
+ const label = isBusy ? `Playing ${formatMoveName(moveName)}` : "Idle";
61
+ updateStatusChip(label, isBusy);
62
+ document.querySelectorAll(".behavior-node").forEach((node) => {
63
+ node.classList.toggle("busy", isBusy);
64
+ });
65
+ }
66
+
67
+ function formatMoveName(moveName) {
68
+ if (!moveName) {
69
+ return "";
70
  }
71
+ const variant = appState.variantLookup.get(moveName);
72
+ if (!variant) {
73
+ return moveName;
74
+ }
75
+ return `${variant.display_label} ${variant.roman_label}`.trim();
76
  }
77
 
78
+ function renderLegend(bands) {
79
+ durationLegend.innerHTML = "";
80
+ bands.forEach((band) => {
81
+ const el = document.createElement("span");
82
+ el.className = "legend-item";
83
+ const swatch = document.createElement("span");
84
+ swatch.className = "legend-swatch";
85
+ swatch.style.background = band.color;
86
+ const label = document.createElement("span");
87
+ label.textContent = band.label;
88
+ el.appendChild(swatch);
89
+ el.appendChild(label);
90
+ durationLegend.appendChild(el);
91
+ });
92
+ }
93
 
94
+ function renderMissing(list) {
95
+ missingList.innerHTML = "";
96
+ list.forEach((name) => {
97
+ const li = document.createElement("li");
98
+ li.textContent = name.replace(/_/g, " ");
99
+ missingList.appendChild(li);
100
+ });
101
+ }
102
 
103
+ function buildVariantLookup(categories) {
104
+ appState.variantLookup.clear();
105
+ categories.forEach((cat) =>
106
+ cat.emotions.forEach((group) =>
107
+ group.variants.forEach((variant) => {
108
+ appState.variantLookup.set(variant.move_name, variant);
109
+ })
110
+ )
111
+ );
112
+ }
113
+
114
+ function renderWheel(categories) {
115
+ outerRing.innerHTML = "";
116
+ innerRing.innerHTML = "";
117
+ const totalGroups = categories.reduce((acc, cat) => acc + cat.emotions.length, 0);
118
+ if (!totalGroups) {
119
+ return;
120
  }
121
+ const gradientParts = [];
122
+ let cursor = 0;
123
+ const wheelRadius = wheel.getBoundingClientRect().width / 2;
124
+ const outerRadius = wheelRadius - 60;
125
+ const behaviorBase = wheelRadius - 150;
126
+ const behaviorStep = 26;
127
+
128
+ categories.forEach((category) => {
129
+ const groupCount = category.emotions.length;
130
+ if (!groupCount) {
131
+ return;
132
+ }
133
+ const startDeg = (cursor / totalGroups) * 360;
134
+ const endDeg = ((cursor + groupCount) / totalGroups) * 360;
135
+ gradientParts.push(`${category.color} ${startDeg}deg ${endDeg}deg`);
136
+
137
+ category.emotions.forEach((group, indexWithinCategory) => {
138
+ const absoluteIndex = cursor + indexWithinCategory + 0.5;
139
+ const angle = absoluteIndex / totalGroups * 360 - 90;
140
+ createEmotionNode(group, angle, outerRadius);
141
+ createBehaviorNodes(group, category, angle, behaviorBase, behaviorStep);
142
+ });
143
+
144
+ cursor += groupCount;
145
+ });
146
+
147
+ outerRing.style.setProperty("--wheel-gradient", `conic-gradient(${gradientParts.join(",")})`);
148
+ innerRing.style.setProperty("--wheel-gradient", `conic-gradient(${gradientParts.join(",")})`);
149
  }
150
 
151
+ function createEmotionNode(group, angle, radius) {
152
+ const node = document.createElement("div");
153
+ node.className = "emotion-node";
154
+ node.dataset.base = group.base_name;
155
+ node.dataset.stack = group.stack_size;
156
+ node.textContent = group.label;
157
+ positionNode(node, angle, radius);
158
 
159
+ node.addEventListener("mouseenter", () => highlightBase(group.base_name, true));
160
+ node.addEventListener("mouseleave", () => highlightBase(group.base_name, false));
161
+
162
+ outerRing.appendChild(node);
163
+ }
164
 
165
+ function createBehaviorNodes(group, category, angle, baseRadius, radiusStep) {
166
+ const total = group.variants.length || 1;
167
+ group.variants.forEach((variant, idx) => {
168
+ const btn = document.createElement("button");
169
+ btn.className = "behavior-node";
170
+ btn.dataset.move = variant.move_name;
171
+ btn.dataset.base = group.base_name;
172
+ btn.dataset.durationBand = variant.duration_band;
173
+ btn.style.setProperty("--duration-color", variant.duration_color);
174
+ btn.textContent = variant.roman_label;
175
+ const offsetAngle = angle + (idx - (total - 1) / 2) * 8;
176
+ const radius = baseRadius - idx * radiusStep;
177
+ positionNode(btn, offsetAngle, radius);
178
+ btn.addEventListener("mouseenter", (event) => showTooltip(event, group, variant));
179
+ btn.addEventListener("mousemove", (event) => moveTooltip(event));
180
+ btn.addEventListener("mouseleave", hideTooltip);
181
+ btn.addEventListener("click", () => triggerPlay(variant.move_name));
182
+ innerRing.appendChild(btn);
183
+ });
184
+ }
185
+
186
+ function positionNode(node, angleDeg, radius) {
187
+ const radians = (angleDeg * Math.PI) / 180;
188
+ const x = Math.cos(radians) * radius;
189
+ const y = Math.sin(radians) * radius;
190
+ node.style.left = "50%";
191
+ node.style.top = "50%";
192
+ node.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
193
+ }
194
+
195
+ function highlightBase(baseName, enabled) {
196
+ document.querySelectorAll(`.emotion-node[data-base="${baseName}"]`).forEach((node) => {
197
+ node.classList.toggle("highlight", enabled);
198
+ });
199
+ document.querySelectorAll(`.behavior-node[data-base="${baseName}"]`).forEach((node) => {
200
+ node.classList.toggle("highlight", enabled);
201
+ });
202
+ }
203
+
204
+ function showTooltip(event, group, variant) {
205
+ const duration = `${variant.duration.toFixed(2)}s`;
206
+ tooltip.innerHTML = `
207
+ <strong>${group.label} ${variant.roman_label}</strong>
208
+ <span>${variant.move_name}</span>
209
+ <p>${variant.description || "No description available yet."}</p>
210
+ <small>${duration} • ${variant.duration_band.toUpperCase()}</small>
211
+ `;
212
+ tooltip.style.borderColor = variant.duration_color;
213
+ tooltip.classList.add("visible");
214
+ moveTooltip(event);
215
+ }
216
+
217
+ function moveTooltip(event) {
218
+ tooltip.style.left = `${event.clientX}px`;
219
+ tooltip.style.top = `${event.clientY - 20}px`;
220
+ }
221
+
222
+ function hideTooltip() {
223
+ tooltip.classList.remove("visible");
224
+ }
225
+
226
+ async function triggerPlay(moveName) {
227
+ if (appState.busy) {
228
+ showToast("Waiting for the previous move to finish.");
229
+ return;
230
+ }
231
+ try {
232
+ const res = await fetchJson("/api/play", { method: "POST", body: { move_name: moveName } });
233
+ if (res.accepted) {
234
+ setBusyState(true, res.queued_move);
235
+ showToast(`Playing ${formatMoveName(res.queued_move)}`);
236
+ } else {
237
+ showToast("Reachy Mini is still busy with the last feel.");
238
+ }
239
+ } catch (error) {
240
+ console.error(error);
241
+ showToast("Cannot reach the backend right now.");
242
+ }
243
+ }
244
+
245
+ async function pollState() {
246
+ try {
247
+ const data = await fetchJson("/api/state");
248
+ const busy = Boolean(data.is_playing || data.pending_move);
249
+ setBusyState(busy, data.current_move || data.pending_move);
250
+ } catch (error) {
251
+ console.warn("State polling failed", error);
252
+ } finally {
253
+ setTimeout(pollState, 1400);
254
+ }
255
+ }
256
+
257
+ async function init() {
258
+ try {
259
+ const data = await fetchJson("/api/emotions");
260
+ appState.categories = data.categories || [];
261
+ appState.durationBands = data.duration_bands || [];
262
+ appState.missing = data.missing_emotions || [];
263
+ buildVariantLookup(appState.categories);
264
+ renderLegend(appState.durationBands);
265
+ renderMissing(appState.missing);
266
+ renderWheel(appState.categories);
267
+ setBusyState(false, null);
268
+ pollState();
269
+ } catch (error) {
270
+ console.error(error);
271
+ updateStatusChip("Failed to load data", false);
272
+ showToast("Unable to fetch the emotions list.");
273
+ }
274
+ }
275
+
276
+ init();
277
+
278
+ window.addEventListener("resize", () => {
279
+ if (!appState.categories.length) {
280
+ return;
281
+ }
282
+ renderWheel(appState.categories);
283
+ setBusyState(appState.busy, appState.currentMove);
284
+ });
emotions/static/style.css CHANGED
@@ -1,25 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- font-family: sans-serif;
3
- margin: 24px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- #sound-btn {
7
- padding: 10px 20px;
8
- border: none;
9
- color: white;
 
 
 
 
 
 
 
 
 
10
  cursor: pointer;
11
- font-size: 16px;
12
- border-radius: 6px;
13
- background-color: #3498db;
14
  }
15
 
16
- #status {
17
- margin-top: 16px;
18
- font-weight: bold;
 
 
 
 
19
  }
20
 
21
- #controls {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  display: flex;
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  align-items: center;
24
- gap: 20px;
25
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(520px, 80vw);
11
+ }
12
+
13
+ *,
14
+ *::before,
15
+ *::after {
16
+ box-sizing: border-box;
17
+ }
18
+
19
  body {
20
+ margin: 0;
21
+ font-family: "Space Grotesk", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
22
+ background: radial-gradient(circle at 20% -10%, rgba(255, 255, 255, 0.1), transparent 45%),
23
+ radial-gradient(circle at 80% -20%, rgba(199, 132, 255, 0.15), transparent 50%),
24
+ var(--bg);
25
+ min-height: 100vh;
26
+ color: var(--text-strong);
27
+ }
28
+
29
+ .background-halo {
30
+ position: fixed;
31
+ inset: 0;
32
+ background: radial-gradient(circle at 50% 10%, rgba(255, 255, 255, 0.07), transparent 50%),
33
+ radial-gradient(circle at 80% 0%, rgba(255, 195, 113, 0.15), transparent 40%);
34
+ filter: blur(40px);
35
+ z-index: 0;
36
+ pointer-events: none;
37
+ }
38
+
39
+ .app-shell {
40
+ position: relative;
41
+ z-index: 1;
42
+ max-width: 1200px;
43
+ margin: 0 auto;
44
+ padding: 56px 24px 96px;
45
+ }
46
+
47
+ .hero {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 24px;
51
+ margin-bottom: 40px;
52
+ }
53
+
54
+ .eyebrow {
55
+ text-transform: uppercase;
56
+ letter-spacing: 0.3em;
57
+ font-size: 0.8rem;
58
+ color: var(--text-muted);
59
+ margin-bottom: 8px;
60
+ }
61
+
62
+ .hero h1 {
63
+ margin: 0;
64
+ font-size: clamp(2.5rem, 6vw, 3.5rem);
65
+ }
66
+
67
+ .subtitle {
68
+ margin: 0;
69
+ color: var(--text-soft);
70
+ max-width: 720px;
71
+ line-height: 1.6;
72
+ }
73
+
74
+ .hero-status {
75
+ display: flex;
76
+ flex-wrap: wrap;
77
+ gap: 12px;
78
+ }
79
+
80
+ .chip {
81
+ padding: 6px 18px;
82
+ border-radius: 999px;
83
+ background: var(--chip-bg);
84
+ color: var(--text-strong);
85
+ font-size: 0.9rem;
86
+ }
87
+
88
+ .chip.soft {
89
+ color: var(--text-soft);
90
+ }
91
+
92
+ .chip.playing {
93
+ background: rgba(255, 155, 102, 0.2);
94
+ }
95
+
96
+ .layout-grid {
97
+ display: grid;
98
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
99
+ gap: 32px;
100
+ }
101
+
102
+ .card {
103
+ background: var(--surface);
104
+ border: 1px solid var(--surface-border);
105
+ border-radius: 32px;
106
+ padding: 32px;
107
+ box-shadow: 0 40px 80px rgba(0, 0, 0, 0.35);
108
+ backdrop-filter: blur(20px);
109
+ }
110
+
111
+ .wheel-card {
112
+ display: flex;
113
+ flex-direction: column;
114
+ gap: 24px;
115
+ }
116
+
117
+ .wheel {
118
+ width: var(--wheel-size);
119
+ height: var(--wheel-size);
120
+ margin: 0 auto;
121
+ position: relative;
122
+ }
123
+
124
+ .ring {
125
+ position: absolute;
126
+ inset: 0;
127
+ border-radius: 50%;
128
+ }
129
+
130
+ .ring.outer::before,
131
+ .ring.inner::before {
132
+ content: "";
133
+ position: absolute;
134
+ inset: 0;
135
+ border-radius: 50%;
136
+ background: var(--wheel-gradient, rgba(255, 255, 255, 0.08));
137
+ opacity: 0.35;
138
+ }
139
+
140
+ .ring.inner {
141
+ inset: 12%;
142
+ }
143
+
144
+ .wheel-center {
145
+ position: absolute;
146
+ inset: 35%;
147
+ border-radius: 50%;
148
+ border: 1px solid rgba(255, 255, 255, 0.1);
149
+ background: rgba(7, 11, 22, 0.9);
150
+ display: flex;
151
+ flex-direction: column;
152
+ align-items: center;
153
+ justify-content: center;
154
+ text-transform: uppercase;
155
+ font-size: 0.85rem;
156
+ letter-spacing: 0.3em;
157
+ text-align: center;
158
+ padding: 16px;
159
+ color: var(--text-muted);
160
+ }
161
+
162
+ .wheel-center small {
163
+ letter-spacing: normal;
164
+ text-transform: none;
165
+ margin-top: 12px;
166
+ color: var(--text-soft);
167
+ }
168
+
169
+ .emotion-node {
170
+ position: absolute;
171
+ width: 120px;
172
+ height: 120px;
173
+ border-radius: 50%;
174
+ backdrop-filter: blur(4px);
175
+ border: 1px solid rgba(255, 255, 255, 0.15);
176
+ color: var(--text-strong);
177
+ text-transform: capitalize;
178
+ font-size: 0.95rem;
179
+ text-align: center;
180
+ padding: 24px 12px;
181
+ line-height: 1.3;
182
+ cursor: default;
183
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
184
+ background: rgba(7, 11, 22, 0.65);
185
+ }
186
+
187
+ .emotion-node::after {
188
+ content: attr(data-stack);
189
+ position: absolute;
190
+ top: 8px;
191
+ right: 8px;
192
+ background: rgba(255, 255, 255, 0.15);
193
+ border-radius: 999px;
194
+ font-size: 0.75rem;
195
+ padding: 2px 8px;
196
+ display: none;
197
+ }
198
+
199
+ .emotion-node[data-stack]:not([data-stack="1"])::after {
200
+ display: inline-flex;
201
+ }
202
+
203
+ .emotion-node.highlight {
204
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
205
+ transform: translate(-50%, -50%) scale(1.04);
206
  }
207
 
208
+ .behavior-node {
209
+ position: absolute;
210
+ width: 64px;
211
+ height: 64px;
212
+ border-radius: 50%;
213
+ border: 2px solid rgba(255, 255, 255, 0.15);
214
+ background: rgba(7, 11, 22, 0.9);
215
+ color: var(--text-strong);
216
+ font-weight: 600;
217
+ font-size: 0.85rem;
218
+ display: inline-flex;
219
+ align-items: center;
220
+ justify-content: center;
221
  cursor: pointer;
222
+ transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
 
 
223
  }
224
 
225
+ .behavior-node::after {
226
+ content: "";
227
+ position: absolute;
228
+ inset: 6px;
229
+ border-radius: 50%;
230
+ border: 2px solid var(--duration-color, rgba(255, 255, 255, 0.5));
231
+ opacity: 0.85;
232
  }
233
 
234
+ .behavior-node:hover {
235
+ transform: translate(-50%, -50%) scale(1.08);
236
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
237
+ }
238
+
239
+ .behavior-node.highlight {
240
+ box-shadow: 0 0 25px rgba(255, 255, 255, 0.25);
241
+ }
242
+
243
+ .behavior-node.busy {
244
+ opacity: 0.35;
245
+ pointer-events: none;
246
+ }
247
+
248
+ .legend-row {
249
  display: flex;
250
+ flex-wrap: wrap;
251
+ gap: 16px;
252
+ align-items: center;
253
+ }
254
+
255
+ .legend {
256
+ display: inline-flex;
257
+ gap: 10px;
258
+ flex-wrap: wrap;
259
+ }
260
+
261
+ .legend-item {
262
+ display: inline-flex;
263
  align-items: center;
264
+ gap: 8px;
265
+ font-size: 0.9rem;
266
+ color: var(--text-soft);
267
+ }
268
+
269
+ .legend-swatch {
270
+ width: 14px;
271
+ height: 14px;
272
+ border-radius: 50%;
273
+ }
274
+
275
+ .legend-note {
276
+ font-size: 0.85rem;
277
+ color: var(--text-muted);
278
+ }
279
+
280
+ .info-card {
281
+ display: flex;
282
+ flex-direction: column;
283
+ gap: 16px;
284
+ }
285
+
286
+ .info-header h2 {
287
+ margin: 0 0 8px;
288
+ }
289
+
290
+ .info-header p {
291
+ margin: 0;
292
+ color: var(--text-soft);
293
+ }
294
+
295
+ .missing-list {
296
+ list-style: none;
297
+ margin: 0;
298
+ padding: 0;
299
+ display: grid;
300
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
301
+ gap: 10px;
302
+ }
303
+
304
+ .missing-list li {
305
+ padding: 10px 14px;
306
+ border-radius: 999px;
307
+ background: rgba(255, 255, 255, 0.06);
308
+ color: var(--text-soft);
309
+ font-size: 0.9rem;
310
+ text-transform: capitalize;
311
+ }
312
+
313
+ .info-footer {
314
+ color: var(--text-muted);
315
+ font-size: 0.95rem;
316
+ line-height: 1.5;
317
+ }
318
+
319
+ .tooltip {
320
+ position: fixed;
321
+ pointer-events: none;
322
+ background: rgba(7, 11, 22, 0.95);
323
+ padding: 14px 20px;
324
+ border-radius: 14px;
325
+ border: 1px solid rgba(255, 255, 255, 0.08);
326
+ box-shadow: 0 20px 45px rgba(0, 0, 0, 0.4);
327
+ color: var(--text-strong);
328
+ font-size: 0.85rem;
329
+ max-width: 280px;
330
+ opacity: 0;
331
+ transform: translate(-50%, calc(-100% - 16px));
332
+ transition: opacity 0.2s ease;
333
+ z-index: 10;
334
+ }
335
+
336
+ .tooltip.visible {
337
+ opacity: 1;
338
+ }
339
+
340
+ .tooltip strong {
341
+ display: block;
342
+ margin-bottom: 4px;
343
+ }
344
+
345
+ .tooltip p {
346
+ margin: 0;
347
+ color: var(--text-soft);
348
+ line-height: 1.4;
349
+ }
350
+
351
+ .toast {
352
+ position: fixed;
353
+ bottom: 32px;
354
+ right: 32px;
355
+ background: rgba(7, 11, 22, 0.95);
356
+ padding: 16px 22px;
357
+ border-radius: 999px;
358
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
359
+ opacity: 0;
360
+ transform: translateY(20px);
361
+ transition: opacity 0.25s ease, transform 0.25s ease;
362
+ font-size: 0.95rem;
363
+ color: var(--text-soft);
364
+ }
365
+
366
+ .toast.visible {
367
+ opacity: 1;
368
+ transform: translateY(0);
369
+ }
370
+
371
+ @media (max-width: 1024px) {
372
+ .layout-grid {
373
+ grid-template-columns: 1fr;
374
+ }
375
+
376
+ .wheel {
377
+ width: min(420px, 90vw);
378
+ height: min(420px, 90vw);
379
+ }
380
+ }
381
+
382
+ @media (max-width: 640px) {
383
+ .card {
384
+ padding: 24px;
385
+ }
386
+
387
+ .hero-status {
388
+ flex-direction: column;
389
+ }
390
+
391
+ .wheel-center {
392
+ font-size: 0.75rem;
393
+ letter-spacing: 0.2em;
394
+ }
395
+ }
index.html CHANGED
@@ -1,75 +1,99 @@
1
  <!doctype html>
2
- <html>
3
 
4
  <head>
5
  <meta charset="utf-8" />
6
- <meta name="viewport" content="width=device-width" />
7
- <title> Emotions </title>
8
  <link rel="stylesheet" href="style.css" />
9
  </head>
10
 
11
  <body>
12
- <div class="hero">
 
13
  <div class="hero-content">
14
- <div class="app-icon">🤖⚡</div>
15
- <h1> Emotions </h1>
16
- <p class="tagline">Enter your tagline here</p>
 
 
 
 
 
 
 
 
17
  </div>
18
- </div>
19
-
20
- <div class="container">
21
- <div class="main-card">
22
- <div class="app-preview">
23
- <div class="preview-image">
24
- <div class="camera-feed">🛠️</div>
 
 
 
25
  </div>
26
  </div>
 
27
  </div>
28
- </div>
29
-
30
- <div class="download-section">
31
- <div class="download-card">
32
- <h2>Install This App</h2>
33
-
34
- <div class="dashboard-config">
35
- <label for="dashboardUrl">Your Reachy Dashboard URL:</label>
36
- <input type="url" id="dashboardUrl" value="http://localhost:8000"
37
- placeholder="http://your-reachy-ip:8000" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- <button id="installBtn" class="install-btn primary">
41
- <span class="btn-icon">📥</span>
42
- Install Emotions to Reachy Mini
43
- </button>
44
-
45
- <div id="installStatus" class="install-status"></div>
46
-
47
- </div>
48
- </div>
49
-
50
- <div class="footer">
51
- <p>
52
- 🤖 Emotions •
53
- <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
54
- <a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank">Browse More
55
- Apps</a>
56
- </p>
57
- </div>
58
- </div>
59
 
60
  <script>
61
- // Get the current Hugging Face Space URL as the repository URL
62
  function getCurrentSpaceUrl() {
63
- // Get current page URL and convert to repository format
64
  const currentUrl = window.location.href;
65
-
66
- // Remove any trailing slashes and query parameters
67
- const cleanUrl = currentUrl.split('?')[0].replace(/\/$/, '');
68
-
69
- return cleanUrl;
70
  }
71
 
72
- // Parse TOML content to extract project name
73
  function parseTomlProjectName(tomlContent) {
74
  try {
75
  const lines = tomlContent.split('\n');
@@ -78,23 +102,19 @@
78
  for (const line of lines) {
79
  const trimmedLine = line.trim();
80
 
81
- // Check if we're entering the [project] section
82
  if (trimmedLine === '[project]') {
83
  inProjectSection = true;
84
  continue;
85
  }
86
 
87
- // Check if we're entering a different section
88
  if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
89
  inProjectSection = false;
90
  continue;
91
  }
92
 
93
- // If we're in the project section, look for the name field
94
  if (inProjectSection && trimmedLine.startsWith('name')) {
95
  const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
96
  if (match) {
97
- // Convert to lowercase and replace invalid characters for app naming
98
  return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
99
  }
100
  }
@@ -107,10 +127,8 @@
107
  }
108
  }
109
 
110
- // Fetch and parse pyproject.toml from the current space
111
  async function getAppNameFromCurrentSpace() {
112
  try {
113
- // Fetch pyproject.toml from the current space
114
  const response = await fetch('./pyproject.toml');
115
  if (!response.ok) {
116
  throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
@@ -120,7 +138,6 @@
120
  return parseTomlProjectName(tomlContent);
121
  } catch (error) {
122
  console.error('Error fetching app name from current space:', error);
123
- // Fallback to extracting from URL if pyproject.toml is not accessible
124
  const url = getCurrentSpaceUrl();
125
  const parts = url.split('/');
126
  const spaceName = parts[parts.length - 1];
@@ -128,9 +145,14 @@
128
  }
129
  }
130
 
 
 
 
 
 
 
131
  async function installToReachy() {
132
  const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
133
- const statusDiv = document.getElementById('installStatus');
134
  const installBtn = document.getElementById('installBtn');
135
 
136
  if (!dashboardUrl) {
@@ -140,30 +162,25 @@
140
 
141
  try {
142
  installBtn.disabled = true;
143
- installBtn.innerHTML = '<span class="btn-icon">⏳</span>Installing...';
144
- showStatus('loading', 'Connecting to your Reachy dashboard...');
145
 
146
- // Test connection
147
  const testResponse = await fetch(`${dashboardUrl}/api/status`, {
148
  method: 'GET',
149
  mode: 'cors',
150
  });
151
 
152
  if (!testResponse.ok) {
153
- throw new Error('Cannot connect to dashboard. Make sure the URL is correct and the dashboard is running.');
154
  }
155
 
156
- showStatus('loading', 'Reading app configuration...');
157
 
158
- // Get app name from pyproject.toml in current space
159
  const appName = await getAppNameFromCurrentSpace();
160
-
161
- // Get current space URL as repository URL
162
  const repoUrl = getCurrentSpaceUrl();
163
 
164
- showStatus('loading', `Starting installation of "${appName}"...`);
165
 
166
- // Start installation
167
  const installResponse = await fetch(`${dashboardUrl}/api/install`, {
168
  method: 'POST',
169
  mode: 'cors',
@@ -179,10 +196,7 @@
179
  const result = await installResponse.json();
180
 
181
  if (installResponse.ok) {
182
- showStatus('success', `✅ Installation started for "${appName}"! Check your dashboard for progress.`);
183
- setTimeout(() => {
184
- showStatus('info', `Open your dashboard at ${dashboardUrl} to see the installed app.`);
185
- }, 3000);
186
  } else {
187
  throw new Error(result.detail || 'Installation failed');
188
  }
@@ -192,44 +206,12 @@
192
  showStatus('error', `❌ ${error.message}`);
193
  } finally {
194
  installBtn.disabled = false;
195
- installBtn.innerHTML = '<span class="btn-icon">📥</span>Install App to Reachy';
196
  }
197
  }
198
 
199
- function showStatus(type, message) {
200
- const statusDiv = document.getElementById('installStatus');
201
- statusDiv.className = `install-status ${type}`;
202
- statusDiv.textContent = message;
203
- statusDiv.style.display = 'block';
204
- }
205
-
206
- function copyToClipboard() {
207
- const repoUrl = document.getElementById('repoUrl').textContent;
208
- navigator.clipboard.writeText(repoUrl).then(() => {
209
- showStatus('success', '📋 Repository URL copied to clipboard!');
210
- }).catch(() => {
211
- showStatus('error', 'Failed to copy URL. Please copy manually.');
212
- });
213
- }
214
-
215
- // Update the displayed repository URL on page load
216
- document.addEventListener('DOMContentLoaded', () => {
217
- // Auto-detect local dashboard
218
- const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
219
- if (isLocalhost) {
220
- document.getElementById('dashboardUrl').value = 'http://localhost:8000';
221
- }
222
-
223
- // Update the repository URL display if element exists
224
- const repoUrlElement = document.getElementById('repoUrl');
225
- if (repoUrlElement) {
226
- repoUrlElement.textContent = getCurrentSpaceUrl();
227
- }
228
- });
229
-
230
- // Event listeners
231
  document.getElementById('installBtn').addEventListener('click', installToReachy);
232
  </script>
233
  </body>
234
 
235
- </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
 
4
  <head>
5
  <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>Reachy Mini • 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>What still needs recording?</h2>
70
+ <p>We’d love to capture hopeful, jealous, playful, apologetic, embarrassed, nostalgic, a proud+shy combo,
71
+ and a confident nod.</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');
 
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
  }
 
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}`);
 
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];
 
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) {
 
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',
 
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
  }
 
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>
style.css CHANGED
@@ -1,411 +1,273 @@
1
- * {
2
- margin: 0;
3
- padding: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  box-sizing: border-box;
5
  }
6
 
7
  body {
8
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
- line-height: 1.6;
10
- color: #333;
11
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
  min-height: 100vh;
 
 
 
 
 
13
  }
14
 
15
- .hero {
16
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
- color: white;
18
- padding: 4rem 2rem;
19
- text-align: center;
 
 
 
 
20
  }
21
 
22
- .hero-content {
23
- max-width: 800px;
 
 
24
  margin: 0 auto;
 
 
 
 
 
25
  }
26
 
27
- .app-icon {
28
- font-size: 4rem;
29
- margin-bottom: 1rem;
30
- display: inline-block;
31
  }
32
 
33
- .hero h1 {
34
- font-size: 3rem;
35
- font-weight: 700;
36
- margin-bottom: 1rem;
37
- background: linear-gradient(45deg, #fff, #f0f9ff);
38
- background-clip: text;
39
- -webkit-background-clip: text;
40
- -webkit-text-fill-color: transparent;
41
  }
42
 
43
- .tagline {
44
- font-size: 1.25rem;
45
- opacity: 0.9;
46
- max-width: 600px;
47
- margin: 0 auto;
48
  }
49
 
50
- .container {
51
- max-width: 1200px;
52
- margin: 0 auto;
53
- padding: 0 2rem;
54
- position: relative;
55
- z-index: 2;
56
  }
57
 
58
- .main-card {
59
- background: white;
60
- border-radius: 20px;
61
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
62
- margin-top: -2rem;
63
- overflow: hidden;
64
- margin-bottom: 3rem;
65
  }
66
 
67
- .app-preview {
68
- background: linear-gradient(135deg, #1e3a8a, #3b82f6);
69
- padding: 3rem;
70
- color: white;
 
 
71
  text-align: center;
72
- position: relative;
73
  }
74
 
75
- .preview-image {
76
- background: #000;
77
- border-radius: 15px;
78
- padding: 2rem;
79
- max-width: 500px;
80
- margin: 0 auto;
81
  position: relative;
82
- overflow: hidden;
83
- }
84
-
85
- .camera-feed {
86
- font-size: 4rem;
87
- margin-bottom: 1rem;
88
- opacity: 0.7;
89
  }
90
 
91
- .detection-overlay {
92
  position: absolute;
93
  top: 50%;
94
  left: 50%;
95
- transform: translate(-50%, -50%);
96
- width: 100%;
97
- }
98
-
99
- .bbox {
100
- background: rgba(34, 197, 94, 0.9);
101
- color: white;
102
- padding: 0.5rem 1rem;
103
- border-radius: 8px;
104
- font-size: 0.9rem;
105
- font-weight: 600;
106
- margin: 0.5rem;
107
- display: inline-block;
108
- border: 2px solid #22c55e;
109
- }
110
-
111
- .app-details {
112
- padding: 3rem;
113
  }
114
 
115
- .app-details h2 {
116
- font-size: 2rem;
117
- color: #1e293b;
118
- margin-bottom: 2rem;
119
- text-align: center;
120
- }
121
-
122
- .template-info {
123
- display: grid;
124
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
125
- gap: 2rem;
126
- margin-bottom: 3rem;
127
  }
128
 
129
- .info-box {
130
- background: #f0f9ff;
131
- border: 2px solid #e0f2fe;
132
- border-radius: 12px;
133
- padding: 2rem;
134
  }
135
 
136
- .info-box h3 {
137
- color: #0c4a6e;
138
- margin-bottom: 1rem;
139
- font-size: 1.2rem;
140
  }
141
 
142
- .info-box p {
143
- color: #0369a1;
144
- line-height: 1.6;
 
 
 
 
 
 
145
  }
146
 
147
- .how-to-use {
148
- background: #fefce8;
149
- border: 2px solid #fde047;
150
- border-radius: 12px;
151
- padding: 2rem;
152
- margin-top: 3rem;
153
  }
154
 
155
- .how-to-use h3 {
156
- color: #a16207;
157
- margin-bottom: 1.5rem;
158
- font-size: 1.3rem;
159
- text-align: center;
160
  }
161
 
162
- .steps {
 
 
 
 
 
163
  display: flex;
164
  flex-direction: column;
165
- gap: 1.5rem;
166
- }
167
-
168
- .step {
169
- display: flex;
170
- align-items: flex-start;
171
- gap: 1rem;
172
  }
173
 
174
- .step-number {
175
- background: #eab308;
176
- color: white;
177
- width: 2rem;
178
- height: 2rem;
179
- border-radius: 50%;
180
- display: flex;
181
- align-items: center;
182
- justify-content: center;
183
- font-weight: bold;
184
- flex-shrink: 0;
185
  }
186
 
187
- .step h4 {
188
- color: #a16207;
189
- margin-bottom: 0.5rem;
190
- font-size: 1.1rem;
 
 
191
  }
192
 
193
- .step p {
194
- color: #ca8a04;
 
195
  }
196
 
197
- .download-card {
198
- background: white;
199
- border-radius: 20px;
200
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
201
- padding: 3rem;
202
- text-align: center;
203
  }
204
 
205
- .download-card h2 {
206
- font-size: 2rem;
207
- color: #1e293b;
208
- margin-bottom: 1rem;
 
 
 
 
 
209
  }
210
 
211
- .download-card>p {
212
- color: #64748b;
213
- font-size: 1.1rem;
214
- margin-bottom: 2rem;
215
  }
216
 
217
- .dashboard-config {
218
- margin-bottom: 2rem;
219
- text-align: left;
220
- max-width: 400px;
221
- margin-left: auto;
222
- margin-right: auto;
223
  }
224
 
225
- .dashboard-config label {
226
- display: block;
227
- color: #374151;
228
- font-weight: 600;
229
- margin-bottom: 0.5rem;
 
230
  }
231
 
232
- .dashboard-config input {
233
  width: 100%;
234
- padding: 0.75rem 1rem;
235
- border: 2px solid #e5e7eb;
236
- border-radius: 8px;
237
- font-size: 0.95rem;
238
- transition: border-color 0.2s;
239
- }
240
-
241
- .dashboard-config input:focus {
242
- outline: none;
243
- border-color: #667eea;
244
  }
245
 
246
  .install-btn {
247
- background: linear-gradient(135deg, #667eea, #764ba2);
248
- color: white;
249
- border: none;
250
- padding: 1.25rem 3rem;
251
- border-radius: 16px;
252
- font-size: 1.2rem;
253
- font-weight: 700;
254
- cursor: pointer;
255
- transition: all 0.3s ease;
256
  display: inline-flex;
257
  align-items: center;
258
- gap: 0.75rem;
259
- margin-bottom: 2rem;
260
- box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
261
- }
262
-
263
- .install-btn:hover:not(:disabled) {
264
- transform: translateY(-3px);
265
- box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
266
  }
267
 
268
  .install-btn:disabled {
269
- opacity: 0.7;
270
  cursor: not-allowed;
271
- transform: none;
272
- }
273
-
274
- .manual-option {
275
- background: #f8fafc;
276
- border-radius: 12px;
277
- padding: 2rem;
278
- margin-top: 2rem;
279
- }
280
-
281
- .manual-option h3 {
282
- color: #1e293b;
283
- margin-bottom: 1rem;
284
- font-size: 1.2rem;
285
- }
286
-
287
- .manual-option>p {
288
- color: #64748b;
289
- margin-bottom: 1rem;
290
- }
291
-
292
- .btn-icon {
293
- font-size: 1.1rem;
294
  }
295
 
296
  .install-status {
297
- padding: 1rem;
298
- border-radius: 8px;
299
  font-size: 0.9rem;
300
- text-align: center;
301
- display: none;
302
- margin-top: 1rem;
303
  }
304
 
305
  .install-status.success {
306
- background: #dcfce7;
307
- color: #166534;
308
- border: 1px solid #bbf7d0;
309
  }
310
 
311
  .install-status.error {
312
- background: #fef2f2;
313
- color: #dc2626;
314
- border: 1px solid #fecaca;
315
  }
316
 
317
  .install-status.loading {
318
- background: #dbeafe;
319
- color: #1d4ed8;
320
- border: 1px solid #bfdbfe;
321
- }
322
-
323
- .install-status.info {
324
- background: #e0f2fe;
325
- color: #0369a1;
326
- border: 1px solid #7dd3fc;
327
- }
328
-
329
- .manual-install {
330
- background: #1f2937;
331
- border-radius: 8px;
332
- padding: 1rem;
333
- margin-bottom: 1rem;
334
- display: flex;
335
- align-items: center;
336
- gap: 1rem;
337
- }
338
-
339
- .manual-install code {
340
- color: #10b981;
341
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
342
- font-size: 0.85rem;
343
- flex: 1;
344
- overflow-x: auto;
345
- }
346
-
347
- .copy-btn {
348
- background: #374151;
349
- color: white;
350
- border: none;
351
- padding: 0.5rem 1rem;
352
- border-radius: 6px;
353
- font-size: 0.8rem;
354
- cursor: pointer;
355
- transition: background-color 0.2s;
356
- }
357
-
358
- .copy-btn:hover {
359
- background: #4b5563;
360
- }
361
-
362
- .manual-steps {
363
- color: #6b7280;
364
- font-size: 0.9rem;
365
- line-height: 1.8;
366
  }
367
 
368
  .footer {
369
  text-align: center;
370
- padding: 2rem;
371
- color: white;
372
- opacity: 0.8;
373
- }
374
-
375
- .footer a {
376
- color: white;
377
- text-decoration: none;
378
- font-weight: 600;
379
- }
380
-
381
- .footer a:hover {
382
- text-decoration: underline;
383
  }
384
 
385
- /* Responsive Design */
386
- @media (max-width: 768px) {
387
  .hero {
388
- padding: 2rem 1rem;
389
- }
390
-
391
- .hero h1 {
392
- font-size: 2rem;
393
  }
394
 
395
- .container {
396
- padding: 0 1rem;
397
  }
398
-
399
- .app-details,
400
- .download-card {
401
- padding: 2rem;
402
- }
403
-
404
- .features-grid {
405
- grid-template-columns: 1fr;
406
- }
407
-
408
- .download-options {
409
- grid-template-columns: 1fr;
410
- }
411
- }
 
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
+ }