RemiFabre commited on
Commit
4fa05de
·
1 Parent(s): 697c679

Add real-time emotion monitor to web GUI

Browse files

- Add get_current_emotion_status() to MovementManager
- Add /emotion_status API endpoint
- Update frontend with emotion panel showing:
- Current emotion name and type
- Progress bar with elapsed/duration
- PAD values visualization for generated emotions
- Poll every 200ms for smooth updates

src/feeling_machine/console.py CHANGED
@@ -303,6 +303,17 @@ class LocalStream:
303
  logger.warning(f"API key validation failed: {e}")
304
  return JSONResponse({"valid": False, "error": "validation_error"}, status_code=500)
305
 
 
 
 
 
 
 
 
 
 
 
 
306
  self._settings_initialized = True
307
 
308
  def launch(self) -> None:
 
303
  logger.warning(f"API key validation failed: {e}")
304
  return JSONResponse({"valid": False, "error": "validation_error"}, status_code=500)
305
 
306
+ # GET /emotion_status -> current emotion being played
307
+ @self._settings_app.get("/emotion_status")
308
+ def _emotion_status() -> JSONResponse:
309
+ try:
310
+ movement_manager = self.handler.deps.movement_manager
311
+ status = movement_manager.get_current_emotion_status()
312
+ return JSONResponse(status)
313
+ except Exception as e:
314
+ logger.warning(f"Failed to get emotion status: {e}")
315
+ return JSONResponse({"playing": False, "error": str(e)})
316
+
317
  self._settings_initialized = True
318
 
319
  def launch(self) -> None:
src/feeling_machine/moves.py CHANGED
@@ -357,6 +357,75 @@ class MovementManager:
357
 
358
  return self._now() - last_activity >= self.idle_inactivity_delay
359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  def set_listening(self, listening: bool) -> None:
361
  """Enable or disable listening mode without touching shared state directly.
362
 
 
357
 
358
  return self._now() - last_activity >= self.idle_inactivity_delay
359
 
360
+ def get_current_emotion_status(self) -> Dict[str, Any]:
361
+ """Get information about the currently playing emotion/move.
362
+
363
+ Thread-safe: reads shared state with appropriate locking.
364
+
365
+ Returns a dict with:
366
+ - playing: bool - whether an emotion/move is currently playing
367
+ - type: str - "pad_emotion", "classic_emotion", "dance", "breathing", or None
368
+ - emotion_name: str or None - name of the emotion (if available)
369
+ - pad_values: dict or None - P, A, D values (if PAD emotion)
370
+ - duration: float or None - total duration in seconds
371
+ - elapsed: float or None - elapsed time in seconds
372
+ - progress: float or None - progress 0.0 to 1.0
373
+ """
374
+ from feeling_machine.dance_emotion_moves import PADEmotionMove, EmotionQueueMove, DanceQueueMove
375
+
376
+ with self._status_lock:
377
+ current_move = self.state.current_move
378
+ move_start_time = self.state.move_start_time
379
+
380
+ if current_move is None:
381
+ return {"playing": False, "type": None}
382
+
383
+ current_time = self._now()
384
+ elapsed = current_time - move_start_time if move_start_time else 0.0
385
+ duration = getattr(current_move, "duration", None)
386
+
387
+ # Handle infinite duration (breathing)
388
+ if duration == float("inf"):
389
+ progress = None
390
+ duration_display = None
391
+ else:
392
+ duration_display = duration
393
+ progress = min(elapsed / duration, 1.0) if duration and duration > 0 else 0.0
394
+
395
+ result: Dict[str, Any] = {
396
+ "playing": True,
397
+ "elapsed": round(elapsed, 2),
398
+ "duration": round(duration_display, 2) if duration_display else None,
399
+ "progress": round(progress, 3) if progress is not None else None,
400
+ }
401
+
402
+ if isinstance(current_move, BreathingMove):
403
+ result["type"] = "breathing"
404
+ result["emotion_name"] = None
405
+ result["pad_values"] = None
406
+ elif isinstance(current_move, PADEmotionMove):
407
+ result["type"] = "pad_emotion"
408
+ result["emotion_name"] = None # PAD emotions don't have names by default
409
+ result["pad_values"] = {
410
+ "P": round(current_move.P, 2),
411
+ "A": round(current_move.A, 2),
412
+ "D": round(current_move.D, 2),
413
+ }
414
+ elif isinstance(current_move, EmotionQueueMove):
415
+ result["type"] = "classic_emotion"
416
+ result["emotion_name"] = getattr(current_move, "emotion_name", None)
417
+ result["pad_values"] = None
418
+ elif isinstance(current_move, DanceQueueMove):
419
+ result["type"] = "dance"
420
+ result["emotion_name"] = getattr(current_move, "move_name", None)
421
+ result["pad_values"] = None
422
+ else:
423
+ result["type"] = "unknown"
424
+ result["emotion_name"] = None
425
+ result["pad_values"] = None
426
+
427
+ return result
428
+
429
  def set_listening(self, listening: bool) -> None:
430
  """Enable or disable listening mode without touching shared state directly.
431
 
src/feeling_machine/static/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Feeling Machine – Settings</title>
7
  <link rel="stylesheet" href="/static/style.css" />
8
  </head>
9
  <body>
@@ -14,11 +14,69 @@
14
  </div>
15
  <div class="container">
16
  <header class="hero">
17
- <div class="pill">Headless control</div>
18
- <h1>Feeling Machine</h1>
19
- <p class="subtitle">Configure your OpenAI API key for the conversation app.</p>
20
  </header>
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  <div id="configured" class="panel hidden">
23
  <div class="panel-heading">
24
  <div>
@@ -51,4 +109,4 @@
51
 
52
  <script src="/static/main.js"></script>
53
  </body>
54
- </html>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Feeling Machine</title>
7
  <link rel="stylesheet" href="/static/style.css" />
8
  </head>
9
  <body>
 
14
  </div>
15
  <div class="container">
16
  <header class="hero">
17
+ <div class="pill">Feeling Machine</div>
18
+ <h1>Emotion Monitor</h1>
19
+ <p class="subtitle" id="subtitle">Configure your OpenAI API key to get started.</p>
20
  </header>
21
 
22
+ <!-- Emotion Status Panel (shown when key is configured) -->
23
+ <div id="emotion-panel" class="panel hidden">
24
+ <div class="panel-heading">
25
+ <div>
26
+ <p class="eyebrow">Now Playing</p>
27
+ <h2 id="emotion-title">Idle</h2>
28
+ </div>
29
+ <span id="emotion-chip" class="chip">Ready</span>
30
+ </div>
31
+
32
+ <div id="emotion-details" class="emotion-details">
33
+ <div class="emotion-row">
34
+ <span class="emotion-label">Status</span>
35
+ <span id="emotion-status" class="emotion-value">Waiting for emotion...</span>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Progress bar -->
40
+ <div id="progress-container" class="progress-container hidden">
41
+ <div class="progress-bar">
42
+ <div id="progress-fill" class="progress-fill"></div>
43
+ </div>
44
+ <div class="progress-text">
45
+ <span id="progress-elapsed">0.0s</span>
46
+ <span id="progress-duration">/ 0.0s</span>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- PAD Values (shown for PAD emotions) -->
51
+ <div id="pad-section" class="pad-section hidden">
52
+ <p class="section-title">PAD Values</p>
53
+ <div class="pad-grid">
54
+ <div class="pad-item">
55
+ <div class="pad-bar-container">
56
+ <div id="pad-p-bar" class="pad-bar pad-pleasure"></div>
57
+ </div>
58
+ <span class="pad-label">Pleasure</span>
59
+ <span id="pad-p-value" class="pad-value">0.00</span>
60
+ </div>
61
+ <div class="pad-item">
62
+ <div class="pad-bar-container">
63
+ <div id="pad-a-bar" class="pad-bar pad-arousal"></div>
64
+ </div>
65
+ <span class="pad-label">Arousal</span>
66
+ <span id="pad-a-value" class="pad-value">0.00</span>
67
+ </div>
68
+ <div class="pad-item">
69
+ <div class="pad-bar-container">
70
+ <div id="pad-d-bar" class="pad-bar pad-dominance"></div>
71
+ </div>
72
+ <span class="pad-label">Dominance</span>
73
+ <span id="pad-d-value" class="pad-value">0.00</span>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <!-- API Key Configuration Panel -->
80
  <div id="configured" class="panel hidden">
81
  <div class="panel-heading">
82
  <div>
 
109
 
110
  <script src="/static/main.js"></script>
111
  </body>
112
+ </html>
src/feeling_machine/static/main.js CHANGED
@@ -62,24 +62,165 @@ function show(el, flag) {
62
  el.classList.toggle("hidden", !flag);
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  async function init() {
66
  const loading = document.getElementById("loading");
67
  const statusEl = document.getElementById("status");
68
  const formPanel = document.getElementById("form-panel");
69
  const configuredPanel = document.getElementById("configured");
 
70
  const saveBtn = document.getElementById("save-btn");
71
  const changeKeyBtn = document.getElementById("change-key-btn");
72
  const input = document.getElementById("api-key");
 
73
 
74
  show(loading, true);
75
  show(formPanel, false);
76
  show(configuredPanel, false);
 
77
 
78
  const st = (await waitForStatus()) || { has_key: false };
79
 
80
  if (st.has_key) {
 
 
81
  show(configuredPanel, true);
 
82
  } else {
 
83
  show(formPanel, true);
84
  }
85
  show(loading, false);
@@ -133,4 +274,4 @@ async function init() {
133
  });
134
  }
135
 
136
- window.addEventListener("DOMContentLoaded", init);
 
62
  el.classList.toggle("hidden", !flag);
63
  }
64
 
65
+ // Emotion status polling
66
+ let emotionPollInterval = null;
67
+
68
+ function getEmotionTypeLabel(type) {
69
+ switch (type) {
70
+ case "pad_emotion": return "Generated Emotion";
71
+ case "classic_emotion": return "Classic Emotion";
72
+ case "dance": return "Dance";
73
+ case "breathing": return "Breathing";
74
+ default: return "Unknown";
75
+ }
76
+ }
77
+
78
+ function getEmotionChipClass(type, playing) {
79
+ if (!playing) return "chip";
80
+ switch (type) {
81
+ case "pad_emotion": return "chip chip-pad";
82
+ case "classic_emotion": return "chip chip-classic";
83
+ case "dance": return "chip chip-dance";
84
+ case "breathing": return "chip chip-breathing";
85
+ default: return "chip chip-ok";
86
+ }
87
+ }
88
+
89
+ function updatePadBar(barEl, value) {
90
+ // PAD values are -1 to 1, convert to 0-100% positioning
91
+ // value of 0 = 50%, value of -1 = 0%, value of 1 = 100%
92
+ const percent = ((value + 1) / 2) * 100;
93
+ barEl.style.width = `${percent}%`;
94
+ }
95
+
96
+ async function fetchEmotionStatus() {
97
+ try {
98
+ const resp = await fetchWithTimeout("/emotion_status", {}, 1000);
99
+ if (!resp.ok) return null;
100
+ return await resp.json();
101
+ } catch (e) {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function updateEmotionUI(status) {
107
+ const emotionTitle = document.getElementById("emotion-title");
108
+ const emotionChip = document.getElementById("emotion-chip");
109
+ const emotionStatus = document.getElementById("emotion-status");
110
+ const progressContainer = document.getElementById("progress-container");
111
+ const progressFill = document.getElementById("progress-fill");
112
+ const progressElapsed = document.getElementById("progress-elapsed");
113
+ const progressDuration = document.getElementById("progress-duration");
114
+ const padSection = document.getElementById("pad-section");
115
+
116
+ if (!status || !status.playing) {
117
+ emotionTitle.textContent = "Idle";
118
+ emotionChip.textContent = "Ready";
119
+ emotionChip.className = "chip";
120
+ emotionStatus.textContent = "Waiting for emotion...";
121
+ show(progressContainer, false);
122
+ show(padSection, false);
123
+ return;
124
+ }
125
+
126
+ // Update title
127
+ if (status.emotion_name) {
128
+ emotionTitle.textContent = status.emotion_name;
129
+ } else if (status.type === "pad_emotion") {
130
+ emotionTitle.textContent = "PAD Emotion";
131
+ } else if (status.type === "breathing") {
132
+ emotionTitle.textContent = "Breathing";
133
+ } else {
134
+ emotionTitle.textContent = getEmotionTypeLabel(status.type);
135
+ }
136
+
137
+ // Update chip
138
+ emotionChip.textContent = getEmotionTypeLabel(status.type);
139
+ emotionChip.className = getEmotionChipClass(status.type, true);
140
+
141
+ // Update status text
142
+ if (status.type === "breathing") {
143
+ emotionStatus.textContent = "Robot is breathing while idle";
144
+ } else if (status.type === "pad_emotion") {
145
+ emotionStatus.textContent = "Playing procedurally generated emotion";
146
+ } else if (status.type === "classic_emotion") {
147
+ emotionStatus.textContent = "Playing pre-recorded animation";
148
+ } else if (status.type === "dance") {
149
+ emotionStatus.textContent = "Dancing!";
150
+ } else {
151
+ emotionStatus.textContent = "Playing...";
152
+ }
153
+
154
+ // Update progress bar
155
+ if (status.duration && status.progress !== null) {
156
+ show(progressContainer, true);
157
+ progressFill.style.width = `${status.progress * 100}%`;
158
+ progressElapsed.textContent = `${status.elapsed.toFixed(1)}s`;
159
+ progressDuration.textContent = `/ ${status.duration.toFixed(1)}s`;
160
+ } else {
161
+ show(progressContainer, false);
162
+ }
163
+
164
+ // Update PAD values
165
+ if (status.pad_values) {
166
+ show(padSection, true);
167
+ document.getElementById("pad-p-value").textContent = status.pad_values.P.toFixed(2);
168
+ document.getElementById("pad-a-value").textContent = status.pad_values.A.toFixed(2);
169
+ document.getElementById("pad-d-value").textContent = status.pad_values.D.toFixed(2);
170
+ updatePadBar(document.getElementById("pad-p-bar"), status.pad_values.P);
171
+ updatePadBar(document.getElementById("pad-a-bar"), status.pad_values.A);
172
+ updatePadBar(document.getElementById("pad-d-bar"), status.pad_values.D);
173
+ } else {
174
+ show(padSection, false);
175
+ }
176
+ }
177
+
178
+ function startEmotionPolling() {
179
+ if (emotionPollInterval) return;
180
+
181
+ async function poll() {
182
+ const status = await fetchEmotionStatus();
183
+ if (status) {
184
+ updateEmotionUI(status);
185
+ }
186
+ }
187
+
188
+ poll(); // Initial fetch
189
+ emotionPollInterval = setInterval(poll, 200); // Poll every 200ms for smooth updates
190
+ }
191
+
192
+ function stopEmotionPolling() {
193
+ if (emotionPollInterval) {
194
+ clearInterval(emotionPollInterval);
195
+ emotionPollInterval = null;
196
+ }
197
+ }
198
+
199
  async function init() {
200
  const loading = document.getElementById("loading");
201
  const statusEl = document.getElementById("status");
202
  const formPanel = document.getElementById("form-panel");
203
  const configuredPanel = document.getElementById("configured");
204
+ const emotionPanel = document.getElementById("emotion-panel");
205
  const saveBtn = document.getElementById("save-btn");
206
  const changeKeyBtn = document.getElementById("change-key-btn");
207
  const input = document.getElementById("api-key");
208
+ const subtitle = document.getElementById("subtitle");
209
 
210
  show(loading, true);
211
  show(formPanel, false);
212
  show(configuredPanel, false);
213
+ show(emotionPanel, false);
214
 
215
  const st = (await waitForStatus()) || { has_key: false };
216
 
217
  if (st.has_key) {
218
+ subtitle.textContent = "Monitor emotions in real-time.";
219
+ show(emotionPanel, true);
220
  show(configuredPanel, true);
221
+ startEmotionPolling();
222
  } else {
223
+ subtitle.textContent = "Configure your OpenAI API key to get started.";
224
  show(formPanel, true);
225
  }
226
  show(loading, false);
 
274
  });
275
  }
276
 
277
+ window.addEventListener("DOMContentLoaded", init);
src/feeling_machine/static/style.css CHANGED
@@ -11,6 +11,9 @@
11
  --accent: #45c4ff;
12
  --accent-2: #5ef0c1;
13
  --shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
 
 
 
14
  }
15
 
16
  * { box-sizing: border-box; }
@@ -60,7 +63,7 @@ body {
60
  .container {
61
  position: relative;
62
  max-width: 600px;
63
- margin: 10vh auto;
64
  padding: 0 24px 40px;
65
  z-index: 1;
66
  }
@@ -128,12 +131,33 @@ body {
128
  color: var(--text);
129
  background: rgba(255, 255, 255, 0.08);
130
  border: 1px solid var(--border);
 
131
  }
132
  .chip-ok {
133
  background: rgba(76, 224, 179, 0.15);
134
  color: var(--ok);
135
  border-color: rgba(76, 224, 179, 0.4);
136
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  .hidden { display: none; }
139
  label {
@@ -203,8 +227,106 @@ button.ghost:hover { border-color: rgba(94, 240, 193, 0.4); }
203
  .status.warn { color: var(--warn); }
204
  .status.error { color: var(--error); }
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  @media (max-width: 760px) {
207
  .hero h1 { font-size: 26px; }
208
  button { width: 100%; justify-content: center; }
209
  .actions { flex-direction: column; align-items: flex-start; }
210
- }
 
 
11
  --accent: #45c4ff;
12
  --accent-2: #5ef0c1;
13
  --shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
14
+ --pad-pleasure: #ff6b9d;
15
+ --pad-arousal: #ffc85c;
16
+ --pad-dominance: #6b8cff;
17
  }
18
 
19
  * { box-sizing: border-box; }
 
63
  .container {
64
  position: relative;
65
  max-width: 600px;
66
+ margin: 6vh auto;
67
  padding: 0 24px 40px;
68
  z-index: 1;
69
  }
 
131
  color: var(--text);
132
  background: rgba(255, 255, 255, 0.08);
133
  border: 1px solid var(--border);
134
+ transition: all 0.2s ease;
135
  }
136
  .chip-ok {
137
  background: rgba(76, 224, 179, 0.15);
138
  color: var(--ok);
139
  border-color: rgba(76, 224, 179, 0.4);
140
  }
141
+ .chip-pad {
142
+ background: rgba(255, 107, 157, 0.15);
143
+ color: var(--pad-pleasure);
144
+ border-color: rgba(255, 107, 157, 0.4);
145
+ }
146
+ .chip-classic {
147
+ background: rgba(69, 196, 255, 0.15);
148
+ color: var(--accent);
149
+ border-color: rgba(69, 196, 255, 0.4);
150
+ }
151
+ .chip-dance {
152
+ background: rgba(255, 200, 92, 0.15);
153
+ color: var(--pad-arousal);
154
+ border-color: rgba(255, 200, 92, 0.4);
155
+ }
156
+ .chip-breathing {
157
+ background: rgba(107, 140, 255, 0.15);
158
+ color: var(--pad-dominance);
159
+ border-color: rgba(107, 140, 255, 0.4);
160
+ }
161
 
162
  .hidden { display: none; }
163
  label {
 
227
  .status.warn { color: var(--warn); }
228
  .status.error { color: var(--error); }
229
 
230
+ /* Emotion Panel Styles */
231
+ .emotion-details {
232
+ margin-top: 12px;
233
+ }
234
+ .emotion-row {
235
+ display: flex;
236
+ justify-content: space-between;
237
+ align-items: center;
238
+ padding: 8px 0;
239
+ border-bottom: 1px solid var(--border);
240
+ }
241
+ .emotion-row:last-child {
242
+ border-bottom: none;
243
+ }
244
+ .emotion-label {
245
+ font-size: 13px;
246
+ color: var(--muted);
247
+ }
248
+ .emotion-value {
249
+ font-size: 14px;
250
+ color: var(--text);
251
+ }
252
+
253
+ /* Progress Bar */
254
+ .progress-container {
255
+ margin-top: 16px;
256
+ }
257
+ .progress-bar {
258
+ height: 6px;
259
+ background: rgba(255, 255, 255, 0.1);
260
+ border-radius: 3px;
261
+ overflow: hidden;
262
+ }
263
+ .progress-fill {
264
+ height: 100%;
265
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
266
+ border-radius: 3px;
267
+ transition: width 0.15s ease;
268
+ }
269
+ .progress-text {
270
+ display: flex;
271
+ justify-content: space-between;
272
+ margin-top: 6px;
273
+ font-size: 12px;
274
+ color: var(--muted);
275
+ }
276
+
277
+ /* PAD Section */
278
+ .pad-section {
279
+ margin-top: 20px;
280
+ padding-top: 16px;
281
+ border-top: 1px solid var(--border);
282
+ }
283
+ .section-title {
284
+ margin: 0 0 12px;
285
+ font-size: 12px;
286
+ text-transform: uppercase;
287
+ letter-spacing: 0.5px;
288
+ color: var(--muted);
289
+ }
290
+ .pad-grid {
291
+ display: grid;
292
+ grid-template-columns: repeat(3, 1fr);
293
+ gap: 16px;
294
+ }
295
+ .pad-item {
296
+ text-align: center;
297
+ }
298
+ .pad-bar-container {
299
+ height: 8px;
300
+ background: rgba(255, 255, 255, 0.1);
301
+ border-radius: 4px;
302
+ overflow: hidden;
303
+ margin-bottom: 8px;
304
+ }
305
+ .pad-bar {
306
+ height: 100%;
307
+ border-radius: 4px;
308
+ transition: width 0.2s ease;
309
+ min-width: 2px;
310
+ }
311
+ .pad-pleasure { background: linear-gradient(90deg, #ff4777, var(--pad-pleasure)); }
312
+ .pad-arousal { background: linear-gradient(90deg, #ff8c00, var(--pad-arousal)); }
313
+ .pad-dominance { background: linear-gradient(90deg, #4a5eff, var(--pad-dominance)); }
314
+ .pad-label {
315
+ display: block;
316
+ font-size: 11px;
317
+ color: var(--muted);
318
+ margin-bottom: 2px;
319
+ }
320
+ .pad-value {
321
+ display: block;
322
+ font-size: 16px;
323
+ font-weight: 600;
324
+ color: var(--text);
325
+ }
326
+
327
  @media (max-width: 760px) {
328
  .hero h1 { font-size: 26px; }
329
  button { width: 100%; justify-content: center; }
330
  .actions { flex-direction: column; align-items: flex-start; }
331
+ .pad-grid { grid-template-columns: 1fr; gap: 12px; }
332
+ }