Spaces:
Running
Running
RemiFabre
commited on
Commit
·
d4694ba
1
Parent(s):
03317af
First iteration, still very WIP
Browse files- README.md +39 -2
- emotions/main.py +309 -50
- emotions/static/index.html +53 -13
- emotions/static/main.js +270 -33
- emotions/static/style.css +385 -15
- index.html +89 -107
- 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:
|
| 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
|
|
|
|
|
|
|
| 7 |
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 17 |
-
t0 = time.time()
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
# Main control loop
|
| 41 |
-
while not stop_event.is_set():
|
| 42 |
-
t = time.time() - t0
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 7 |
-
<
|
| 8 |
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
</head>
|
| 10 |
|
| 11 |
<body>
|
| 12 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
<div id="
|
| 24 |
-
<
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
function
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
cursor: pointer;
|
| 11 |
-
|
| 12 |
-
border-radius: 6px;
|
| 13 |
-
background-color: #3498db;
|
| 14 |
}
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
display: flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
align-items: center;
|
| 24 |
-
gap:
|
| 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="
|
|
|
|
| 13 |
<div class="hero-content">
|
| 14 |
-
<
|
| 15 |
-
<h1>
|
| 16 |
-
<p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
</div>
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
<div class="
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
</div>
|
| 26 |
</div>
|
|
|
|
| 27 |
</div>
|
| 28 |
-
</
|
| 29 |
-
|
| 30 |
-
<
|
| 31 |
-
<
|
| 32 |
-
<
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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.
|
| 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.
|
| 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',
|
| 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.
|
| 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 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
box-sizing: border-box;
|
| 5 |
}
|
| 6 |
|
| 7 |
body {
|
| 8 |
-
|
| 9 |
-
line-height: 1.6;
|
| 10 |
-
color: #333;
|
| 11 |
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 12 |
min-height: 100vh;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
-
.
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
.hero
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
margin: 0 auto;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
-
.
|
| 28 |
-
font-size: 4rem;
|
| 29 |
-
margin-bottom:
|
| 30 |
-
display: inline-block;
|
| 31 |
}
|
| 32 |
|
| 33 |
-
.hero
|
| 34 |
-
|
| 35 |
-
|
| 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 |
-
.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
}
|
| 49 |
|
| 50 |
-
.
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
z-index: 2;
|
| 56 |
}
|
| 57 |
|
| 58 |
-
.
|
| 59 |
-
|
| 60 |
-
border-radius:
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
overflow: hidden;
|
| 64 |
-
margin-bottom: 3rem;
|
| 65 |
}
|
| 66 |
|
| 67 |
-
.
|
| 68 |
-
background:
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
text-align: center;
|
| 72 |
-
position: relative;
|
| 73 |
}
|
| 74 |
|
| 75 |
-
.
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 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 |
-
.
|
| 92 |
position: absolute;
|
| 93 |
top: 50%;
|
| 94 |
left: 50%;
|
| 95 |
-
|
| 96 |
-
|
| 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 |
-
.
|
| 116 |
-
|
| 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 |
-
.
|
| 130 |
-
|
| 131 |
-
border: 2px solid #e0f2fe;
|
| 132 |
-
border-radius: 12px;
|
| 133 |
-
padding: 2rem;
|
| 134 |
}
|
| 135 |
|
| 136 |
-
.
|
| 137 |
-
|
| 138 |
-
margin-bottom: 1rem;
|
| 139 |
-
font-size: 1.2rem;
|
| 140 |
}
|
| 141 |
|
| 142 |
-
.
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
|
| 147 |
-
.
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
border-radius:
|
| 151 |
-
|
| 152 |
-
margin-top: 3rem;
|
| 153 |
}
|
| 154 |
|
| 155 |
-
.
|
| 156 |
-
color:
|
| 157 |
-
margin
|
| 158 |
-
font-size:
|
| 159 |
-
text-align: center;
|
| 160 |
}
|
| 161 |
|
| 162 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
display: flex;
|
| 164 |
flex-direction: column;
|
| 165 |
-
gap:
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
.step {
|
| 169 |
-
display: flex;
|
| 170 |
-
align-items: flex-start;
|
| 171 |
-
gap: 1rem;
|
| 172 |
}
|
| 173 |
|
| 174 |
-
.
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 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 |
-
.
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
-
.
|
| 194 |
-
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
-
.
|
| 198 |
-
|
| 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 |
-
.
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
-
.
|
| 212 |
-
|
| 213 |
-
font-size: 1.1rem;
|
| 214 |
-
margin-bottom: 2rem;
|
| 215 |
}
|
| 216 |
|
| 217 |
-
.
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
max-width: 400px;
|
| 221 |
-
margin-left: auto;
|
| 222 |
-
margin-right: auto;
|
| 223 |
}
|
| 224 |
|
| 225 |
-
|
| 226 |
-
display:
|
| 227 |
-
|
| 228 |
-
font-
|
| 229 |
-
|
|
|
|
| 230 |
}
|
| 231 |
|
| 232 |
-
|
| 233 |
width: 100%;
|
| 234 |
-
padding:
|
| 235 |
-
border:
|
| 236 |
-
border
|
| 237 |
-
|
| 238 |
-
|
| 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:
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
}
|
| 267 |
|
| 268 |
.install-btn:disabled {
|
| 269 |
-
opacity: 0.
|
| 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 |
-
|
| 298 |
-
|
| 299 |
font-size: 0.9rem;
|
| 300 |
-
text-
|
| 301 |
-
display: none;
|
| 302 |
-
margin-top: 1rem;
|
| 303 |
}
|
| 304 |
|
| 305 |
.install-status.success {
|
| 306 |
-
|
| 307 |
-
color: #166534;
|
| 308 |
-
border: 1px solid #bbf7d0;
|
| 309 |
}
|
| 310 |
|
| 311 |
.install-status.error {
|
| 312 |
-
|
| 313 |
-
color: #dc2626;
|
| 314 |
-
border: 1px solid #fecaca;
|
| 315 |
}
|
| 316 |
|
| 317 |
.install-status.loading {
|
| 318 |
-
|
| 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 |
-
|
| 371 |
-
|
| 372 |
-
|
| 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 |
-
|
| 386 |
-
@media (max-width: 768px) {
|
| 387 |
.hero {
|
| 388 |
-
padding:
|
| 389 |
-
}
|
| 390 |
-
|
| 391 |
-
.hero h1 {
|
| 392 |
-
font-size: 2rem;
|
| 393 |
}
|
| 394 |
|
| 395 |
-
.
|
| 396 |
-
padding:
|
| 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 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|