techfreakworm commited on
Commit
dd2ec5b
·
unverified ·
1 Parent(s): 0f24486

docs: spec for SSE-driven progress UI + footer branding

Browse files
docs/superpowers/specs/2026-04-29-progress-and-branding-design.md ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Progress UI + Footer Branding — Design Spec
2
+
3
+ **Date:** 2026-04-29
4
+ **Status:** Approved (Sections 1–2) — ready for implementation plan
5
+ **Repo:** `/Users/techfreakworm/Projects/llm/chatterbox-voicecloner`
6
+ **Builds on:**
7
+ - `docs/superpowers/specs/2026-04-28-chatterbox-voice-studio-design.md`
8
+ - `docs/superpowers/specs/2026-04-29-param-expansion-and-dialog-design.md`
9
+
10
+ ---
11
+
12
+ ## 1. Problem & Goals
13
+
14
+ Two small polish improvements:
15
+
16
+ 1. **Progress feedback during generation** — today, after clicking Generate the UI just disables the button. On Free CPU HF Spaces a single clip can take 30–90s and Dialog mode runs sequentially per turn — the user has no idea what's happening or how far along it is.
17
+ 2. **Personal branding in the footer** — a "Made with ♥ by techfreakworm" credit linking to mayankgupta.in, in the same editorial-studio aesthetic as the rest of the UI.
18
+
19
+ ### Non-goals
20
+
21
+ - Token-level progress hooks into the chatterbox model loop. Fragile across model versions.
22
+ - ETA estimation based on text length / device empirics. v1 uses *real* signals only — start, per-turn, done — and elapsed-time ticking.
23
+ - Persistent generation history of timing data on the server. Server stays stateless.
24
+ - A logo/glyph or animated mark (deferred). Just typography for the credit.
25
+
26
+ ### Success criteria
27
+
28
+ 1. While `/api/generate` or `/api/generate/dialog` is running, a `ProgressBar` is visible in the Studio with:
29
+ - elapsed time counter ticking every 0.5s
30
+ - in Dialog mode, a determinate fill = `current_turn / total_turns` and the label "Turn 2 of 4"
31
+ - in Single mode, an indeterminate animated stripe with label "Generating…"
32
+ 2. The bar transitions to a 1-second "done" full-bar flash, then fades out to idle.
33
+ 3. On error, the bar turns red and shows the error message.
34
+ 4. Multiple browser tabs subscribed to `/api/progress` all see the same events.
35
+ 5. The footer shows the centered "Made with ♥ by techfreakworm / 2026" block, where "techfreakworm" is a single anchored link to `https://mayankgupta.in` (target=_blank, rel="noopener noreferrer"). Hovering the credit transitions "techfreakworm" to ember and pulses the heart.
36
+
37
+ ---
38
+
39
+ ## 2. Decisions Locked In
40
+
41
+ | # | Decision | Rationale |
42
+ |---|---|---|
43
+ | Q1 | **C — Server-side SSE heartbeats; per-turn progress for Dialog** | Real signals from the inference pipeline. No fake progress, no calibration. |
44
+ | Q2 | **B-style center-stacked credit, "Made with ♥ by techfreakworm / 2026"** | Open-source convention you asked for, matches editorial typography. |
45
+
46
+ ---
47
+
48
+ ## 3. Architecture Delta
49
+
50
+ ```
51
+ chatterbox-voicecloner/
52
+ ├── server/
53
+ │ ├── progress.py NEW (Tasks: progress)
54
+ │ ├── main.py MODIFY (mount /api/progress; wrap generate endpoints)
55
+ │ └── dialog.py MODIFY (emit turn_complete events)
56
+ ├── tests/
57
+ │ ├── test_progress.py NEW
58
+ │ └── test_main_progress_sse.py NEW
59
+ ├── web/src/
60
+ │ ├── components/
61
+ │ │ ├── ProgressBar.tsx NEW
62
+ │ │ └── MadeBy.tsx NEW
63
+ │ ├── lib/progress.ts NEW (subscriber + useProgress hook)
64
+ │ ├── pages/Studio.tsx MODIFY (render ProgressBar + MadeBy)
65
+ │ └── test/
66
+ │ ├── progress.test.ts NEW (state machine tests)
67
+ │ └── MadeBy.test.tsx NEW
68
+ ```
69
+
70
+ No changes to launchers, Dockerfile, model adapters, registry, or schemas.
71
+
72
+ ---
73
+
74
+ ## 4. Backend — Progress System
75
+
76
+ ### 4.1 `server/progress.py`
77
+
78
+ Thin in-memory pub/sub built on `asyncio.Queue`.
79
+
80
+ ```python
81
+ """Progress event bus for in-flight generations.
82
+
83
+ Endpoints (`/api/generate`, `/api/generate/dialog`) wrap their work in
84
+ `session(...)` which emits `start` and `done`/`error` events plus a
85
+ 0.5s `tick` heartbeat. Dialog mode also emits `turn_complete` between
86
+ adapter calls. Subscribers receive events via `subscribe()` (used by
87
+ the SSE endpoint).
88
+ """
89
+ from __future__ import annotations
90
+
91
+ import asyncio
92
+ import time
93
+ from contextlib import asynccontextmanager
94
+ from dataclasses import dataclass, field
95
+ from typing import AsyncIterator, Literal
96
+
97
+
98
+ EventType = Literal["start", "tick", "turn_complete", "done", "error"]
99
+
100
+
101
+ @dataclass
102
+ class ProgressEvent:
103
+ type: EventType
104
+ elapsed_s: float
105
+ payload: dict = field(default_factory=dict)
106
+
107
+ def to_dict(self) -> dict:
108
+ return {"type": self.type, "elapsed_s": round(self.elapsed_s, 2), **self.payload}
109
+
110
+
111
+ class ProgressBus:
112
+ def __init__(self) -> None:
113
+ self._subscribers: list[asyncio.Queue[ProgressEvent]] = []
114
+ self._lock = asyncio.Lock()
115
+ self._current_session: "_Session | None" = None
116
+
117
+ async def publish(self, event: ProgressEvent) -> None:
118
+ async with self._lock:
119
+ subs = list(self._subscribers)
120
+ for q in subs:
121
+ await q.put(event)
122
+
123
+ @asynccontextmanager
124
+ async def subscribe(self) -> AsyncIterator[asyncio.Queue[ProgressEvent]]:
125
+ q: asyncio.Queue[ProgressEvent] = asyncio.Queue()
126
+ async with self._lock:
127
+ self._subscribers.append(q)
128
+ # Replay current state for late joiners
129
+ if self._current_session is not None:
130
+ snapshot = self._current_session.snapshot_event()
131
+ if snapshot is not None:
132
+ await q.put(snapshot)
133
+ try:
134
+ yield q
135
+ finally:
136
+ async with self._lock:
137
+ if q in self._subscribers:
138
+ self._subscribers.remove(q)
139
+
140
+ @asynccontextmanager
141
+ async def session(
142
+ self, kind: Literal["single", "dialog"], total_turns: int = 1,
143
+ ) -> AsyncIterator["_Session"]:
144
+ session = _Session(self, kind=kind, total_turns=total_turns)
145
+ async with self._lock:
146
+ self._current_session = session
147
+ await self.publish(
148
+ ProgressEvent(
149
+ type="start",
150
+ elapsed_s=0.0,
151
+ payload={"kind": kind, "total_turns": total_turns, "turn": 0},
152
+ ),
153
+ )
154
+ ticker = asyncio.create_task(session._tick_loop())
155
+ try:
156
+ yield session
157
+ await self.publish(
158
+ ProgressEvent(
159
+ type="done",
160
+ elapsed_s=session.elapsed(),
161
+ payload={
162
+ "kind": kind,
163
+ "seed_used": session.seed_used,
164
+ "turn": session.turn,
165
+ "total_turns": total_turns,
166
+ },
167
+ ),
168
+ )
169
+ except Exception as exc:
170
+ await self.publish(
171
+ ProgressEvent(
172
+ type="error",
173
+ elapsed_s=session.elapsed(),
174
+ payload={"message": str(exc)},
175
+ ),
176
+ )
177
+ raise
178
+ finally:
179
+ ticker.cancel()
180
+ try:
181
+ await ticker
182
+ except asyncio.CancelledError:
183
+ pass
184
+ async with self._lock:
185
+ if self._current_session is session:
186
+ self._current_session = None
187
+
188
+
189
+ @dataclass
190
+ class _Session:
191
+ bus: ProgressBus
192
+ kind: Literal["single", "dialog"]
193
+ total_turns: int
194
+ started_at: float = field(default_factory=time.monotonic)
195
+ turn: int = 0
196
+ seed_used: int | None = None
197
+
198
+ def elapsed(self) -> float:
199
+ return time.monotonic() - self.started_at
200
+
201
+ def set_seed(self, seed: int) -> None:
202
+ self.seed_used = seed
203
+
204
+ async def turn_complete(self, turn_index: int) -> None:
205
+ self.turn = turn_index
206
+ await self.bus.publish(
207
+ ProgressEvent(
208
+ type="turn_complete",
209
+ elapsed_s=self.elapsed(),
210
+ payload={
211
+ "turn": turn_index,
212
+ "total_turns": self.total_turns,
213
+ "kind": self.kind,
214
+ },
215
+ ),
216
+ )
217
+
218
+ async def _tick_loop(self) -> None:
219
+ try:
220
+ while True:
221
+ await asyncio.sleep(0.5)
222
+ await self.bus.publish(
223
+ ProgressEvent(
224
+ type="tick",
225
+ elapsed_s=self.elapsed(),
226
+ payload={
227
+ "kind": self.kind,
228
+ "turn": self.turn,
229
+ "total_turns": self.total_turns,
230
+ },
231
+ ),
232
+ )
233
+ except asyncio.CancelledError:
234
+ pass
235
+
236
+ def snapshot_event(self) -> ProgressEvent | None:
237
+ # Replay a synthetic tick so a late SSE subscriber knows what's running.
238
+ return ProgressEvent(
239
+ type="tick",
240
+ elapsed_s=self.elapsed(),
241
+ payload={
242
+ "kind": self.kind,
243
+ "turn": self.turn,
244
+ "total_turns": self.total_turns,
245
+ },
246
+ )
247
+
248
+
249
+ # App-level singleton (built once per FastAPI instance via lifespan in main.py).
250
+ _BUS: ProgressBus | None = None
251
+
252
+
253
+ def get_bus() -> ProgressBus:
254
+ global _BUS
255
+ if _BUS is None:
256
+ _BUS = ProgressBus()
257
+ return _BUS
258
+ ```
259
+
260
+ ### 4.2 `/api/progress` SSE endpoint
261
+
262
+ In `server/main.py`:
263
+
264
+ ```python
265
+ from server.progress import get_bus
266
+ import json
267
+
268
+ @app.get("/api/progress")
269
+ async def progress_events():
270
+ bus = get_bus()
271
+ async def gen():
272
+ async with bus.subscribe() as q:
273
+ while True:
274
+ evt = await q.get()
275
+ yield {"data": json.dumps(evt.to_dict())}
276
+ return EventSourceResponse(gen())
277
+ ```
278
+
279
+ ### 4.3 Wrapping generate endpoints
280
+
281
+ `/api/generate` (single) is wrapped:
282
+
283
+ ```python
284
+ async with get_bus().session("single", total_turns=1) as sess:
285
+ # existing generate logic; capture seed_used:
286
+ wav_bytes, _sr, seed_used = gen_fn(text, ref_path, language, parsed_params)
287
+ sess.set_seed(seed_used)
288
+ return Response(...)
289
+ ```
290
+
291
+ `/api/generate/dialog` is wrapped similarly with `kind="dialog"` and `total_turns=len(turns)`. Inside `server/dialog.py:generate_dialog`, after each turn's adapter call, we call `await session.turn_complete(i + 1)`. Since `generate_dialog` does not currently take a session, the signature is extended:
292
+
293
+ ```python
294
+ async def generate_dialog(
295
+ *, registry, engine_id, text, language, params, speaker_clips, silence_ms=250,
296
+ session=None, # optional ProgressBus session
297
+ ):
298
+ ```
299
+
300
+ When `session is None` (e.g., direct unit tests), turn-complete events are skipped.
301
+
302
+ ### 4.4 Tests
303
+
304
+ `tests/test_progress.py`:
305
+
306
+ - `bus.subscribe` registers a queue and `publish` delivers to it.
307
+ - Two concurrent subscribers both receive the same events.
308
+ - Late subscriber gets a snapshot tick when joining mid-session.
309
+ - `session(...)` emits start → done in normal flow.
310
+ - `session(...)` emits start → error on exception and re-raises.
311
+ - `session.turn_complete(i)` emits a `turn_complete` event with the right turn/total payload.
312
+
313
+ `tests/test_main_progress_sse.py`:
314
+
315
+ - Connect to `/api/progress`, dispatch a fake `/api/generate` (using FakeAdapter), assert the SSE stream contains a `start` event and a `done` event with non-zero `elapsed_s`.
316
+ - For dialog: dispatch `/api/generate/dialog` with a 2-turn script; assert the stream emits `start (total=2)`, at least one `turn_complete (turn=1)`, `turn_complete (turn=2)`, `done`.
317
+
318
+ The tests use `httpx.ASGITransport` with the existing `lifespan_ctx` fixture and the existing `FakeAdapter`; the bus singleton is reset between tests with a small fixture that monkeypatches `_BUS = None`.
319
+
320
+ ---
321
+
322
+ ## 5. Frontend — Progress UI
323
+
324
+ ### 5.1 `lib/progress.ts`
325
+
326
+ ```ts
327
+ export type ProgressState =
328
+ | { phase: "idle" }
329
+ | {
330
+ phase: "running";
331
+ kind: "single" | "dialog";
332
+ turn: number;
333
+ total: number;
334
+ elapsedS: number;
335
+ }
336
+ | { phase: "done"; elapsedS: number }
337
+ | { phase: "error"; message: string };
338
+
339
+ export function subscribeProgress(
340
+ onState: (s: ProgressState) => void,
341
+ ): () => void {
342
+ const es = new EventSource("/api/progress");
343
+ let doneTimer: number | null = null;
344
+ es.onmessage = (m) => {
345
+ if (doneTimer) {
346
+ window.clearTimeout(doneTimer);
347
+ doneTimer = null;
348
+ }
349
+ const evt = JSON.parse(m.data) as {
350
+ type: "start" | "tick" | "turn_complete" | "done" | "error";
351
+ elapsed_s: number;
352
+ kind?: "single" | "dialog";
353
+ turn?: number;
354
+ total_turns?: number;
355
+ message?: string;
356
+ };
357
+ if (evt.type === "start" || evt.type === "tick" || evt.type === "turn_complete") {
358
+ onState({
359
+ phase: "running",
360
+ kind: (evt.kind ?? "single") as "single" | "dialog",
361
+ turn: evt.turn ?? 0,
362
+ total: evt.total_turns ?? 1,
363
+ elapsedS: evt.elapsed_s ?? 0,
364
+ });
365
+ return;
366
+ }
367
+ if (evt.type === "done") {
368
+ onState({ phase: "done", elapsedS: evt.elapsed_s });
369
+ doneTimer = window.setTimeout(() => onState({ phase: "idle" }), 1000);
370
+ return;
371
+ }
372
+ if (evt.type === "error") {
373
+ onState({ phase: "error", message: evt.message ?? "Generation failed" });
374
+ }
375
+ };
376
+ return () => {
377
+ if (doneTimer) window.clearTimeout(doneTimer);
378
+ es.close();
379
+ };
380
+ }
381
+ ```
382
+
383
+ ### 5.2 `useProgress` hook
384
+
385
+ ```ts
386
+ import { useEffect, useState } from "react";
387
+ import { subscribeProgress, type ProgressState } from "./progress";
388
+
389
+ export function useProgress(): ProgressState {
390
+ const [state, setState] = useState<ProgressState>({ phase: "idle" });
391
+ useEffect(() => {
392
+ const close = subscribeProgress(setState);
393
+ return close;
394
+ }, []);
395
+ return state;
396
+ }
397
+ ```
398
+
399
+ ### 5.3 `ProgressBar.tsx`
400
+
401
+ ```tsx
402
+ import { useProgress } from "@/lib/progress";
403
+
404
+ function fmt(s: number): string {
405
+ const m = Math.floor(s / 60);
406
+ const sec = Math.floor(s % 60);
407
+ return `${m}:${sec.toString().padStart(2, "0")}`;
408
+ }
409
+
410
+ export default function ProgressBar() {
411
+ const state = useProgress();
412
+ if (state.phase === "idle") return null;
413
+
414
+ if (state.phase === "error") {
415
+ return (
416
+ <div className="border-b border-red-900/40 bg-red-950/30 px-8 py-2.5">
417
+ <span className="label-mono text-red-400">progress error</span>
418
+ <span className="ml-3 text-sm text-red-300/90">{state.message}</span>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ const isRunning = state.phase === "running";
424
+ const isDialog = isRunning && state.kind === "dialog";
425
+ const fill =
426
+ state.phase === "done"
427
+ ? 1
428
+ : isDialog && state.total > 0
429
+ ? state.turn / state.total
430
+ : null; // null = indeterminate
431
+
432
+ const label =
433
+ state.phase === "done"
434
+ ? `done · ${fmt(state.elapsedS)}`
435
+ : isDialog
436
+ ? `Turn ${state.turn} of ${state.total} · ${fmt(state.elapsedS)}`
437
+ : `Generating · ${fmt(state.elapsedS)}`;
438
+
439
+ return (
440
+ <div className="border-b border-[hsl(var(--ember))]/30 bg-[hsl(var(--ember))]/10 px-8 py-2">
441
+ <div className="flex items-center gap-4">
442
+ <span className="label-mono text-[hsl(var(--ember))] whitespace-nowrap">
443
+ {label}
444
+ </span>
445
+ <div className="flex-1 h-1 bg-[hsl(var(--ember))]/20 rounded-sm overflow-hidden">
446
+ {fill === null ? (
447
+ <div className="h-full w-1/3 bg-[hsl(var(--ember))] animate-[progress-stripe_1.2s_linear_infinite]" />
448
+ ) : (
449
+ <div
450
+ className="h-full bg-[hsl(var(--ember))] transition-[width] duration-200 ease-linear"
451
+ style={{ width: `${fill * 100}%` }}
452
+ />
453
+ )}
454
+ </div>
455
+ </div>
456
+ </div>
457
+ );
458
+ }
459
+ ```
460
+
461
+ The `progress-stripe` keyframe is added to `tailwind.config.ts`:
462
+
463
+ ```ts
464
+ keyframes: {
465
+ ...
466
+ "progress-stripe": {
467
+ "0%": { transform: "translateX(-100%)" },
468
+ "100%": { transform: "translateX(300%)" },
469
+ },
470
+ },
471
+ animation: {
472
+ ...
473
+ "progress-stripe": "progress-stripe 1.2s linear infinite",
474
+ },
475
+ ```
476
+
477
+ ### 5.4 Studio integration
478
+
479
+ `web/src/pages/Studio.tsx` adds `<ProgressBar />` immediately below `<LoadingBanner />` and above the error banner. The component is self-subscribing — Studio doesn't pass it any props.
480
+
481
+ ---
482
+
483
+ ## 6. Frontend — `MadeBy` component + footer integration
484
+
485
+ ### 6.1 `MadeBy.tsx`
486
+
487
+ ```tsx
488
+ const URL = "https://mayankgupta.in";
489
+
490
+ export default function MadeBy() {
491
+ return (
492
+ <a
493
+ href={URL}
494
+ target="_blank"
495
+ rel="noopener noreferrer"
496
+ aria-label="Made by Mayank Gupta — opens mayankgupta.in in a new tab"
497
+ className="group block text-center py-6 select-none"
498
+ >
499
+ <div className="label-mono inline-flex items-center gap-1.5 text-muted-foreground">
500
+ <span>Made with</span>
501
+ <span
502
+ aria-hidden
503
+ className="text-[hsl(var(--ember))] group-hover:animate-pulse-dot"
504
+ >
505
+
506
+ </span>
507
+ <span>by</span>
508
+ </div>
509
+ <div className="display-serif text-[24px] mt-1 transition-colors duration-200 group-hover:text-[hsl(var(--ember))]">
510
+ techfreakworm
511
+ </div>
512
+ <div className="label-mono mt-1 text-muted-foreground/70">2026</div>
513
+ </a>
514
+ );
515
+ }
516
+ ```
517
+
518
+ ### 6.2 Studio footer block
519
+
520
+ Replace the existing `<footer>` content with:
521
+
522
+ ```tsx
523
+ <footer className="border-t border-border mt-16">
524
+ <MadeBy />
525
+ <div className="rule-dotted mx-8" />
526
+ <div className="mx-auto max-w-[1280px] px-8 py-6 flex items-center justify-between">
527
+ <span className="label-mono">chatterbox · resemble ai</span>
528
+ <span className="label-mono">stateless · browser-persisted</span>
529
+ </div>
530
+ </footer>
531
+ ```
532
+
533
+ ### 6.3 Tests
534
+
535
+ `web/src/test/MadeBy.test.tsx`:
536
+
537
+ ```tsx
538
+ import { render, screen } from "@testing-library/react";
539
+ import { describe, expect, it } from "vitest";
540
+ import MadeBy from "@/components/MadeBy";
541
+
542
+ describe("MadeBy", () => {
543
+ it("renders an anchor to mayankgupta.in opening in a new tab", () => {
544
+ render(<MadeBy />);
545
+ const link = screen.getByRole("link", { name: /made by/i });
546
+ expect(link).toHaveAttribute("href", "https://mayankgupta.in");
547
+ expect(link).toHaveAttribute("target", "_blank");
548
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
549
+ expect(link.textContent).toMatch(/techfreakworm/);
550
+ });
551
+ });
552
+ ```
553
+
554
+ ---
555
+
556
+ ## 7. Edge Cases (frozen)
557
+
558
+ | Case | Resolution |
559
+ |---|---|
560
+ | `/api/progress` connection drops mid-generation | EventSource auto-reconnects; on reconnect the bus's snapshot tick brings the new client up to date. |
561
+ | User opens two tabs and clicks Generate in one | Both tabs see the same events (single shared bus). The non-generating tab also shows the bar — acceptable: it reflects what the server is doing. |
562
+ | Generation fails after start | `error` event fires; bar turns red; auto-reset on the next `start` or after the user clicks Generate again. |
563
+ | Multiple sessions in flight | Cannot happen: registry serializes generations behind the active-model lock. |
564
+ | User has JS disabled | No bar; existing button-disabled UX is the fallback. |
565
+ | Tick fires after session ends (race) | Bus's `_current_session` reset under the lock; an in-flight tick still has a valid `_Session` reference and will publish one more event before the ticker is cancelled — harmless. |
566
+ | Heart emoji `♥` not rendering on user's browser | Fallback character from system font; if catastrophic the test wouldn't catch it but it's a single Unicode codepoint widely supported. |
567
+
568
+ ---
569
+
570
+ ## 8. Implementation Order (preview)
571
+
572
+ 1. Backend `progress.py` + tests.
573
+ 2. Wire `/api/progress` SSE endpoint and refactor `/api/generate` + `/api/generate/dialog` to use `session(...)`. Plumb `seed_used` into the session.
574
+ 3. Frontend `lib/progress.ts` + `useProgress` + tests.
575
+ 4. Frontend `ProgressBar.tsx` + Studio integration.
576
+ 5. `MadeBy.tsx` + footer integration + test.
577
+
578
+ Each phase ends with green pytest/vitest and a sole-author commit per `CLAUDE.md`.
579
+
580
+ ---
581
+
582
+ *End of design spec.*