jrubiosainz commited on
Commit
f2c42be
·
verified ·
1 Parent(s): 44a00f7

Initial publish: curiosity_bot

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
README.md CHANGED
@@ -1,10 +1,43 @@
1
  ---
2
  title: Curiosity Bot
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: static
7
  pinned: false
 
 
 
 
8
  ---
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Curiosity Bot
3
+ emoji: 🔍
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: static
7
  pinned: false
8
+ short_description: "Robot gets curious about new objects"
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
  ---
13
+ # Curiosity Bot 🔍🤖
14
 
15
+ Uses EfficientDet object detection to spot objects in the robot's camera view. When something new appears, the robot gets curious — tilting its head, leaning forward, and perking its antennas.
16
+
17
+ ## Features
18
+
19
+ - **Novelty detection**: New objects get maximum curiosity, familiar ones get less attention
20
+ - **Familiarity tracking**: Objects seen repeatedly become "boring" over time
21
+ - **Reappearance surprise**: Objects that disappear and come back trigger renewed curiosity
22
+ - **Proximity interest**: Larger/closer objects are more interesting
23
+ - **Lonely drift**: When nothing is detected, the robot gently scans the environment
24
+
25
+ ## Behaviors
26
+
27
+ - **Novel object**: Head tilt + lean forward + antenna excitement
28
+ - **Familiar object**: Mild acknowledgment
29
+ - **Object reappears**: Renewed curiosity (familiarity partially reset)
30
+ - **No objects**: Gentle idle scanning motion
31
+
32
+ ## Requirements
33
+
34
+ - Camera connected to Reachy Mini
35
+ - `mediapipe`, `opencv-python`, `numpy`
36
+ - EfficientDet model (auto-downloaded on first run)
37
+
38
+ ## Install & Run
39
+
40
+ ```bash
41
+ pip install .
42
+ reachy-mini run curiosity_bot
43
+ ```
curiosity_bot/__init__.py ADDED
File without changes
curiosity_bot/__pycache__/main.cpython-312.pyc ADDED
Binary file (7.55 kB). View file
 
curiosity_bot/main.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Curiosity Bot — Detects objects and reacts with curiosity behaviors."""
2
+
3
+ import threading
4
+ import time
5
+ from collections import defaultdict
6
+ from typing import Optional
7
+
8
+ import cv2
9
+ import numpy as np
10
+ from reachy_mini import ReachyMini, ReachyMiniApp
11
+ from reachy_mini.utils import create_head_pose
12
+
13
+ try:
14
+ import mediapipe as mp
15
+ except ImportError:
16
+ mp = None
17
+
18
+
19
+ class CuriosityBot(ReachyMiniApp):
20
+ custom_app_url: str | None = None
21
+ request_media_backend: str | None = None
22
+
23
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
24
+ if mp is None:
25
+ print("mediapipe not available, running idle")
26
+ while not stop_event.is_set():
27
+ time.sleep(1)
28
+ return
29
+
30
+ # Use MediaPipe Object Detection
31
+ model_path = None
32
+ try:
33
+ from mediapipe.tasks.python import vision
34
+ from mediapipe.tasks.python import BaseOptions
35
+ # Try EfficientDet lite0
36
+ import importlib.resources
37
+ model_path = "/tmp/efficientdet_lite0.tflite"
38
+ import urllib.request
39
+ import os
40
+ if not os.path.exists(model_path):
41
+ print("Downloading EfficientDet model...")
42
+ urllib.request.urlretrieve(
43
+ "https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/int8/1/efficientdet_lite0.tflite",
44
+ model_path
45
+ )
46
+ except Exception as e:
47
+ print(f"Object detection setup failed: {e}")
48
+ model_path = None
49
+
50
+ detector = None
51
+ if model_path:
52
+ try:
53
+ options = mp.tasks.vision.ObjectDetectorOptions(
54
+ base_options=mp.tasks.BaseOptions(model_asset_path=model_path),
55
+ max_results=5,
56
+ score_threshold=0.4,
57
+ )
58
+ detector = mp.tasks.vision.ObjectDetector.create_from_options(options)
59
+ except Exception as e:
60
+ print(f"Failed to create detector: {e}")
61
+
62
+ # Object familiarity tracker
63
+ familiarity: dict[str, float] = defaultdict(float) # label → familiarity score
64
+ last_seen: dict[str, float] = {} # label → last seen time
65
+ disappeared: dict[str, float] = {} # label → disappeared time
66
+
67
+ # Curiosity state
68
+ curiosity_target: Optional[str] = None
69
+ curiosity_start = 0.0
70
+ curiosity_level = 0.0
71
+
72
+ while not stop_event.is_set():
73
+ t = time.time()
74
+
75
+ # Get frame
76
+ frame = None
77
+ if hasattr(reachy_mini, 'camera') and reachy_mini.camera is not None:
78
+ try:
79
+ frame = reachy_mini.camera.get_frame()
80
+ except Exception:
81
+ pass
82
+
83
+ detected_objects: list[tuple[str, float, float, float]] = [] # (label, cx, cy, area)
84
+
85
+ if frame is not None and detector is not None:
86
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
87
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
88
+ results = detector.detect(mp_image)
89
+ h, w = frame.shape[:2]
90
+
91
+ for det in results.detections:
92
+ label = det.categories[0].category_name
93
+ score = det.categories[0].score
94
+ bb = det.bounding_box
95
+ cx = (bb.origin_x + bb.width / 2) / w
96
+ cy = (bb.origin_y + bb.height / 2) / h
97
+ area = (bb.width * bb.height) / (w * h)
98
+ detected_objects.append((label, cx, cy, area))
99
+
100
+ # Update familiarity and check for novelty
101
+ current_labels = set()
102
+ most_novel: Optional[tuple[str, float, float]] = None
103
+ max_novelty = 0.0
104
+
105
+ for label, cx, cy, area in detected_objects:
106
+ current_labels.add(label)
107
+ # Check if reappeared after disappearing
108
+ if label in disappeared:
109
+ gone_time = t - disappeared[label]
110
+ if gone_time > 5.0: # Been gone > 5s → renewed curiosity
111
+ familiarity[label] *= 0.3 # Reset some familiarity
112
+ del disappeared[label]
113
+
114
+ last_seen[label] = t
115
+ novelty = max(0, 1.0 - familiarity[label])
116
+ # Larger/closer objects are more interesting
117
+ novelty *= (0.5 + area * 5)
118
+
119
+ if novelty > max_novelty:
120
+ max_novelty = novelty
121
+ most_novel = (label, cx, cy)
122
+
123
+ # Increase familiarity over time
124
+ familiarity[label] = min(1.0, familiarity[label] + 0.005)
125
+
126
+ # Track disappeared objects
127
+ for label, last_t in list(last_seen.items()):
128
+ if label not in current_labels and (t - last_t) > 1.0:
129
+ disappeared[label] = last_t
130
+ del last_seen[label]
131
+
132
+ # Update curiosity target
133
+ if most_novel and max_novelty > 0.2:
134
+ if curiosity_target != most_novel[0]:
135
+ curiosity_target = most_novel[0]
136
+ curiosity_start = t
137
+ curiosity_level = min(1.0, max_novelty)
138
+ else:
139
+ curiosity_level *= 0.95 # Decay
140
+ if curiosity_level < 0.05:
141
+ curiosity_target = None
142
+
143
+ # Generate robot pose based on curiosity state
144
+ if curiosity_target and most_novel:
145
+ label, cx, cy = most_novel
146
+ # Look toward object (map 0-1 to yaw/pitch)
147
+ target_yaw = (0.5 - cx) * 40 # Object left → look left
148
+ target_pitch = (0.5 - cy) * 25 - 5 # Slight tilt down for curiosity
149
+
150
+ # Curiosity tilt
151
+ elapsed = t - curiosity_start
152
+ tilt_roll = 8 * np.sin(elapsed * 1.5) * curiosity_level # Head tilt
153
+ lean_pitch = -5 * curiosity_level # Lean forward
154
+
155
+ # Antenna excitement
156
+ ant_angle = 20 * curiosity_level
157
+ ant_wobble = 5 * np.sin(elapsed * 3) * curiosity_level
158
+
159
+ head = create_head_pose(
160
+ yaw=target_yaw,
161
+ pitch=target_pitch + lean_pitch,
162
+ roll=tilt_roll,
163
+ degrees=True
164
+ )
165
+ antennas = np.deg2rad(np.array([
166
+ ant_angle + ant_wobble,
167
+ ant_angle - ant_wobble
168
+ ]))
169
+ reachy_mini.set_target(head=head, antennas=antennas)
170
+ else:
171
+ # Idle — gentle drift (lonely/scanning)
172
+ drift_yaw = 15 * np.sin(2 * np.pi * 0.05 * t)
173
+ drift_pitch = 3 * np.sin(2 * np.pi * 0.08 * t)
174
+ head = create_head_pose(yaw=drift_yaw, pitch=drift_pitch, degrees=True)
175
+ reachy_mini.set_target(head=head, antennas=np.array([0.0, 0.0]))
176
+
177
+ time.sleep(0.02)
178
+
179
+ if detector:
180
+ detector.close()
181
+
182
+
183
+ if __name__ == "__main__":
184
+ app = CuriosityBot()
185
+ try:
186
+ app.wrapped_run()
187
+ except KeyboardInterrupt:
188
+ app.stop()
curiosity_bot/static/index.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
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>
curiosity_bot/static/main.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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();
curiosity_bot/static/style.css ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
index.html CHANGED
@@ -1,19 +1,40 @@
1
  <!doctype html>
2
  <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
  </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!doctype html>
2
  <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Curiosity Bot </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> Curiosity Bot </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="footer">
31
+ <p>
32
+ 🤖 Curiosity Bot •
33
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
34
+ <a href="https://huggingface.co/spaces/pollen-robotics/reachy-mini-landing-page#apps" target="_blank">Browse More
35
+ Apps</a>
36
+ </p>
37
+ </div>
38
+ </body>
39
+
40
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "curiosity_bot"
8
+ version = "0.1.0"
9
+ description = "Uses EfficientDet to detect objects and triggers curiosity behaviors with familiarity tracking"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini",
14
+ "mediapipe",
15
+ "opencv-python",
16
+ "numpy"
17
+ ]
18
+ keywords = ["reachy-mini-app"]
19
+
20
+ [project.entry-points."reachy_mini_apps"]
21
+ curiosity_bot = "curiosity_bot.main:CuriosityBot"
22
+
23
+ [tool.setuptools]
24
+ package-dir = { "" = "." }
25
+ include-package-data = true
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["."]
29
+
30
+ [tool.setuptools.package-data]
31
+ curiosity_bot = ["**/*"] # Also include all non-.py files
style.css CHANGED
@@ -1,28 +1,411 @@
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
 
 
20
  margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }