JuanCS-Dev commited on
Commit
ea7430b
·
verified ·
1 Parent(s): f52ff93

Deploy: streamer Stream Deck dashboard at /studio

Browse files
bot/dashboard_parity_gate.py CHANGED
@@ -364,6 +364,17 @@ PARITY_CONTRACT: tuple[ParityContractEntry, ...] = (
364
  backend_test_files=("bot/tests/scientific/test_phase29_production_scientific.py",),
365
  route_snippet="/api/coaching/churn-risk",
366
  ),
 
 
 
 
 
 
 
 
 
 
 
367
  ParityContractEntry(
368
  method="GET",
369
  backend_route="/api/segments",
 
364
  backend_test_files=("bot/tests/scientific/test_phase29_production_scientific.py",),
365
  route_snippet="/api/coaching/churn-risk",
366
  ),
367
+ ParityContractEntry(
368
+ method="GET",
369
+ backend_route="/api/studio/stream",
370
+ domain="studio",
371
+ dashboard_surface="studio_sse",
372
+ status="headless_approved",
373
+ headless_reason="SSE agregado do /studio (EventSource); substitui 8 polls, sem painel separado.",
374
+ planned_phase="phase-30-studio-realtime",
375
+ backend_test_files=("bot/tests/scientific/test_phase30_studio_stream_scientific.py",),
376
+ route_snippet="/api/studio/stream",
377
+ ),
378
  ParityContractEntry(
379
  method="GET",
380
  backend_route="/api/segments",
bot/dashboard_server_routes.py CHANGED
@@ -1,5 +1,7 @@
1
  import asyncio
 
2
  import mimetypes
 
3
  from collections.abc import Callable
4
  from typing import Any
5
 
@@ -727,6 +729,52 @@ def _handle_get_segments(handler: Any, _query: dict[str, list[str]]) -> None:
727
  handler._send_json({"ok": True, "segments": available_segments()}, status_code=200)
728
 
729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  def _handle_get_voice_messages(handler: Any, query: dict[str, list[str]]) -> None:
731
  since_raw = str((query.get("since") or ["0"])[0] or "0")
732
  try:
@@ -825,6 +873,7 @@ _GET_ROUTE_HANDLERS: dict[str, Callable[[Any, dict[str, list[str]]], None]] = {
825
  "/api/voice/audio": _handle_get_voice_audio,
826
  "/api/coaching/churn-risk": _handle_get_coaching_churn,
827
  "/api/segments": _handle_get_segments,
 
828
  "/api/observability/conversions": _handle_get_revenue_conversions,
829
  "/api/webhooks": _handle_get_webhooks,
830
  "/api/persona-profile": _handle_get_persona_profile,
@@ -1103,6 +1152,7 @@ __all__ = [
1103
  "build_post_stream_report_payload",
1104
  "build_semantic_memory_payload",
1105
  "build_sentiment_scores_payload",
 
1106
  "handle_get",
1107
  "handle_get_config_js",
1108
  "handle_post",
 
1
  import asyncio
2
+ import json
3
  import mimetypes
4
+ import time
5
  from collections.abc import Callable
6
  from typing import Any
7
 
 
729
  handler._send_json({"ok": True, "segments": available_segments()}, status_code=200)
730
 
731
 
732
+ STUDIO_STREAM_INTERVAL_SECONDS = 2.0
733
+ STUDIO_STREAM_MAX_SECONDS = 300.0
734
+
735
+
736
+ def build_studio_snapshot(channel_id: str | None = None) -> dict[str, Any]:
737
+ """Single aggregated payload for /studio — replaces 8 separate polls."""
738
+ safe_channel = (channel_id or "default").strip().lower() or "default"
739
+ queue_payload = control_plane.list_actions(status=None, limit=20)
740
+ return {
741
+ "ok": True,
742
+ "ts": time.time(),
743
+ "channel": safe_channel,
744
+ "control_plane": build_control_plane_state_payload(),
745
+ "sentiment": build_sentiment_scores_payload(safe_channel),
746
+ "churn": build_coaching_churn_payload(safe_channel),
747
+ "observability": build_observability_payload(safe_channel),
748
+ "queue": {"ok": True, "mode": TWITCH_CHAT_MODE, **queue_payload},
749
+ "clips": {"ok": True, "items": clip_jobs.get_jobs()},
750
+ "vision": {"ok": True, **vision_runtime.get_status()},
751
+ "hud": {"ok": True, "messages": hud_runtime.get_messages(since=0)},
752
+ }
753
+
754
+
755
+ def _handle_get_studio_stream(handler: Any, query: dict[str, list[str]]) -> None:
756
+ """SSE stream of the studio snapshot. Caps the connection so the thread is
757
+ freed periodically; the EventSource client reconnects automatically."""
758
+ channel_id = _resolve_channel_id(query, required=False)
759
+ handler.send_response(200)
760
+ handler._send_cors_headers()
761
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
762
+ handler.send_header("Cache-Control", "no-store")
763
+ handler.send_header("Connection", "keep-alive")
764
+ handler.send_header("X-Accel-Buffering", "no") # disable proxy buffering
765
+ handler.end_headers()
766
+
767
+ deadline = time.time() + STUDIO_STREAM_MAX_SECONDS
768
+ try:
769
+ while time.time() < deadline:
770
+ data = json.dumps(build_studio_snapshot(channel_id), ensure_ascii=False)
771
+ handler.wfile.write(f"data: {data}\n\n".encode())
772
+ handler.wfile.flush()
773
+ time.sleep(STUDIO_STREAM_INTERVAL_SECONDS)
774
+ except (BrokenPipeError, ConnectionResetError, OSError):
775
+ return # client disconnected — let the thread end cleanly
776
+
777
+
778
  def _handle_get_voice_messages(handler: Any, query: dict[str, list[str]]) -> None:
779
  since_raw = str((query.get("since") or ["0"])[0] or "0")
780
  try:
 
873
  "/api/voice/audio": _handle_get_voice_audio,
874
  "/api/coaching/churn-risk": _handle_get_coaching_churn,
875
  "/api/segments": _handle_get_segments,
876
+ "/api/studio/stream": _handle_get_studio_stream,
877
  "/api/observability/conversions": _handle_get_revenue_conversions,
878
  "/api/webhooks": _handle_get_webhooks,
879
  "/api/persona-profile": _handle_get_persona_profile,
 
1152
  "build_post_stream_report_payload",
1153
  "build_semantic_memory_payload",
1154
  "build_sentiment_scores_payload",
1155
+ "build_studio_snapshot",
1156
  "handle_get",
1157
  "handle_get_config_js",
1158
  "handle_post",
bot/tests/scientific/test_phase30_studio_stream_scientific.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scientific test suite for Phase 30 (Fase S0): studio real-time data layer.
2
+
3
+ Tests validate behavioral contracts of the aggregated SSE snapshot, NOT
4
+ implementation details:
5
+ 1. build_studio_snapshot aggregates every studio sub-payload into one object.
6
+ 2. The aggregated snapshot is JSON-serializable (it must stream as SSE data).
7
+ 3. The /api/studio/stream route is registered (and declared in the parity gate).
8
+ """
9
+
10
+ import json
11
+
12
+ import bot.dashboard_server_routes as routes
13
+
14
+
15
+ class TestStudioSnapshotAggregates:
16
+ """Prove one snapshot replaces the 8 separate studio polls."""
17
+
18
+ def test_snapshot_has_all_sections(self):
19
+ snap = routes.build_studio_snapshot("default")
20
+ for key in (
21
+ "control_plane",
22
+ "sentiment",
23
+ "churn",
24
+ "observability",
25
+ "queue",
26
+ "clips",
27
+ "vision",
28
+ "hud",
29
+ ):
30
+ assert key in snap, f"missing section: {key}"
31
+ assert snap["ok"] is True
32
+ assert snap["channel"] == "default"
33
+
34
+ def test_snapshot_is_json_serializable(self):
35
+ # Must serialize cleanly — it is streamed as `data: <json>\n\n`.
36
+ payload = json.dumps(routes.build_studio_snapshot("default"), ensure_ascii=False)
37
+ assert payload.startswith("{")
38
+ assert json.loads(payload)["ok"] is True
39
+
40
+ def test_channel_is_normalized(self):
41
+ assert routes.build_studio_snapshot("Canal_X")["channel"] == "canal_x"
42
+
43
+
44
+ class TestStudioStreamRoute:
45
+ """Prove the SSE endpoint is wired."""
46
+
47
+ def test_stream_route_registered(self):
48
+ assert "/api/studio/stream" in routes._GET_ROUTE_HANDLERS
49
+
50
+ def test_stream_interval_is_sane(self):
51
+ # Must push fast enough for <1s-feel state but not hammer the LLM/db.
52
+ assert 0 < routes.STUDIO_STREAM_INTERVAL_SECONDS <= 5
53
+ assert routes.STUDIO_STREAM_MAX_SECONDS >= 60
dashboard/features/studio/main.js CHANGED
@@ -3,6 +3,7 @@
3
  // each tile to a real bot endpoint. Vanilla ES module, no build step.
4
  import * as api from "./api.js";
5
  import { ICONS, hydrateIcons } from "./icons.js";
 
6
 
7
  const TOKEN_KEY = "byte_dashboard_admin_token";
8
  const POLL_MS = 5000;
@@ -47,7 +48,7 @@ function saveTokenFromGate() {
47
  if (!value) return;
48
  localStorage.setItem(TOKEN_KEY, value);
49
  closeGate();
50
- tick();
51
  }
52
 
53
  // ---------- modals ----------
@@ -161,15 +162,7 @@ function renderVision(payload) {
161
  }
162
 
163
  function renderQueue(payload) {
164
- const items = Array.isArray(payload.items)
165
- ? payload.items
166
- : Array.isArray(payload.queue)
167
- ? payload.queue
168
- : [];
169
- state.pendingActions = items.filter((it) => {
170
- const s = String(it.status || it.state || "pending").toLowerCase();
171
- return s === "pending" || s === "awaiting" || s === "open";
172
- });
173
  const badge = $("approveBadge");
174
  const n = state.pendingActions.length;
175
  badge.textContent = String(n);
@@ -180,38 +173,53 @@ function renderQueue(payload) {
180
  $("tileApprove").disabled = n === 0;
181
  }
182
 
183
- function renderTicker(messages) {
 
184
  const list = $("tickerList");
185
- if (!messages.length) {
186
- if (!state.bootedTicker) {
187
- list.innerHTML = '<li class="ticker-empty">Sem atividade ainda…</li>';
188
- state.bootedTicker = true;
189
- }
190
  return;
191
  }
192
- if (!state.bootedTicker) list.innerHTML = "";
193
- for (const m of messages) {
194
  const li = document.createElement("li");
195
  li.className = "ticker-item";
196
  const t = document.createElement("time");
197
  const ts = Number(m.ts || 0) * 1000;
198
  t.textContent = ts
199
- ? new Date(ts).toLocaleTimeString([], {
200
- hour: "2-digit",
201
- minute: "2-digit",
202
- })
203
  : "";
204
  const span = document.createElement("span");
205
  span.textContent = m.text || "";
206
  li.append(t, span);
207
- list.prepend(li);
208
- state.hudSince = Math.max(state.hudSince, Number(m.ts || 0));
209
  }
210
- while (list.children.length > 12) list.removeChild(list.lastChild);
211
- state.bootedTicker = true;
212
  }
213
 
214
- // ---------- polling ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  async function tick() {
216
  if (!hasToken()) {
217
  openGate();
@@ -220,7 +228,7 @@ async function tick() {
220
  const [cp, sent, hud, clips, vision, queue, obs, churn] = await Promise.allSettled([
221
  api.getControlPlane(),
222
  api.getSentiment(state.channel),
223
- api.getHudMessages(state.hudSince),
224
  api.getClipJobs(),
225
  api.getVisionStatus(),
226
  api.getActionQueue(20),
@@ -233,29 +241,17 @@ async function tick() {
233
  return;
234
  }
235
  closeGate();
236
-
237
- if (cp.status === "fulfilled") {
238
- const autonomy = cp.value.autonomy || {};
239
- state.online = true;
240
- state.suspended = Boolean(autonomy.suspended);
241
- state.mode = cp.value.mode || state.mode;
242
- } else {
243
- state.online = false;
244
- }
245
- renderStatus();
246
-
247
- if (sent.status === "fulfilled") {
248
- state.channel = sent.value.channel_id || state.channel;
249
- renderMood(sent.value);
250
- }
251
- if (obs.status === "fulfilled")
252
- renderUptime(obs.value && obs.value.bot && obs.value.bot.uptime_minutes);
253
- if (hud.status === "fulfilled")
254
- renderTicker(Array.isArray(hud.value.messages) ? hud.value.messages : []);
255
- if (clips.status === "fulfilled") renderClips(clips.value);
256
- if (vision.status === "fulfilled") renderVision(vision.value);
257
- if (queue.status === "fulfilled") renderQueue(queue.value);
258
- if (churn.status === "fulfilled") renderRetention(churn.value);
259
  }
260
 
261
  const RETENTION_LABEL = {
@@ -522,14 +518,65 @@ function bind() {
522
  $("approveClose").addEventListener("click", () => closeModal("approveModal"));
523
  }
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  function start() {
526
  hydrateIcons();
527
  // Give the pause tile icon host a stable id for swapping.
528
  $("tilePause").querySelector(".tile-ico").id = "pauseIco";
529
  bind();
530
- if (!hasToken()) openGate();
531
- tick();
532
- setInterval(tick, POLL_MS);
533
  }
534
 
535
  if (document.readyState === "loading") {
 
3
  // each tile to a real bot endpoint. Vanilla ES module, no build step.
4
  import * as api from "./api.js";
5
  import { ICONS, hydrateIcons } from "./icons.js";
6
+ import { deriveStudioState, pendingActions, tickerEntries } from "./snapshot.js";
7
 
8
  const TOKEN_KEY = "byte_dashboard_admin_token";
9
  const POLL_MS = 5000;
 
48
  if (!value) return;
49
  localStorage.setItem(TOKEN_KEY, value);
50
  closeGate();
51
+ connectRealtime();
52
  }
53
 
54
  // ---------- modals ----------
 
162
  }
163
 
164
  function renderQueue(payload) {
165
+ state.pendingActions = pendingActions({ queue: payload });
 
 
 
 
 
 
 
 
166
  const badge = $("approveBadge");
167
  const n = state.pendingActions.length;
168
  badge.textContent = String(n);
 
173
  $("tileApprove").disabled = n === 0;
174
  }
175
 
176
+ function renderTicker(entries) {
177
+ // Idempotent: rebuilt from the full recent list each snapshot (SSE-safe).
178
  const list = $("tickerList");
179
+ if (!entries.length) {
180
+ const empty = document.createElement("li");
181
+ empty.className = "ticker-empty";
182
+ empty.textContent = "Sem atividade ainda…";
183
+ list.replaceChildren(empty);
184
  return;
185
  }
186
+ const frag = document.createDocumentFragment();
187
+ for (const m of entries) {
188
  const li = document.createElement("li");
189
  li.className = "ticker-item";
190
  const t = document.createElement("time");
191
  const ts = Number(m.ts || 0) * 1000;
192
  t.textContent = ts
193
+ ? new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
 
 
 
194
  : "";
195
  const span = document.createElement("span");
196
  span.textContent = m.text || "";
197
  li.append(t, span);
198
+ frag.appendChild(li);
 
199
  }
200
+ list.replaceChildren(frag);
 
201
  }
202
 
203
+ // ---------- render from a snapshot (shared by SSE + polling) ----------
204
+ function applySnapshot(snap) {
205
+ if (!snap) return;
206
+ const derived = deriveStudioState(snap);
207
+ state.online = derived.online;
208
+ state.suspended = derived.suspended;
209
+ state.mode = derived.mode;
210
+ state.channel = derived.channel;
211
+ renderStatus();
212
+ if (snap.sentiment) renderMood(snap.sentiment);
213
+ if (snap.observability)
214
+ renderUptime(snap.observability.bot && snap.observability.bot.uptime_minutes);
215
+ if (snap.hud) renderTicker(tickerEntries(snap.hud.messages));
216
+ if (snap.clips) renderClips(snap.clips);
217
+ if (snap.vision) renderVision(snap.vision);
218
+ if (snap.queue) renderQueue(snap.queue);
219
+ if (snap.churn) renderRetention(snap.churn);
220
+ }
221
+
222
+ // ---------- polling fallback (one-shot + interval) ----------
223
  async function tick() {
224
  if (!hasToken()) {
225
  openGate();
 
228
  const [cp, sent, hud, clips, vision, queue, obs, churn] = await Promise.allSettled([
229
  api.getControlPlane(),
230
  api.getSentiment(state.channel),
231
+ api.getHudMessages(0),
232
  api.getClipJobs(),
233
  api.getVisionStatus(),
234
  api.getActionQueue(20),
 
241
  return;
242
  }
243
  closeGate();
244
+ applySnapshot({
245
+ ok: cp.status === "fulfilled",
246
+ control_plane: cp.status === "fulfilled" ? cp.value : null,
247
+ sentiment: sent.status === "fulfilled" ? sent.value : null,
248
+ hud: hud.status === "fulfilled" ? hud.value : null,
249
+ clips: clips.status === "fulfilled" ? clips.value : null,
250
+ vision: vision.status === "fulfilled" ? vision.value : null,
251
+ queue: queue.status === "fulfilled" ? queue.value : null,
252
+ observability: obs.status === "fulfilled" ? obs.value : null,
253
+ churn: churn.status === "fulfilled" ? churn.value : null,
254
+ });
 
 
 
 
 
 
 
 
 
 
 
 
255
  }
256
 
257
  const RETENTION_LABEL = {
 
518
  $("approveClose").addEventListener("click", () => closeModal("approveModal"));
519
  }
520
 
521
+ // ---------- realtime (SSE) with polling fallback ----------
522
+ let eventSource = null;
523
+ let pollTimer = null;
524
+ let sseFailures = 0;
525
+
526
+ function startPolling() {
527
+ if (pollTimer) return;
528
+ tick();
529
+ pollTimer = setInterval(tick, POLL_MS);
530
+ }
531
+
532
+ function connectRealtime() {
533
+ const token = (localStorage.getItem(TOKEN_KEY) || "").trim();
534
+ if (!token) {
535
+ openGate();
536
+ return;
537
+ }
538
+ if (typeof EventSource === "undefined") {
539
+ startPolling();
540
+ return;
541
+ }
542
+ try {
543
+ eventSource = new EventSource(
544
+ `/api/studio/stream?channel=${encodeURIComponent(state.channel)}&auth=${encodeURIComponent(token)}`,
545
+ );
546
+ } catch (_e) {
547
+ startPolling();
548
+ return;
549
+ }
550
+ eventSource.onmessage = (event) => {
551
+ sseFailures = 0;
552
+ if (pollTimer) {
553
+ clearInterval(pollTimer);
554
+ pollTimer = null;
555
+ }
556
+ closeGate();
557
+ try {
558
+ applySnapshot(JSON.parse(event.data));
559
+ } catch (_e) {
560
+ /* ignore a malformed frame; next one will arrive */
561
+ }
562
+ };
563
+ eventSource.onerror = () => {
564
+ // EventSource auto-reconnects; only fall back if it cannot re-establish.
565
+ sseFailures += 1;
566
+ if (sseFailures >= 2) {
567
+ eventSource.close();
568
+ eventSource = null;
569
+ startPolling();
570
+ }
571
+ };
572
+ }
573
+
574
  function start() {
575
  hydrateIcons();
576
  // Give the pause tile icon host a stable id for swapping.
577
  $("tilePause").querySelector(".tile-ico").id = "pauseIco";
578
  bind();
579
+ connectRealtime();
 
 
580
  }
581
 
582
  if (document.readyState === "loading") {
dashboard/features/studio/snapshot.js ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // dashboard/features/studio/snapshot.js
2
+ // Pure, DOM-free derivations from the aggregated /api/studio/stream snapshot.
3
+ // Kept side-effect-free so they can be unit-tested deterministically.
4
+
5
+ const PENDING_STATES = new Set(["pending", "awaiting", "open"]);
6
+
7
+ /** Derive the high-level studio state from a snapshot (no DOM). */
8
+ export function deriveStudioState(snap) {
9
+ const cp = (snap && snap.control_plane) || {};
10
+ const autonomy = cp.autonomy || {};
11
+ const sentiment = (snap && snap.sentiment) || {};
12
+ return {
13
+ online: Boolean(snap && snap.ok !== false),
14
+ suspended: Boolean(autonomy.suspended),
15
+ mode: cp.mode || "—",
16
+ channel: sentiment.channel_id || (snap && snap.channel) || "default",
17
+ pendingCount: pendingActions(snap).length,
18
+ };
19
+ }
20
+
21
+ /** Pending action-queue items from a snapshot (no DOM). */
22
+ export function pendingActions(snap) {
23
+ const queue = (snap && snap.queue) || {};
24
+ const items = Array.isArray(queue.items)
25
+ ? queue.items
26
+ : Array.isArray(queue.queue)
27
+ ? queue.queue
28
+ : [];
29
+ return items.filter((it) =>
30
+ PENDING_STATES.has(String(it.status || it.state || "pending").toLowerCase()),
31
+ );
32
+ }
33
+
34
+ /** Ticker messages, newest first, capped — idempotent for SSE full snapshots. */
35
+ export function tickerEntries(messages, limit = 12) {
36
+ if (!Array.isArray(messages)) return [];
37
+ return [...messages]
38
+ .sort((a, b) => Number(b.ts || 0) - Number(a.ts || 0))
39
+ .slice(0, limit);
40
+ }
dashboard/tests/studio_stream_merge.test.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ deriveStudioState,
6
+ pendingActions,
7
+ tickerEntries,
8
+ } from "../features/studio/snapshot.js";
9
+
10
+ test("deriveStudioState reads a full snapshot the same way SSE and polling produce it", () => {
11
+ const snap = {
12
+ ok: true,
13
+ channel: "gaules",
14
+ control_plane: { mode: "irc", autonomy: { suspended: true } },
15
+ sentiment: { channel_id: "gaules", avg: 0.4, count: 10 },
16
+ queue: { items: [{ status: "pending" }, { status: "approved" }] },
17
+ };
18
+ const s = deriveStudioState(snap);
19
+ assert.equal(s.online, true);
20
+ assert.equal(s.suspended, true);
21
+ assert.equal(s.mode, "irc");
22
+ assert.equal(s.channel, "gaules");
23
+ assert.equal(s.pendingCount, 1);
24
+ });
25
+
26
+ test("deriveStudioState degrades safely on empty/failed snapshot", () => {
27
+ assert.equal(deriveStudioState(null).online, false);
28
+ assert.equal(deriveStudioState({ ok: false }).online, false);
29
+ assert.equal(deriveStudioState({}).channel, "default");
30
+ });
31
+
32
+ test("pendingActions filters only open states", () => {
33
+ const snap = {
34
+ queue: {
35
+ items: [
36
+ { status: "pending" },
37
+ { status: "awaiting" },
38
+ { status: "open" },
39
+ { status: "approved" },
40
+ { status: "rejected" },
41
+ ],
42
+ },
43
+ };
44
+ assert.equal(pendingActions(snap).length, 3);
45
+ });
46
+
47
+ test("tickerEntries is idempotent: newest-first, capped, pure", () => {
48
+ const messages = [
49
+ { ts: 1, text: "a" },
50
+ { ts: 3, text: "c" },
51
+ { ts: 2, text: "b" },
52
+ ];
53
+ const out = tickerEntries(messages, 2);
54
+ assert.deepEqual(
55
+ out.map((m) => m.text),
56
+ ["c", "b"],
57
+ );
58
+ // Calling twice yields the same result (no mutation of input).
59
+ assert.deepEqual(tickerEntries(messages, 2), out);
60
+ assert.equal(messages[0].text, "a"); // input untouched
61
+ assert.deepEqual(tickerEntries(null), []);
62
+ });
docs/VALIDATION_REPORT_2026-06-29.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validation Report — Co-Streamer Implementation (brutalmente honesto)
2
+
3
+ **Data:** 2026-06-29 · **Escopo:** validação completa de tudo que esta sessão implementou (Fases 0–4), caçando bugs, inconsistências e gaps. Sem tour de vitória.
4
+
5
+ ---
6
+
7
+ ## 1. Veredito
8
+
9
+ O **código entregue na primeira passada NÃO era world-class.** Funcionava nos testes e nos smoke tests felizes, mas escondia **1 vulnerabilidade de segurança, 1 bug que quebraria a voz real, e 4 problemas de robustez/qualidade.** Esta passada de validação encontrou e corrigiu todos. **Agora** o backend está sólido: 1061 testes verdes, 85% cobertura, todos os endpoints validados ao vivo, com isolamento de erro por request.
10
+
11
+ A honestidade exige dizer: vários desses defeitos eu **deveria ter pego antes**. A validação só os expôs porque desta vez li o código com olhar adversarial em vez de confiar nos testes verdes.
12
+
13
+ ## 2. Defeitos encontrados e corrigidos
14
+
15
+ | # | Severidade | Defeito | Por que era grave | Fix |
16
+ | :--- | :--- | :--- | :--- | :--- |
17
+ | 1 | 🔴 **Segurança** | **XSS armazenado** no modal de aprovação (`studio/main.js`): `title`/`body`/`kind` de itens da fila injetados via `innerHTML` | Itens incluem **texto de viewer** (resgate de Channel Points). Um resgate com `<img onerror=...>` executaria no dashboard **autenticado** do streamer | DOM via `textContent`, zero `innerHTML` interpolado |
18
+ | 2 | 🔴 **Correção** | Corpo JSON do TTS montado com `repr()` do Python | `repr("você 'tá'")` → JSON inválido. Toda fala com aspas/apóstrofo/quebra de linha quebraria o ElevenLabs no minuto em que a key fosse plugada | `json.dumps` |
19
+ | 3 | 🟠 **Performance** | Comentário rodava chamada LLM **síncrona dentro do thread HTTP** do `/api/vision/ingest` | Cada frame "interessante" segurava a resposta do ingest por segundos | thread daemon fire-and-forget |
20
+ | 4 | 🟠 **Robustez** | `do_GET/POST/PUT` **não isolavam exceções** dos handlers | Um erro inesperado em qualquer endpoint deixava o cliente **pendurado** (sem resposta) | `_run_route_safely` → 500 limpo |
21
+ | 5 | 🟠 **Consistência** | `highlight_runtime` enfileirava clipe **sem checar** `clip_pipeline_enabled` (a visão checa) | Pluraria a fila de risco com candidatos mesmo com pipeline off | gate adicionado + wired na autonomia |
22
+ | 6 | 🟡 **Memória** | `regulars._seen` crescia **sem limite** (todo viewer, pra sempre) | Memory leak lento numa live longa | eviction LRU (cap 5000/canal) |
23
+ | 7 | 🟡 **Regressão (auto-infligida)** | O fix #4 (`_dispatch` como método) **quebrou 22 testes HTTP** (MagicMock dispatch) | — | refatorado pra função de módulo `_run_route_safely`; pego e corrigido na mesma passada |
24
+
25
+ ## 3. Gaps honestos que PERMANECEM
26
+
27
+ - **`channel_points_runtime` e `regulars_runtime` são lógica testada, mas NÃO estão ligadas** (0 referências de produção). Decisão consciente:
28
+ - **Channel Points** depende da subscription de redemption do EventSub (twitchio v3) + scope, e o bot roda em **IRC** hoje. Wirar com a API de redemption **adivinhada e não-validável** seria injetar código de baixa confiança num backend que precisa ser relógio suíço. Mantive o core (`process_redemption`) testado e correto.
29
+ - **Regulars** exige hook no caminho quente de mensagens e **muda comportamento ao vivo** (saudar viewers reais) — não injeto isso sem poder validar numa sessão real.
30
+ - **Veredito honesto:** essas duas features são "lógica pronta, integração pendente de um ambiente validável", **não** "entregues e rodando". Corrigi o doc do plano pra refletir isso.
31
+ - **Numeração de fases nos testes** reusa `phase26`/`phase27` entre conceitos diferentes — inconsistência cosmética com a convenção (1 fase = 1 conceito). Não corrigido (baixo valor, alto churn).
32
+ - **`studio` status** usa `innerHTML` com `state.mode`/`state.channel` (valores **do servidor**, sanitizados) — risco baixo, não user-input; deixei.
33
+
34
+ ## 4. Meta-Reflexão
35
+
36
+ 1. **Comportamento esperado re-derivado:** backend que roda "encaixado como relógio suíço" = sem XSS, sem bug de serialização, sem bloqueio de thread, sem leak, com isolamento de erro, sem código morto enganoso.
37
+ 2. **Rastreabilidade:** cada fix tem teste — XSS (DOM), JSON (`TestProviderRequestIsValidJson`), isolamento (`test_dispatch_*`), gate de highlight (`test_disabled_clip_pipeline_is_a_noop`), eviction (regulars), threading (comentário não bloqueia, validado e2e antes).
38
+ 3. **Suíte:** 1061 passed, 4 skipped, **85.37% cobertura**, hermética.
39
+ 4. **Regressão:** zero (a auto-infligida foi pega e corrigida).
40
+ 5. **Ao vivo:** 9 endpoints 200, 5 páginas 200, erro de payload → 400 limpo (deploy `f52ff93`).
41
+ 6. **Lição:** testes verdes ≠ código correto. Os 5 defeitos passaram por 1061 testes verdes porque os testes cobriam o caminho feliz, não o adversarial (texto com aspas, input malicioso, handler que explode). **Validação adversarial é obrigatória, não opcional.**
42
+
43
+ ## 5. Estado final
44
+
45
+ ✅ O backend que **roda** (vision, voice, commentary, segments, recap, churn, studio, todos os overlays) está sólido, blindado e validado ao vivo.
46
+ 🟡 Duas features (Channel Points, Regulars) têm o core testado mas aguardam integração validável.
47
+ 🔑 Ativação plena = checklist de operador em `docs/plan/README.md` (TTS key, Supabase, scopes Twitch, eventsub mode).
docs/plan/03-interaction.md CHANGED
@@ -22,4 +22,6 @@ Os 3 módulos foram construídos e testados; a **ativação ao vivo** de cada um
22
 
23
  **3.2 Regulars — ✅ código pronto + testado.** `regulars_runtime.note_chatter`: first-timer não saúda; retorno após gap → saúda 1x; chat contínuo não re-saúda. **Ativação:** chamar `note_chatter` no handler de mensagem (IRC/eventsub) e mandar a saudação via voz/chat; memória persistente entra com Supabase (Fase 1.3).
24
 
25
- **Go:** ✅ código concluído. Abuso de redemption é contido (voice reward limita 200 chars + sanitiza; não-voz vai pra fila). Memória de regulars é por sessão (in-memory), respeitando isolamento por canal.
 
 
 
22
 
23
  **3.2 Regulars — ✅ código pronto + testado.** `regulars_runtime.note_chatter`: first-timer não saúda; retorno após gap → saúda 1x; chat contínuo não re-saúda. **Ativação:** chamar `note_chatter` no handler de mensagem (IRC/eventsub) e mandar a saudação via voz/chat; memória persistente entra com Supabase (Fase 1.3).
24
 
25
+ **Go:** ✅ código concluído. Abuso de redemption é contido (voice reward limita 200 chars + sanitiza; não-voz vai pra fila). Memória de regulars é por sessão (in-memory, com eviction LRU), respeitando isolamento por canal.
26
+
27
+ > ⚠️ **Status honesto (ver [VALIDATION_REPORT](../VALIDATION_REPORT_2026-06-29.md)):** `channel_points_runtime` e `regulars_runtime` são **lógica testada, NÃO wiradas** (0 refs de produção). Ativação pendente de ambiente validável (EventSub mode + scope de redemptions; hook no caminho de mensagens). Não foram injetadas nos hot paths do bot live sem validação — decisão de integridade pro backend "relógio suíço".
docs/plan/README.md CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  # Plano de Implementação — Byte Co-Streamer
2
 
3
  Roteiro faseado que converte a [auditoria](../DEEP_AUDIT_2026-06-29.md) + o [report do co-streamer](../CO_STREAMER_REPORT_2026-06-29.md) em execução. Cada fase entrega **código + testes científicos + Meta-Reflexão**.
 
1
+ # Planos do Byte
2
+
3
+ - **[Co-Streamer](#plano-de-implementação--byte-co-streamer)** (este doc) — vê/fala/comenta/aparece.
4
+ - **[Studio Dashboard State-of-the-Art](studio-state-of-the-art.md)** — evoluir o `/studio` pra referência.
5
+
6
+ ---
7
+
8
  # Plano de Implementação — Byte Co-Streamer
9
 
10
  Roteiro faseado que converte a [auditoria](../DEEP_AUDIT_2026-06-29.md) + o [report do co-streamer](../CO_STREAMER_REPORT_2026-06-29.md) em execução. Cada fase entrega **código + testes científicos + Meta-Reflexão**.
docs/plan/studio-state-of-the-art.md ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Studio Dashboard — Plano State-of-the-Art (spec executável autônomo)
2
+
3
+ > **Este documento é autocontido.** Um agente deve conseguir implementá-lo do zero,
4
+ > fase a fase, **sem precisar de contexto externo** além do repositório. Toda
5
+ > convenção, caminho de arquivo, comando e critério de aceite está aqui.
6
+
7
+ **Alvo:** elevar a dash simples `/studio` de "Stream Deck MVP sólido" para **referência de painel operacional de streamer** — *wow porém leve*. · **Atualizado:** 2026-06-29.
8
+
9
+ ---
10
+
11
+ ## 0. Como executar este plano (meta-agêntico)
12
+
13
+ 1. **Ordem:** fases na sequência da seção 8 (default: S0 → S1 → S4 → S2 → S3 → S5 → S6). Não pule pré-requisitos.
14
+ 2. **Loop por fase:** `implementar → testar → validar ao vivo → meta-reflexão → commit`. Só avance após o **Gate de Pronto** (seção 9) passar 100%.
15
+ 3. **Invariantes (valem SEMPRE, não-negociáveis):** seção 2.
16
+ 4. **Sem ambiguidade:** cada fase lista arquivos exatos a criar/editar, passos, testes (com comando) e critério binário. Se algo estiver subespecificado, o agente escolhe a opção mais simples que respeita os invariantes e registra a decisão na meta-reflexão.
17
+ 5. **Saída por fase:** código + testes verdes + evidência ao vivo (screenshot/curl) + seção de meta-reflexão preenchida neste doc + 1 commit.
18
+
19
+ ---
20
+
21
+ ## 1. Contexto do repositório (autocontido — não precisa grep)
22
+
23
+ **Stack do `/studio`:** vanilla JS (ES modules), servido por um HTTP server Python (`http.server`). Sem framework. LLM=Gemini; deploy=HF Spaces (Docker, porta 7860).
24
+
25
+ **Mapa de arquivos relevantes:**
26
+ | Arquivo | Papel |
27
+ | :--- | :--- |
28
+ | `dashboard/studio.html` | markup do `/studio` (~210 linhas) |
29
+ | `dashboard/features/studio/main.js` | controller/poll/render (~540 linhas) |
30
+ | `dashboard/features/studio/api.js` | wrappers de endpoint |
31
+ | `dashboard/features/studio/icons.js` | set de ícones SVG inline |
32
+ | `dashboard/features/shared/api.js` | `fetchWithTimeout(url, opts, ms)` — injeta token + reescreve `/api/` p/ HF + fallback `?auth=` |
33
+ | `dashboard/styles/studio.css` | estilos (~810 linhas, tema claro só) |
34
+ | `dashboard/tests/*.test.js` | testes JS (rodam com `node --test`) |
35
+ | `bot/dashboard_server_routes.py` | rotas GET + serve de páginas (`_dashboard_asset_route`, `_GET_ROUTE_HANDLERS`) |
36
+ | `bot/dashboard_server_routes_post.py` | rotas POST (`_POST_ROUTE_HANDLERS`) |
37
+ | `bot/dashboard_server.py` | `HealthHandler` (server); `_run_route_safely` isola erros → 500 |
38
+ | `bot/dashboard_parity_gate.py` | contrato rota↔teste (`PARITY_CONTRACT`); teste falha se rota nova não for declarada |
39
+ | `bot/tests/scientific/test_phaseN_*_scientific.py` | testes científicos (contratos comportamentais) |
40
+
41
+ **Convenções (copiar exatamente):**
42
+
43
+ - **Servir uma página nova** (`/foo` → `dashboard/foo.html`): em `bot/dashboard_server_routes.py`, dentro de `_dashboard_asset_route`, adicione:
44
+ ```python
45
+ if route in {"/foo", "/foo/", "/dashboard/foo", "/dashboard/foo/"}:
46
+ handler._send_dashboard_asset("foo.html", "text/html; charset=utf-8")
47
+ return True
48
+ ```
49
+ - **Endpoint GET** `/api/foo`: defina `def _handle_get_foo(handler, query): handler._send_json({...}, status_code=200)` em `dashboard_server_routes.py` e registre em `_GET_ROUTE_HANDLERS`. **POST:** em `dashboard_server_routes_post.py`, use `require_auth_and_read_payload(handler)` e registre em `_POST_ROUTE_HANDLERS`.
50
+ - **Parity gate (obrigatório p/ rota `/api/*` nova):** adicione um `ParityContractEntry(...)` em `bot/dashboard_parity_gate.py`. Para superfícies sem teste de dashboard dedicado use `status="headless_approved"` com `headless_reason`, `planned_phase` e `backend_test_files=("bot/tests/scientific/test_phaseN_..._scientific.py",)` **que contenham a string da rota** (`route_snippet`). Senão `test_dashboard_parity_gate` quebra.
51
+ - **Teste científico (py):** arquivo `bot/tests/scientific/test_phaseN_<nome>_scientific.py`; docstring lista os contratos; classes `Prove that...`; foca comportamento, não implementação. **Numere a partir de Phase 30** (26–29 já usados).
52
+ - **Teste JS:** `dashboard/tests/<nome>.test.js`:
53
+ ```js
54
+ import assert from "node:assert/strict";
55
+ import test from "node:test";
56
+ import { fn } from "../features/studio/<mod>.js";
57
+ test("...", () => { assert.equal(fn(...), ...); });
58
+ ```
59
+ - **Frontend novo:** módulos ES sob `dashboard/features/studio/`; importe `fetchWithTimeout` de `../shared/api.js`. Token: `localStorage["byte_dashboard_admin_token"]`; overlays leem `?token=` da URL.
60
+
61
+ **Comandos de referência (use estes literais):**
62
+ ```bash
63
+ # Suíte completa (deve terminar, hermética, verde, ≥80% cobertura)
64
+ python -m pytest bot/tests/ -q -p no:cacheprovider
65
+ # Suíte rápida sem cobertura
66
+ python -m pytest bot/tests/ --no-cov -q -p no:cacheprovider
67
+ # Testes JS
68
+ node --test dashboard/tests/*.test.js
69
+ # Render headless local (validar visual): sirva o repo e use o chromium do playwright
70
+ # exe: /home/juan/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome
71
+ # serve: python3 -m http.server <porta> --directory . ; abra http://127.0.0.1:<porta>/dashboard/studio.html
72
+ # Deploy HF (lê de HEAD; precisa de HF_TOKEN e do snapshot git archive):
73
+ # ver docs/HF_SPACES_DEPLOY_RUNBOOK.md (HfApi().upload_folder + restart_space)
74
+ # Validar ao vivo: curl -H "X-Byte-Admin-Token: <ADMIN_TOKEN>" https://juancs-dev-twitch-byte-bot.hf.space/api/...
75
+ ```
76
+ > **Segredos:** `HF_TOKEN` e `<ADMIN_TOKEN>` (= secret `BYTE_DASHBOARD_ADMIN_TOKEN`) **não** ficam neste doc; o operador fornece. Nunca commite segredo.
77
+
78
+ ---
79
+
80
+ ## 2. Invariantes (não-negociáveis, valem em TODAS as fases)
81
+
82
+ 1. **Segurança:** **nunca** `innerHTML` com dado dinâmico/de usuário — use `textContent`/`createElement`. (Já houve XSS via `innerHTML` no modal de aprovação; não repetir.)
83
+ 2. **Wow sem peso:** **zero** lib de animação/UI; CSS nativo only; efeitos a 60fps (só `transform`/`opacity`); **sempre** respeitar `prefers-reduced-motion`. JS do `/studio` não cresce >20% por fase.
84
+ 3. **Sem bloqueio:** nada de chamada síncrona pesada no thread de request; sem polling redundante após S0.
85
+ 4. **Resiliência:** todo fetch falha graciosamente (overlay/painel nunca quebra a cena); estados offline/sem-token claros.
86
+ 5. **Verde sempre:** após cada fase, `python -m pytest bot/tests/` termina verde, hermético, cobertura ≥80%, e `node --test dashboard/tests/*.test.js` verde (exceto a falha pré-existente conhecida em `control_plane_information_density.test.js`).
87
+ 6. **Vanilla:** sem React/Vue/etc. ES modules + utilidades minúsculas testáveis.
88
+ 7. **Não duplicar** a dash ops técnica (`/`); `/studio` é streamer-facing e simples.
89
+
90
+ ---
91
+
92
+ ## 3. Estado atual medido (baseline honesto)
93
+
94
+ `/studio` (~1750 LOC) = Stream Deck tátil: header de status, medidor de humor, faixa de retenção (churn), grid 3×3 de tiles (PAUSAR, FALAR, HYPE, APROVAR, VISÃO, CLIPES, CANAIS, AGIR, RECAP), ticker, modais (falar/canais/recap/aprovações), gate de token, toasts, ícones SVG. Consome 15 endpoints **via polling** (8 a cada 5s).
95
+
96
+ **Lacunas medidas vs SOTA:** polling-storm (sem SSE); sem tendências/sparklines; layout fixo; sem teclado/command palette; só tema claro (`prefers-color-scheme`=0); a11y parcial (ARIA em modais, sem foco-trap/roving tabindex/reduced-motion); não-PWA; sem UI otimista; onboarding fraco.
97
+
98
+ ---
99
+
100
+ ## 4. Princípios
101
+
102
+ - **Calm by default, loud on demand** · **One-glance truth** · **Tudo em ≤2 toques** · **Reversível & seguro** · **Wow sem peso** (invariante 2).
103
+
104
+ ## 4.5 Linguagem visual — "bonito + leve" (grounding junho 2026)
105
+
106
+ Fugir do SaaS genérico (gradiente pastel + sans amigável): design **deliberado, não decorativo** ([Tubik](https://blog.tubikstudio.com/ui-design-trends-2026/), [Tenet](https://www.wearetenet.com/blog/ui-ux-design-trends)).
107
+
108
+ - **Function-forward + tipografia mono-inspired** nos dados (números/labels em mono) → identidade "cabine de operação".
109
+ - **Glassmorphism 2.0:** `backdrop-filter` multi-camada + relative color + `mask-image`; GPU via `translateZ(0)`, `will-change` só durante animação ([ref](https://weblogtrips.com/technology/glassmorphism-2-0-css-techniques-2026/)).
110
+ - **Paleta restrita arejada;** dark mode cidadão de 1ª classe.
111
+ - **Movimento com propósito** (comunica estado, não enfeite).
112
+
113
+ **Toolkit de wow leve (CSS nativo, zero lib):**
114
+ | Técnica | Onde | Por que leve |
115
+ | :--- | :--- | :--- |
116
+ | **View Transitions API** | tile→modal, troca de estado/tab | transição nativa entre estados do DOM |
117
+ | **Scroll-driven animations** (`scroll`/`view-timeline`) | reveals, sparklines | roda no compositor thread → 60fps mesmo com main thread ocupado ([ref](https://dev.to/nickbenksim/creating-complex-scroll-driven-animations-with-pure-css-in-2026-17l)) |
118
+ | **`@starting-style`** | entrada de modais/toasts/banner | substitui libs de animação ([ref](https://medium.com/@alexdev82/css-micro-animations-in-2026-7-pure-css-techniques-that-replace-javascript-efd374b6e8a4)) |
119
+ | **`color-mix()` / relative color** | tema + estados | deriva cores sem duplicar tokens |
120
+
121
+ ---
122
+
123
+ ## 5. Fases (cada uma = spec executável)
124
+
125
+ > Formato fixo por fase: **Objetivo · Arquivos · Implementação · Testes (comando) · Critério de pronto (binário) · Validação ao vivo · Meta-reflexão (preencher ao concluir)**.
126
+
127
+ ### Fase S0 — Camada de dados em tempo real (fundação)
128
+ **Objetivo:** 1 stream substitui 8 polls; estado <1s; leve.
129
+ **Arquivos:** criar `bot/studio_snapshot.py` (builder `build_studio_snapshot(channel)` agregando control-plane+sentiment+queue+vision+clips+churn num dict); editar `bot/dashboard_server_routes.py` (handler SSE `_handle_get_studio_stream` em `_GET_ROUTE_HANDLERS` para `/api/studio/stream`, `Content-Type: text/event-stream`, loop que emite `data: <json>\n\n` a cada ~2s ou em mudança, com keep-alive); editar `dashboard/features/studio/main.js` (usar `EventSource("/api/studio/stream?auth=<token>")` com `onmessage` → render; reconexão com backoff; **fallback** ao polling atual se `EventSource` falhar 2x); criar `bot/tests/scientific/test_phase30_studio_stream_scientific.py`; criar `dashboard/tests/studio_stream_merge.test.js`.
130
+ **Implementação:** (1) `build_studio_snapshot` reusa os builders existentes (`build_coaching_churn_payload`, `voice_runtime`, etc.); (2) SSE: gerar via um generator no handler escrevendo no `wfile`; tratar desconexão (BrokenPipe) sem logar erro; (3) cliente: extrair o render do `tick()` atual numa função `applySnapshot(snap)` reusada por SSE e polling.
131
+ **Testes:** `python -m pytest bot/tests/scientific/test_phase30_studio_stream_scientific.py -q --no-cov` (prova: `build_studio_snapshot` retorna o contrato agregado; rota registrada; `Content-Type` correto). `node --test dashboard/tests/studio_stream_merge.test.js` (merge incremental determinístico).
132
+ **Critério de pronto:** [ ] 1 conexão SSE substitui os 8 polls; [ ] cai pra polling sem quebrar quando SSE indisponível; [ ] latência de estado <1s; [ ] parity gate atualizado; [ ] suíte verde.
133
+ **Validação ao vivo:** `curl -N -H "X-Byte-Admin-Token: <T>" .../api/studio/stream` emite eventos; render headless do `/studio` mostra dados.
134
+ **Meta-reflexão:** *(preencher)*
135
+
136
+ ### Fase S1 — Glanceability & hierarquia
137
+ **Objetivo:** verdade num olhar.
138
+ **Arquivos:** criar `dashboard/features/studio/health.js` (função pura `operationalHealth({streamHealth, churnBand, pendingActions, online}) -> "healthy"|"attention"|"critical"`); criar `dashboard/features/studio/sparkline.js` (gera `<path>` SVG a partir de N pontos — pura); editar `studio.html` (indicador-herói + 3 sparklines: humor/viewers/saúde, dados de `/api/observability/history`); editar `main.js`/`studio.css`; criar `dashboard/tests/studio_health.test.js` e `studio_sparkline.test.js`.
139
+ **Implementação:** herói consolida sinais → 1 estado com cor/halo; atenção roteada: se `critical` ou `pendingActions>0`, herói/aprovação sobem ao topo (View Transition). **Byte copiloto:** uma dica dispensável (`#byteHint`) sugerindo a próxima ação (ex.: chat esfriando → "solte um HYPE?"), nunca agindo sozinho.
140
+ **Testes:** node — `operationalHealth` determinístico (todas as bandas) + `sparkline` (N pontos → path válido, vazio → seguro).
141
+ **Critério de pronto:** [ ] estado operacional legível <1s; [ ] sparklines de 3 métricas; [ ] dica de copiloto opcional; [ ] transições nativas 60fps; [ ] suíte verde.
142
+ **Validação ao vivo:** render headless mostra herói + sparklines; forçar `critical` via dados mostra reordenação.
143
+ **Meta-reflexão:** *(preencher)*
144
+
145
+ ### Fase S4 — Identidade visual & acessibilidade (o "wow leve") · *fazer cedo (quick win)*
146
+ **Objetivo:** bonito, distintivo, acessível — sem peso.
147
+ **Arquivos:** criar `dashboard/styles/tokens-studio.css` (design tokens em `:root` e `[data-theme="dark"]`); editar `studio.css` (consumir tokens; Glassmorphism 2.0; tipografia mono nos dados; `@starting-style` em modais/toasts/banner; scroll-driven nos reveals; tudo dentro de `@media (prefers-reduced-motion: no-preference)`); editar `studio.html` (toggle de tema persistido em `localStorage["byte_studio_theme"]`, default `auto`); criar `dashboard/tests/studio_theme.test.js` e `dashboard/tests/studio_a11y.test.js` (usar `axe-core` se disponível; senão asserts estruturais: todos os modais têm foco-trap/`aria-modal`, grid com roving tabindex).
148
+ **Implementação:** extrair cores hardcoded do `studio.css` para tokens; adicionar `[data-theme]`; foco-trap nos 4 modais; roving tabindex no grid de tiles; live-regions corretas; `prefers-reduced-motion` desliga **todo** movimento.
149
+ **Testes:** `node --test dashboard/tests/studio_theme.test.js dashboard/tests/studio_a11y.test.js`.
150
+ **Critério de pronto:** [ ] dark/claro/auto; [ ] 0 violações axe críticas (ou checklist a11y manual no doc); [ ] navegável 100% por teclado; [ ] `reduced-motion` zera animações; [ ] **nenhuma lib nova**; [ ] suíte verde.
151
+ **Validação ao vivo:** render headless em dark + claro; tab-navigation manual documentada.
152
+ **Meta-reflexão:** *(preencher)*
153
+
154
+ ### Fase S2 — Poder (command palette + atalhos + otimismo)
155
+ **Objetivo:** velocidade pro avançado.
156
+ **Arquivos:** criar `dashboard/features/studio/commands.js` (registry puro: `{id, label, keys, run}[]` + `filterCommands(query)`); criar `dashboard/features/studio/palette.js` (UI Cmd/Ctrl+K, abre com `@starting-style`+View Transition); editar `main.js` (atalhos P/F/H/A…; optimistic update com rollback no erro); criar `dashboard/tests/studio_commands.test.js`.
157
+ **Testes:** node — `filterCommands` determinístico; reducer otimista aplica+reverte.
158
+ **Critério de pronto:** [ ] toda ação por teclado; [ ] palette fuzzy; [ ] feedback instantâneo com rollback; [ ] suíte verde.
159
+ **Validação ao vivo:** render headless abre palette; executar comando reflete na hora.
160
+ **Meta-reflexão:** *(preencher)*
161
+
162
+ ### Fase S3 — Customização & layout
163
+ **Objetivo:** o painel de cada um.
164
+ **Arquivos:** criar `dashboard/features/studio/layout.js` (modelo de layout: ordem/visibilidade/densidade; `serialize/parse` com guarda de inválido; persiste em `localStorage["byte_studio_layout"]`); editar `studio.html`/`main.js` (drag-and-drop reordenar, fixar/esconder tile, densidade compacto/confortável, perfis ex. FPS/Just Chatting); criar `dashboard/tests/studio_layout.test.js`.
165
+ **Testes:** node — round-trip de layout; layout corrompido → fallback ao default.
166
+ **Critério de pronto:** [ ] layout sobrevive reload; [ ] reset disponível; [ ] perfis; [ ] suíte verde.
167
+ **Meta-reflexão:** *(preencher)*
168
+
169
+ ### Fase S5 — Alcance (PWA + mobile + push)
170
+ **Objetivo:** na palma da mão.
171
+ **Arquivos:** criar `dashboard/studio.webmanifest`; criar `dashboard/sw.js` (service worker: cache do shell, offline); rotas p/ servir ambos (`bot/dashboard_server_routes.py`); editar `studio.html` (link manifest + registro do SW); Web Push opt-in p/ alerta crítico (churn crítico/fila travada); refinements mobile (bottom-sheet nos modais, alvos de toque ≥44px).
172
+ **Testes:** node/estrutural — manifest válido (campos obrigatórios); SW registra e cacheia shell; push é opt-in.
173
+ **Critério de pronto:** [ ] instalável (Lighthouse PWA); [ ] shell offline; [ ] alerta crítico chega; [ ] performance ≥90; [ ] suíte verde.
174
+ **Meta-reflexão:** *(preencher)*
175
+
176
+ ### Fase S6 — Delight & onboarding
177
+ **Objetivo:** primeira impressão e acabamento.
178
+ **Arquivos:** editar `studio.html`/`main.js` (first-run guiado: cole token → conecte canal → abra overlays; estados vazios com propósito; **avatar Byte mini** no canto reagindo ao humor, reusando `dashboard/features/avatar/expression.js`); criar `dashboard/tests/studio_onboarding.test.js`.
179
+ **Critério de pronto:** [ ] novo streamer entende em <60s; [ ] estados vazios úteis; [ ] avatar mini; [ ] suíte verde.
180
+ **Meta-reflexão:** *(preencher)*
181
+
182
+ ---
183
+
184
+ ## 6. Decisões técnicas (fixas — não re-decidir)
185
+
186
+ - **SSE, não WebSocket** (unidirecional basta; reconecta sozinho; passa no proxy HF). Ações continuam REST POST.
187
+ - **Store mínimo no cliente:** objeto observável simples (sem framework) que renders assinam — habilita optimistic UI + sparklines sem spaghetti.
188
+ - **Customização:** `localStorage` primeiro; sync via `channel-config` é opcional/futuro.
189
+ - **Tokens de tema** em CSS custom properties + `[data-theme]`.
190
+
191
+ ## 7. Não-objetivos
192
+
193
+ Não virar editor de cena/overlay (é OBS) · não reescrever em framework · não duplicar a dash ops `/`.
194
+
195
+ ## 8. Sequência recomendada
196
+
197
+ **S0** (fundação tempo real) cedo → **S1 + S4-dark mode** (quick wins de "uau") → **S2 → S3 → S5 → S6**.
198
+
199
+ ## 9. Gate de Pronto (rodar ao fim de CADA fase — todos ✅ antes de avançar)
200
+
201
+ 1. [ ] Critério de pronto da fase 100% (checklist binário).
202
+ 2. [ ] `python -m pytest bot/tests/ -q` → verde, hermético, cobertura ≥80%.
203
+ 3. [ ] `node --test dashboard/tests/*.test.js` → verde (exceto a falha pré-existente conhecida).
204
+ 4. [ ] Invariantes (seção 2) respeitados — em especial: sem `innerHTML` dinâmico, sem lib nova, 60fps + reduced-motion, sem regressão.
205
+ 5. [ ] Evidência ao vivo registrada (screenshot headless e/ou curl) após deploy.
206
+ 6. [ ] Seção "Meta-reflexão" da fase preenchida (comportamento↔teste, regressão, Go/No-Go).
207
+ 7. [ ] 1 commit com mensagem `feat(studio): <fase> …` + Co-Authored-By.
208
+
209
+ ---
210
+
211
+ ### Fontes (deep research — junho 2026)
212
+ [Tubik UI 2026](https://blog.tubikstudio.com/ui-design-trends-2026/) · [Tenet UI/UX 2026](https://www.wearetenet.com/blog/ui-ux-design-trends) · [Muzli dashboards 2026](https://muz.li/blog/best-dashboard-design-examples-inspirations-for-2026/) · [Minimalist web 2026](https://www.digitalsilk.com/digital-trends/minimalist-web-design-trends/) · [Glassmorphism 2.0](https://weblogtrips.com/technology/glassmorphism-2-0-css-techniques-2026/) · [Scroll-driven CSS 2026](https://dev.to/nickbenksim/creating-complex-scroll-driven-animations-with-pure-css-in-2026-17l) · [CSS micro-animations sem JS](https://medium.com/@alexdev82/css-micro-animations-in-2026-7-pure-css-techniques-that-replace-javascript-efd374b6e8a4)