Arslan1997 commited on
Commit
f74c934
·
1 Parent(s): 34b0226

added blogs

Browse files
.env.example CHANGED
@@ -70,8 +70,8 @@ JOB_STALE_SECONDS=900
70
  # ---- ElevenLabs (audio overviews, video narration, voice cloning)
71
  ELEVENLABS_API_KEY=
72
  ELEVENLABS_MODEL=eleven_multilingual_v2
73
- ELEVENLABS_VOICE_A=21m00Tcm4TlvDq8ikWAM
74
- ELEVENLABS_VOICE_B=pNInz6obpgDQGcFMaJgB
75
 
76
  # ---- Launchpad social publishing
77
  # Bluesky needs nothing here (users connect with an app password).
 
70
  # ---- ElevenLabs (audio overviews, video narration, voice cloning)
71
  ELEVENLABS_API_KEY=
72
  ELEVENLABS_MODEL=eleven_multilingual_v2
73
+ ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
74
+ ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
75
 
76
  # ---- Launchpad social publishing
77
  # Bluesky needs nothing here (users connect with an app password).
backend/app/config.py CHANGED
@@ -78,8 +78,8 @@ class Settings(BaseSettings):
78
  # ElevenLabs (premade voices by default; a consented clone can replace host A)
79
  elevenlabs_api_key: str = ""
80
  elevenlabs_model: str = "eleven_multilingual_v2"
81
- elevenlabs_voice_a: str = "21m00Tcm4TlvDq8ikWAM" # Rachel
82
- elevenlabs_voice_b: str = "pNInz6obpgDQGcFMaJgB" # Adam
83
 
84
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
85
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
 
78
  # ElevenLabs (premade voices by default; a consented clone can replace host A)
79
  elevenlabs_api_key: str = ""
80
  elevenlabs_model: str = "eleven_multilingual_v2"
81
+ elevenlabs_voice_a: str = "EXAVITQu4vr4xnSDxMaL" # Sarah (premade)
82
+ elevenlabs_voice_b: str = "JBFqnCBsd6RMkjVDRZzb" # George (premade)
83
 
84
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
85
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
backend/app/llm/signatures.py CHANGED
@@ -115,7 +115,10 @@ class ResearchArchive(dspy.Signature):
115
  lines before relying on them. Answer ONLY from text you read. Every factual sentence needs a
116
  [n] marker, and every marker needs a citation with the file path and the line range you read.
117
  If the archive does not cover the question, say so plainly and set unsupported=true.
118
- Write in plain, warm prose. Never use em dashes."""
 
 
 
119
 
120
  question: str = dspy.InputField()
121
  archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
@@ -190,7 +193,8 @@ class SummarizeNotebook(dspy.Signature):
190
 
191
  title: str = dspy.InputField()
192
  passages: list[str] = dspy.InputField(desc="Posts with path and numbered lines")
193
- summary: str = dspy.OutputField(desc="3 to 6 paragraphs with [n] markers")
 
194
  themes: list[str] = dspy.OutputField(desc="3 to 7 short theme names")
195
  citations: list[FileCitation] = dspy.OutputField()
196
 
 
115
  lines before relying on them. Answer ONLY from text you read. Every factual sentence needs a
116
  [n] marker, and every marker needs a citation with the file path and the line range you read.
117
  If the archive does not cover the question, say so plainly and set unsupported=true.
118
+ Write in plain, warm prose formatted as Markdown: short paragraphs separated by blank lines,
119
+ bullet or numbered lists when comparing or listing things, **bold** for the key idea, and a
120
+ ### heading only when the answer has distinct parts. Put [n] markers right after the sentence
121
+ or list item they support. Never use em dashes."""
122
 
123
  question: str = dspy.InputField()
124
  archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
 
193
 
194
  title: str = dspy.InputField()
195
  passages: list[str] = dspy.InputField(desc="Posts with path and numbered lines")
196
+ summary: str = dspy.OutputField(desc="Markdown: 3 to 6 short paragraphs or sections (### headings allowed), "
197
+ "blank lines between blocks, [n] markers after supported sentences")
198
  themes: list[str] = dspy.OutputField(desc="3 to 7 short theme names")
199
  citations: list[FileCitation] = dspy.OutputField()
200
 
backend/app/pipeline/media.py CHANGED
@@ -39,6 +39,13 @@ def host_voices(db: Session, workspace_id: uuid.UUID) -> tuple[str, str]:
39
  return voices.get("host_a") or settings.elevenlabs_voice_a, voices.get("host_b") or settings.elevenlabs_voice_b
40
 
41
 
 
 
 
 
 
 
 
42
  def _require_tts() -> None:
43
  if not settings.elevenlabs_api_key:
44
  raise PermanentJobError("Audio needs ELEVENLABS_API_KEY in .env")
@@ -64,20 +71,30 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
64
  raise RuntimeError("The script came back empty")
65
  tools = tools_for(corpus, docs)
66
  voice_a, voice_b = host_voices(db, artifact.workspace_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  audio = bytearray()
68
  segments = []
69
  t = 0.0
70
- for i, line in enumerate(lines):
71
- voice = voice_a if line.get("speaker") == "host_a" else voice_b
72
- clip = tts.synthesize(artifact.workspace_id, line["text"], voice)
73
  seconds = tts.mp3_seconds(clip)
74
  segments.append({"speaker": line.get("speaker", "host_a"), "text": line["text"],
75
  "start": round(t, 2), "end": round(t + seconds, 2),
76
  "sources": verify_refs(tools, line.get("sources") or [])})
77
  audio += clip
78
  t += seconds
79
- update_job(db, job, progress=0.2 + 0.75 * (i + 1) / len(lines),
80
- message=f"Recording line {i + 1} of {len(lines)}")
81
  key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
82
  storage.put_bytes(key, bytes(audio), "audio/mpeg")
83
  artifact.storage_key = key
@@ -129,13 +146,14 @@ def make_video(db: Session, job: Job, artifact: Artifact) -> None:
129
  audio = bytearray()
130
  cursor = 0.0
131
  voice_a, _ = host_voices(db, artifact.workspace_id)
 
132
  out_scenes = []
133
  for i, s in enumerate(scenes):
134
  narration = (s.get("narration") or "").strip()
135
  duration = _clamp(s.get("duration_hint_s"), 2.0, 20.0)
136
  if narration and settings.elevenlabs_api_key:
137
  update_job(db, job, progress=0.15 + 0.5 * i / len(scenes), message=f"Narrating scene {i + 1}")
138
- clip, words = tts.synthesize_with_timestamps(artifact.workspace_id, narration, voice_a)
139
  seconds = tts.mp3_seconds(clip)
140
  # Pad with silence so each clip starts exactly when its scene does.
141
  audio += _silence(cursor - tts.mp3_seconds(bytes(audio)))
 
39
  return voices.get("host_a") or settings.elevenlabs_voice_a, voices.get("host_b") or settings.elevenlabs_voice_b
40
 
41
 
42
+ def host_settings(db: Session, workspace_id: uuid.UUID) -> dict[str, tts.VoiceSettings]:
43
+ """Per host delivery settings (stability, similarity, style, speed) saved on the Voice page."""
44
+ vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
45
+ saved = ((vp.host_voices if vp else None) or {}).get("settings") or {}
46
+ return {h: tts.VoiceSettings.from_dict(saved.get(h)) for h in ("host_a", "host_b")}
47
+
48
+
49
  def _require_tts() -> None:
50
  if not settings.elevenlabs_api_key:
51
  raise PermanentJobError("Audio needs ELEVENLABS_API_KEY in .env")
 
71
  raise RuntimeError("The script came back empty")
72
  tools = tools_for(corpus, docs)
73
  voice_a, voice_b = host_voices(db, artifact.workspace_id)
74
+ delivery = host_settings(db, artifact.workspace_id)
75
+ script = [
76
+ tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
77
+ settings=delivery["host_b" if ln.get("speaker") == "host_b" else "host_a"])
78
+ for ln in lines
79
+ ]
80
+ update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")
81
+
82
+ def progress(done: int) -> None:
83
+ if done % 3 == 0 or done == len(script):
84
+ update_job(db, job, progress=0.2 + 0.75 * done / len(script),
85
+ message=f"Recorded {done} of {len(script)} lines")
86
+
87
+ clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
88
  audio = bytearray()
89
  segments = []
90
  t = 0.0
91
+ for line, clip in zip(lines, clips, strict=True):
 
 
92
  seconds = tts.mp3_seconds(clip)
93
  segments.append({"speaker": line.get("speaker", "host_a"), "text": line["text"],
94
  "start": round(t, 2), "end": round(t + seconds, 2),
95
  "sources": verify_refs(tools, line.get("sources") or [])})
96
  audio += clip
97
  t += seconds
 
 
98
  key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
99
  storage.put_bytes(key, bytes(audio), "audio/mpeg")
100
  artifact.storage_key = key
 
146
  audio = bytearray()
147
  cursor = 0.0
148
  voice_a, _ = host_voices(db, artifact.workspace_id)
149
+ narrator = host_settings(db, artifact.workspace_id)["host_a"]
150
  out_scenes = []
151
  for i, s in enumerate(scenes):
152
  narration = (s.get("narration") or "").strip()
153
  duration = _clamp(s.get("duration_hint_s"), 2.0, 20.0)
154
  if narration and settings.elevenlabs_api_key:
155
  update_job(db, job, progress=0.15 + 0.5 * i / len(scenes), message=f"Narrating scene {i + 1}")
156
+ clip, words = tts.synthesize_with_timestamps(artifact.workspace_id, narration, voice_a, narrator)
157
  seconds = tts.mp3_seconds(clip)
158
  # Pad with silence so each clip starts exactly when its scene does.
159
  audio += _silence(cursor - tts.mp3_seconds(bytes(audio)))
backend/app/pipeline/research.py CHANGED
@@ -64,7 +64,7 @@ def verify_citations(tools: ScopedTools, answer: str, citations) -> tuple[str, l
64
  return f"[{n}]" if n else ""
65
 
66
  text = _MARKER.sub(swap, answer)
67
- text = re.sub(r"\s+([.,;:!?])", r"\1", text)
68
  return text.strip(), verified
69
 
70
 
 
64
  return f"[{n}]" if n else ""
65
 
66
  text = _MARKER.sub(swap, answer)
67
+ text = re.sub(r"[ \t]+([.,;:!?])", r"\1", text) # keep line breaks: answers are Markdown
68
  return text.strip(), verified
69
 
70
 
backend/app/routers/voice.py CHANGED
@@ -1,16 +1,17 @@
 
1
  import uuid
2
  from datetime import UTC, datetime
3
 
4
  from fastapi import APIRouter, Depends, HTTPException, Request
5
  from fastapi.concurrency import run_in_threadpool
6
  from pydantic import BaseModel, Field
7
- from sqlalchemy import select
8
 
9
  from app.auth import Ctx, get_ctx
10
  from app.config import settings
11
- from app.models import Upload, VoiceConsent, VoiceProfile
12
  from app.services import tts
13
- from app.services.jobs import create_job, serialize_job
14
  from app.services.plans import effective_plan
15
  from app.services.storage import storage
16
 
@@ -21,27 +22,60 @@ CONSENT_TEXT = (
21
  "ElevenLabs, only for audio and video made in my workspace. I can revoke this at any time, which deletes "
22
  "the synthetic voice."
23
  )
 
24
 
25
 
26
  class BuildIn(BaseModel):
27
  document_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
28
 
29
 
 
 
 
 
 
 
 
30
  class ProfileIn(BaseModel):
31
  profile: dict | None = None
32
  host_voices: dict[str, str] | None = None
 
33
 
34
 
35
  class ConsentIn(BaseModel):
36
- upload_id: uuid.UUID
 
37
  agreed: bool
38
  consent_text: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
  def _profile(ctx: Ctx) -> VoiceProfile | None:
42
  return ctx.db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == ctx.workspace.id))
43
 
44
 
 
 
 
 
 
 
 
 
45
  def _consent(ctx: Ctx) -> VoiceConsent | None:
46
  return ctx.db.scalar(
47
  select(VoiceConsent).where(VoiceConsent.workspace_id == ctx.workspace.id, VoiceConsent.revoked_at.is_(None))
@@ -49,25 +83,42 @@ def _consent(ctx: Ctx) -> VoiceConsent | None:
49
  )
50
 
51
 
 
 
 
 
 
 
 
52
  def _serialize(ctx: Ctx) -> dict:
53
  vp = _profile(ctx)
54
  consent = _consent(ctx)
55
  plan = effective_plan(ctx.db, ctx.workspace)
56
  voices = (vp.host_voices if vp else None) or {}
 
 
 
57
  return {
58
  "profile": vp.profile_json if vp else None,
59
  "sample_doc_ids": vp.sample_doc_ids if vp else [],
60
  "updated_at": vp.updated_at.isoformat() if vp and vp.updated_at else None,
61
  "host_voices": {"host_a": voices.get("host_a") or settings.elevenlabs_voice_a,
62
  "host_b": voices.get("host_b") or settings.elevenlabs_voice_b},
 
63
  "clone": {
64
  "allowed": plan.voice_cloning,
65
  "consent_text": CONSENT_TEXT,
66
- "status": ("ready" if consent and consent.elevenlabs_voice_id else "processing" if consent else "none"),
 
 
67
  "voice_id": consent.elevenlabs_voice_id if consent else None,
68
  "created_at": consent.created_at.isoformat() if consent else None,
 
 
69
  },
70
  "tts_configured": bool(settings.elevenlabs_api_key),
 
 
71
  }
72
 
73
 
@@ -78,14 +129,17 @@ def get_voice(ctx: Ctx = Depends(get_ctx)):
78
 
79
  @router.put("")
80
  def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
81
- vp = _profile(ctx)
82
- if not vp:
83
- vp = VoiceProfile(workspace_id=ctx.workspace.id)
84
- ctx.db.add(vp)
85
  if body.profile is not None:
86
  vp.profile_json = body.profile
 
87
  if body.host_voices is not None:
88
- vp.host_voices = {k: v for k, v in body.host_voices.items() if k in ("host_a", "host_b") and v}
 
 
 
 
 
89
  ctx.db.commit()
90
  return _serialize(ctx)
91
 
@@ -101,11 +155,95 @@ def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
101
  async def voices(ctx: Ctx = Depends(get_ctx)):
102
  items = await run_in_threadpool(tts.list_voices)
103
  consent = _consent(ctx)
104
- if consent and consent.elevenlabs_voice_id and not any(v["voice_id"] == consent.elevenlabs_voice_id for v in items):
105
- items = [{"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "category": "cloned"}, *items]
 
 
 
106
  return items
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  @router.post("/consent")
110
  def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
111
  if not effective_plan(ctx.db, ctx.workspace).voice_cloning:
@@ -114,14 +252,24 @@ def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx))
114
  raise HTTPException(400, "Please read and accept the consent statement.")
115
  if not settings.elevenlabs_api_key:
116
  raise HTTPException(503, "Voice cloning needs ELEVENLABS_API_KEY in .env")
117
- upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
118
- if not upload or upload.status != "complete" or not upload.content_type.startswith("audio/"):
119
- raise HTTPException(400, "Upload an audio recording first.")
120
- consent = VoiceConsent(workspace_id=ctx.workspace.id, user_id=ctx.user.id, sample_key=upload.key,
 
 
 
 
 
 
121
  consent_text=CONSENT_TEXT, ip_address=request.client.host if request.client else None)
122
  ctx.db.add(consent)
123
  ctx.db.commit()
124
- job = create_job(ctx.db, ctx.workspace.id, "voice_clone", {"consent_id": str(consent.id)}, max_attempts=1)
 
 
 
 
125
  return {"job": serialize_job(job), **_serialize(ctx)}
126
 
127
 
@@ -133,7 +281,7 @@ async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
133
  if consent.elevenlabs_voice_id and settings.elevenlabs_api_key:
134
  try:
135
  await run_in_threadpool(tts.delete_voice, consent.elevenlabs_voice_id)
136
- except Exception:
137
  pass
138
  consent.revoked_at = datetime.now(UTC)
139
  storage.delete(consent.sample_key)
 
1
+ import re
2
  import uuid
3
  from datetime import UTC, datetime
4
 
5
  from fastapi import APIRouter, Depends, HTTPException, Request
6
  from fastapi.concurrency import run_in_threadpool
7
  from pydantic import BaseModel, Field
8
+ from sqlalchemy import func, select
9
 
10
  from app.auth import Ctx, get_ctx
11
  from app.config import settings
12
+ from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
13
  from app.services import tts
14
+ from app.services.jobs import create_job, record_usage, serialize_job
15
  from app.services.plans import effective_plan
16
  from app.services.storage import storage
17
 
 
22
  "ElevenLabs, only for audio and video made in my workspace. I can revoke this at any time, which deletes "
23
  "the synthetic voice."
24
  )
25
+ PREVIEW_LINE = "Here is how I sound reading your archive. Every line I say comes from something you wrote."
26
 
27
 
28
  class BuildIn(BaseModel):
29
  document_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
30
 
31
 
32
+ class DeliveryIn(BaseModel):
33
+ stability: float = Field(0.45, ge=0, le=1)
34
+ similarity_boost: float = Field(0.8, ge=0, le=1)
35
+ style: float = Field(0.2, ge=0, le=1)
36
+ speed: float = Field(1.0, ge=0.7, le=1.2)
37
+
38
+
39
  class ProfileIn(BaseModel):
40
  profile: dict | None = None
41
  host_voices: dict[str, str] | None = None
42
+ delivery: dict[str, DeliveryIn] | None = None # per host: {"host_a": {...}, "host_b": {...}}
43
 
44
 
45
  class ConsentIn(BaseModel):
46
+ upload_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
47
+ upload_id: uuid.UUID | None = None # single sample (older clients)
48
  agreed: bool
49
  consent_text: str
50
+ remove_background_noise: bool = True
51
+
52
+
53
+ class PreviewIn(BaseModel):
54
+ voice_id: str = Field(min_length=5, max_length=60)
55
+ text: str | None = Field(None, max_length=400)
56
+ host: str | None = None # use that host's saved delivery settings
57
+ delivery: DeliveryIn | None = None
58
+
59
+
60
+ class LibraryAddIn(BaseModel):
61
+ public_owner_id: str = Field(min_length=5, max_length=100)
62
+ voice_id: str = Field(min_length=5, max_length=60)
63
+ name: str = Field(min_length=1, max_length=100)
64
+ use_as: str | None = None # "host_a" | "host_b"
65
 
66
 
67
  def _profile(ctx: Ctx) -> VoiceProfile | None:
68
  return ctx.db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == ctx.workspace.id))
69
 
70
 
71
+ def _ensure_profile(ctx: Ctx) -> VoiceProfile:
72
+ vp = _profile(ctx)
73
+ if not vp:
74
+ vp = VoiceProfile(workspace_id=ctx.workspace.id)
75
+ ctx.db.add(vp)
76
+ return vp
77
+
78
+
79
  def _consent(ctx: Ctx) -> VoiceConsent | None:
80
  return ctx.db.scalar(
81
  select(VoiceConsent).where(VoiceConsent.workspace_id == ctx.workspace.id, VoiceConsent.revoked_at.is_(None))
 
83
  )
84
 
85
 
86
+ def _clone_job(ctx: Ctx, consent: VoiceConsent) -> Job | None:
87
+ return ctx.db.scalar(
88
+ select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_clone")
89
+ .order_by(Job.created_at.desc()).limit(1)
90
+ )
91
+
92
+
93
  def _serialize(ctx: Ctx) -> dict:
94
  vp = _profile(ctx)
95
  consent = _consent(ctx)
96
  plan = effective_plan(ctx.db, ctx.workspace)
97
  voices = (vp.host_voices if vp else None) or {}
98
+ delivery = voices.get("settings") or {}
99
+ job = _clone_job(ctx, consent) if consent else None
100
+ preview_key = (job.result or {}).get("preview_key") if job and job.status == "done" else None
101
  return {
102
  "profile": vp.profile_json if vp else None,
103
  "sample_doc_ids": vp.sample_doc_ids if vp else [],
104
  "updated_at": vp.updated_at.isoformat() if vp and vp.updated_at else None,
105
  "host_voices": {"host_a": voices.get("host_a") or settings.elevenlabs_voice_a,
106
  "host_b": voices.get("host_b") or settings.elevenlabs_voice_b},
107
+ "delivery": {h: tts.VoiceSettings.from_dict(delivery.get(h)).body() for h in ("host_a", "host_b")},
108
  "clone": {
109
  "allowed": plan.voice_cloning,
110
  "consent_text": CONSENT_TEXT,
111
+ "status": ("ready" if consent and consent.elevenlabs_voice_id
112
+ else "failed" if consent and job and job.status == "failed"
113
+ else "processing" if consent else "none"),
114
  "voice_id": consent.elevenlabs_voice_id if consent else None,
115
  "created_at": consent.created_at.isoformat() if consent else None,
116
+ "error": job.error if job and job.status == "failed" else None,
117
+ "preview_url": storage.presign_get(preview_key) if preview_key else None,
118
  },
119
  "tts_configured": bool(settings.elevenlabs_api_key),
120
+ "model": tts.model_id(),
121
+ "models": tts.MODELS,
122
  }
123
 
124
 
 
129
 
130
  @router.put("")
131
  def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
132
+ vp = _ensure_profile(ctx)
 
 
 
133
  if body.profile is not None:
134
  vp.profile_json = body.profile
135
+ voices = dict(vp.host_voices or {})
136
  if body.host_voices is not None:
137
+ voices.update({k: v for k, v in body.host_voices.items() if k in ("host_a", "host_b") and v})
138
+ if body.delivery is not None:
139
+ saved = dict(voices.get("settings") or {})
140
+ saved.update({h: d.model_dump() for h, d in body.delivery.items() if h in ("host_a", "host_b")})
141
+ voices["settings"] = saved
142
+ vp.host_voices = voices
143
  ctx.db.commit()
144
  return _serialize(ctx)
145
 
 
155
  async def voices(ctx: Ctx = Depends(get_ctx)):
156
  items = await run_in_threadpool(tts.list_voices)
157
  consent = _consent(ctx)
158
+ if consent and consent.elevenlabs_voice_id:
159
+ items = [{**v, "name": f"{v['name']} (my voice)", "category": "my voice"}
160
+ if v["voice_id"] == consent.elevenlabs_voice_id else v for v in items]
161
+ if not any(v["voice_id"] == consent.elevenlabs_voice_id for v in items):
162
+ items = [{"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "category": "my voice"}, *items]
163
  return items
164
 
165
 
166
+ @router.get("/library")
167
+ async def library(ctx: Ctx = Depends(get_ctx), search: str = "", gender: str = "", age: str = "",
168
+ accent: str = "", language: str = "", use_case: str = "", page: int = 0):
169
+ if not settings.elevenlabs_api_key:
170
+ raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to browse the voice library.")
171
+ try:
172
+ return await run_in_threadpool(lambda: tts.search_library(
173
+ search=search, gender=gender, age=age, accent=accent, language=language, use_case=use_case,
174
+ page=max(page, 0)))
175
+ except tts.TTSError as exc:
176
+ raise HTTPException(502, str(exc)) from exc
177
+
178
+
179
+ @router.post("/library/add")
180
+ async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
181
+ try:
182
+ voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
183
+ except tts.TTSError as exc:
184
+ raise HTTPException(502, str(exc)) from exc
185
+ if body.use_as in ("host_a", "host_b"):
186
+ vp = _ensure_profile(ctx)
187
+ vp.host_voices = {**(vp.host_voices or {}), body.use_as: voice_id}
188
+ ctx.db.commit()
189
+ return {"voice_id": voice_id, **_serialize(ctx)}
190
+
191
+
192
+ @router.post("/preview")
193
+ async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
194
+ """Speak a short line in any voice with the given (or a host's saved) delivery. Cached, metered."""
195
+ if not settings.elevenlabs_api_key:
196
+ raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to preview voices.")
197
+ if body.delivery:
198
+ vs = tts.VoiceSettings.from_dict(body.delivery.model_dump())
199
+ else:
200
+ saved = (((_profile(ctx) or VoiceProfile()).host_voices or {}).get("settings") or {}).get(body.host or "")
201
+ vs = tts.VoiceSettings.from_dict(saved)
202
+ text = (body.text or PREVIEW_LINE).strip()[:400]
203
+ try:
204
+ clip = await run_in_threadpool(tts.synthesize, ctx.workspace.id, text, body.voice_id, vs)
205
+ except tts.TTSError as exc:
206
+ raise HTTPException(502, str(exc)) from exc
207
+ key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
208
+ storage.put_bytes(key, clip, "audio/mpeg")
209
+ record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="tts", provider="elevenlabs",
210
+ quantity=round(tts.mp3_seconds(clip), 1), unit="seconds")
211
+ return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
212
+
213
+
214
+ @router.get("/reading-script")
215
+ def reading_script(ctx: Ctx = Depends(get_ctx)):
216
+ """About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
217
+ doc = ctx.db.scalar(
218
+ select(Document).where(Document.workspace_id == ctx.workspace.id, Document.clean_text != "")
219
+ .order_by(func.length(Document.clean_text).desc()).limit(1)
220
+ )
221
+ if not doc:
222
+ text = ("Writing is how I think out loud. I start with a question I cannot answer yet, then I follow it "
223
+ "until the answer surprises me. Some weeks that takes a paragraph; some weeks it takes a month. "
224
+ "What matters is that every sentence earns its place, and that the reader leaves with something "
225
+ "they did not have when they arrived.")
226
+ return {"title": "Sample passage", "text": text, "words": len(text.split())}
227
+ paragraphs = [p for p in re.split(r"\n\s*\n", doc.clean_text) if len(p.split()) > 12 and not p.startswith("## ")]
228
+ picked, words = [], 0
229
+ for p in paragraphs:
230
+ picked.append(p.strip())
231
+ words += len(p.split())
232
+ if words >= 280: # roughly two minutes read aloud
233
+ break
234
+ if not picked: # short or list heavy posts: read the text as is
235
+ plain = re.sub(r"^## .*$", "", doc.clean_text, flags=re.M).split()
236
+ picked, words = [" ".join(plain[:280])], min(len(plain), 280)
237
+ return {"title": doc.title, "text": "\n\n".join(picked), "words": words}
238
+
239
+
240
+ @router.get("/quota")
241
+ async def quota(ctx: Ctx = Depends(get_ctx)):
242
+ if not settings.elevenlabs_api_key:
243
+ return {}
244
+ return await run_in_threadpool(tts.subscription)
245
+
246
+
247
  @router.post("/consent")
248
  def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
249
  if not effective_plan(ctx.db, ctx.workspace).voice_cloning:
 
252
  raise HTTPException(400, "Please read and accept the consent statement.")
253
  if not settings.elevenlabs_api_key:
254
  raise HTTPException(503, "Voice cloning needs ELEVENLABS_API_KEY in .env")
255
+ ids = body.upload_ids or ([body.upload_id] if body.upload_id else [])
256
+ uploads = ctx.db.scalars(select(Upload).where(Upload.id.in_(ids), Upload.workspace_id == ctx.workspace.id)).all()
257
+ uploads = [u for u in uploads if u.status == "complete" and u.content_type.startswith("audio/")]
258
+ if not uploads:
259
+ raise HTTPException(400, "Record or upload at least one audio sample first.")
260
+ # A new clone replaces the previous one.
261
+ old = _consent(ctx)
262
+ if old:
263
+ old.revoked_at = datetime.now(UTC)
264
+ consent = VoiceConsent(workspace_id=ctx.workspace.id, user_id=ctx.user.id, sample_key=uploads[0].key,
265
  consent_text=CONSENT_TEXT, ip_address=request.client.host if request.client else None)
266
  ctx.db.add(consent)
267
  ctx.db.commit()
268
+ job = create_job(ctx.db, ctx.workspace.id, "voice_clone", {
269
+ "consent_id": str(consent.id), "sample_keys": [u.key for u in uploads],
270
+ "remove_background_noise": body.remove_background_noise,
271
+ "replaces_voice_id": old.elevenlabs_voice_id if old else None,
272
+ }, max_attempts=1)
273
  return {"job": serialize_job(job), **_serialize(ctx)}
274
 
275
 
 
281
  if consent.elevenlabs_voice_id and settings.elevenlabs_api_key:
282
  try:
283
  await run_in_threadpool(tts.delete_voice, consent.elevenlabs_voice_id)
284
+ except tts.TTSError:
285
  pass
286
  consent.revoked_at = datetime.now(UTC)
287
  storage.delete(consent.sample_key)
backend/app/services/tts.py CHANGED
@@ -1,7 +1,23 @@
1
- """ElevenLabs text to speech, cached in storage by (script, voice, settings)."""
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import base64
4
  import json
 
 
 
 
5
 
6
  import httpx
7
 
@@ -11,17 +27,49 @@ from app.services.storage import keys, storage
11
  API = "https://api.elevenlabs.io/v1"
12
  OUTPUT = "mp3_44100_128"
13
  BITRATE = 128_000
 
 
 
 
 
14
 
15
- PREMADE = [
16
- {"voice_id": "21m00Tcm4TlvDq8ikWAM", "name": "Rachel", "category": "premade"},
17
- {"voice_id": "pNInz6obpgDQGcFMaJgB", "name": "Adam", "category": "premade"},
18
- {"voice_id": "EXAVITQu4vr4xnSDxMaL", "name": "Bella", "category": "premade"},
19
- {"voice_id": "ErXwobaYiN019PkySvjV", "name": "Antoni", "category": "premade"},
20
- {"voice_id": "AZnzlk1XvdvUeBnXmlld", "name": "Domi", "category": "premade"},
21
- {"voice_id": "TxGEqnHWrfWFTfGW9XjX", "name": "Josh", "category": "premade"},
22
  ]
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class TTSError(RuntimeError):
26
  pass
27
 
@@ -32,44 +80,112 @@ def _headers() -> dict:
32
  return {"xi-api-key": settings.elevenlabs_api_key}
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def mp3_seconds(data: bytes) -> float:
36
  return len(data) * 8 / BITRATE
37
 
38
 
39
- def _voice_settings() -> dict:
40
- return {"stability": 0.45, "similarity_boost": 0.8, "style": 0.2, "use_speaker_boost": True}
41
 
42
 
43
- def _cache_key(workspace_id, text: str, voice_id: str, variant: str) -> str:
44
- return keys.tts_cache(workspace_id, text, voice_id,
45
- json.dumps(_voice_settings(), sort_keys=True) + settings.elevenlabs_model + variant)
46
 
47
 
48
- def synthesize(workspace_id, text: str, voice_id: str) -> bytes:
49
- key = _cache_key(workspace_id, text, voice_id, OUTPUT)
 
 
 
50
  if storage.exists(key):
51
  return storage.get_bytes(key)
52
- body = {"text": text, "model_id": settings.elevenlabs_model, "voice_settings": _voice_settings()}
53
- resp = httpx.post(f"{API}/text-to-speech/{voice_id}", params={"output_format": OUTPUT},
54
- headers=_headers(), json=body, timeout=120)
55
- if resp.status_code >= 400:
56
- raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
 
57
  storage.put_bytes(key, resp.content, "audio/mpeg")
58
  return resp.content
59
 
60
 
61
- def synthesize_with_timestamps(workspace_id, text: str, voice_id: str) -> tuple[bytes, list[dict]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  """Audio plus word timings [{text, startMs, endMs}] for captions."""
63
- key = _cache_key(workspace_id, text, voice_id, "timestamps") + ".json"
 
64
  if storage.exists(key):
65
  data = json.loads(storage.get_bytes(key))
66
  else:
67
- body = {"text": text, "model_id": settings.elevenlabs_model, "voice_settings": _voice_settings()}
68
- resp = httpx.post(f"{API}/text-to-speech/{voice_id}/with-timestamps", params={"output_format": OUTPUT},
69
- headers=_headers(), json=body, timeout=120)
70
- if resp.status_code >= 400:
71
- raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
72
- data = resp.json()
73
  storage.put_bytes(key, json.dumps(data).encode(), "application/json")
74
  audio = base64.b64decode(data["audio_base64"])
75
  return audio, words_from_alignment(data.get("alignment") or {})
@@ -96,29 +212,94 @@ def words_from_alignment(alignment: dict) -> list[dict]:
96
  return words
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def list_voices() -> list[dict]:
 
100
  if not settings.elevenlabs_api_key:
101
  return PREMADE
102
  try:
103
- resp = httpx.get(f"{API}/voices", headers=_headers(), timeout=20)
104
- resp.raise_for_status()
105
- except httpx.HTTPError:
106
  return PREMADE
107
- return [
108
- {"voice_id": v["voice_id"], "name": v["name"], "category": v.get("category"),
109
- "preview_url": v.get("preview_url")}
110
- for v in resp.json().get("voices", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  ]
 
 
 
 
 
 
 
112
 
113
 
114
- def clone_voice(name: str, sample: bytes, filename: str, content_type: str) -> str:
115
- resp = httpx.post(f"{API}/voices/add", headers=_headers(), timeout=120,
116
- data={"name": name, "description": "Notestack consented voice clone"},
117
- files={"files": (filename, sample, content_type)})
118
- if resp.status_code >= 400:
119
- raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
 
 
 
 
120
  return resp.json()["voice_id"]
121
 
122
 
123
  def delete_voice(voice_id: str) -> None:
124
- httpx.delete(f"{API}/voices/{voice_id}", headers=_headers(), timeout=30)
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ElevenLabs: speech generation, voices (premade, Voice Library, consented clones), cached in storage.
2
+
3
+ Generation
4
+ - `synthesize` renders one line. Lines of a conversation pass the neighbouring text
5
+ (previous_text / next_text) so intonation flows across cuts, and are cached by
6
+ (text, voice, model, settings, context) so re-renders cost nothing.
7
+ - 429 and 5xx responses retry with backoff; a long script renders in parallel (`synthesize_many`).
8
+
9
+ Voices
10
+ - premade voices and Voice Library ("shared") voices are licensed by ElevenLabs for commercial use on
11
+ paid ElevenLabs plans; library voices are added to the account before use.
12
+ - cloning is Instant Voice Cloning from one or more consented samples, with background noise removal.
13
+ """
14
 
15
  import base64
16
  import json
17
+ import time
18
+ from collections.abc import Callable
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from dataclasses import dataclass
21
 
22
  import httpx
23
 
 
27
  API = "https://api.elevenlabs.io/v1"
28
  OUTPUT = "mp3_44100_128"
29
  BITRATE = 128_000
30
+ MODELS = {
31
+ "eleven_multilingual_v2": "Multilingual v2 (most natural, default)",
32
+ "eleven_turbo_v2_5": "Turbo v2.5 (fast, half the cost)",
33
+ "eleven_flash_v2_5": "Flash v2.5 (fastest, cheapest)",
34
+ }
35
 
36
+ PREMADE = [ # current ElevenLabs default voices, shown when the account list cannot be fetched
37
+ {"voice_id": "EXAVITQu4vr4xnSDxMaL", "name": "Sarah", "category": "premade"},
38
+ {"voice_id": "JBFqnCBsd6RMkjVDRZzb", "name": "George", "category": "premade"},
39
+ {"voice_id": "CwhRBWXzGAHq8TQ4Fs17", "name": "Roger", "category": "premade"},
40
+ {"voice_id": "cgSgspJ2msm6clMCkdW9", "name": "Jessica", "category": "premade"},
41
+ {"voice_id": "nPczCjzI2devNBz1zQrb", "name": "Brian", "category": "premade"},
42
+ {"voice_id": "IKne3meq5aSn9XLyUdCD", "name": "Charlie", "category": "premade"},
43
  ]
44
 
45
 
46
+ @dataclass(frozen=True)
47
+ class VoiceSettings:
48
+ stability: float = 0.45
49
+ similarity_boost: float = 0.8
50
+ style: float = 0.2
51
+ speed: float = 1.0
52
+ use_speaker_boost: bool = True
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict | None) -> "VoiceSettings":
56
+ data = data or {}
57
+
58
+ def clamp(key: str, lo: float, hi: float) -> float:
59
+ try:
60
+ return max(lo, min(hi, float(data.get(key, getattr(cls, key)))))
61
+ except (TypeError, ValueError):
62
+ return getattr(cls, key)
63
+
64
+ return cls(stability=clamp("stability", 0, 1), similarity_boost=clamp("similarity_boost", 0, 1),
65
+ style=clamp("style", 0, 1), speed=clamp("speed", 0.7, 1.2),
66
+ use_speaker_boost=bool(data.get("use_speaker_boost", True)))
67
+
68
+ def body(self) -> dict:
69
+ return {"stability": self.stability, "similarity_boost": self.similarity_boost, "style": self.style,
70
+ "speed": self.speed, "use_speaker_boost": self.use_speaker_boost}
71
+
72
+
73
  class TTSError(RuntimeError):
74
  pass
75
 
 
80
  return {"xi-api-key": settings.elevenlabs_api_key}
81
 
82
 
83
+ def _request(method: str, path: str, *, attempts: int = 4, **kwargs) -> httpx.Response:
84
+ """ElevenLabs call with retries for rate limits and transient errors."""
85
+ delay = 1.5
86
+ for attempt in range(attempts):
87
+ try:
88
+ resp = httpx.request(method, f"{API}{path}", headers=_headers(), timeout=kwargs.pop("timeout", 120),
89
+ **kwargs)
90
+ except httpx.TransportError as exc:
91
+ if attempt == attempts - 1:
92
+ raise TTSError(f"ElevenLabs unreachable: {exc}") from exc
93
+ time.sleep(delay)
94
+ delay *= 2
95
+ continue
96
+ if resp.status_code == 429 or resp.status_code >= 500:
97
+ if attempt == attempts - 1:
98
+ break
99
+ time.sleep(float(resp.headers.get("retry-after") or delay))
100
+ delay *= 2
101
+ continue
102
+ if resp.status_code >= 400:
103
+ raise TTSError(_explain(resp))
104
+ return resp
105
+ raise TTSError(_explain(resp))
106
+
107
+
108
+ def _explain(resp: httpx.Response) -> str:
109
+ try:
110
+ detail = resp.json().get("detail")
111
+ message = detail.get("message") if isinstance(detail, dict) else detail
112
+ except ValueError:
113
+ message = resp.text[:300]
114
+ if resp.status_code == 401:
115
+ return "ElevenLabs rejected the API key."
116
+ if resp.status_code == 429:
117
+ return "ElevenLabs is rate limiting or the character quota is used up."
118
+ return f"ElevenLabs {resp.status_code}: {message}"
119
+
120
+
121
  def mp3_seconds(data: bytes) -> float:
122
  return len(data) * 8 / BITRATE
123
 
124
 
125
+ def model_id() -> str:
126
+ return settings.elevenlabs_model if settings.elevenlabs_model in MODELS else "eleven_multilingual_v2"
127
 
128
 
129
+ def _cache_key(workspace_id, text: str, voice_id: str, vs: VoiceSettings, variant: str) -> str:
130
+ return keys.tts_cache(workspace_id, text, voice_id, json.dumps(vs.body(), sort_keys=True) + model_id() + variant)
 
131
 
132
 
133
+ def synthesize(workspace_id, text: str, voice_id: str, voice_settings: VoiceSettings | None = None,
134
+ previous_text: str | None = None, next_text: str | None = None) -> bytes:
135
+ vs = voice_settings or VoiceSettings()
136
+ context = f"|{(previous_text or '')[-300:]}|{(next_text or '')[:300]}"
137
+ key = _cache_key(workspace_id, text, voice_id, vs, OUTPUT + context)
138
  if storage.exists(key):
139
  return storage.get_bytes(key)
140
+ body: dict = {"text": text, "model_id": model_id(), "voice_settings": vs.body()}
141
+ if previous_text:
142
+ body["previous_text"] = previous_text[-300:]
143
+ if next_text:
144
+ body["next_text"] = next_text[:300]
145
+ resp = _request("POST", f"/text-to-speech/{voice_id}", params={"output_format": OUTPUT}, json=body)
146
  storage.put_bytes(key, resp.content, "audio/mpeg")
147
  return resp.content
148
 
149
 
150
+ @dataclass
151
+ class Line:
152
+ text: str
153
+ voice_id: str
154
+ settings: VoiceSettings
155
+
156
+
157
+ def synthesize_many(workspace_id, lines: list[Line], on_done: Callable[[int], None] | None = None,
158
+ workers: int = 4) -> list[bytes]:
159
+ """Render a whole script in parallel, in order. Each line hears its neighbours for natural flow."""
160
+ def render(i: int) -> bytes:
161
+ line = lines[i]
162
+ prev_text = lines[i - 1].text if i > 0 else None
163
+ next_text = lines[i + 1].text if i + 1 < len(lines) else None
164
+ return synthesize(workspace_id, line.text, line.voice_id, line.settings, prev_text, next_text)
165
+
166
+ out: list[bytes | None] = [None] * len(lines)
167
+ done = 0
168
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
169
+ futures = {pool.submit(render, i): i for i in range(len(lines))}
170
+ for fut, i in futures.items():
171
+ out[i] = fut.result()
172
+ done += 1
173
+ if on_done:
174
+ on_done(done)
175
+ return [b or b"" for b in out]
176
+
177
+
178
+ def synthesize_with_timestamps(workspace_id, text: str, voice_id: str,
179
+ voice_settings: VoiceSettings | None = None) -> tuple[bytes, list[dict]]:
180
  """Audio plus word timings [{text, startMs, endMs}] for captions."""
181
+ vs = voice_settings or VoiceSettings()
182
+ key = _cache_key(workspace_id, text, voice_id, vs, "timestamps") + ".json"
183
  if storage.exists(key):
184
  data = json.loads(storage.get_bytes(key))
185
  else:
186
+ body = {"text": text, "model_id": model_id(), "voice_settings": vs.body()}
187
+ data = _request("POST", f"/text-to-speech/{voice_id}/with-timestamps", params={"output_format": OUTPUT},
188
+ json=body).json()
 
 
 
189
  storage.put_bytes(key, json.dumps(data).encode(), "application/json")
190
  audio = base64.b64decode(data["audio_base64"])
191
  return audio, words_from_alignment(data.get("alignment") or {})
 
212
  return words
213
 
214
 
215
+ # Voices
216
+
217
+
218
+ def _voice(v: dict) -> dict:
219
+ labels = v.get("labels") or {}
220
+ return {
221
+ "voice_id": v["voice_id"],
222
+ "name": v.get("name") or "Voice",
223
+ "category": v.get("category"),
224
+ "preview_url": v.get("preview_url"),
225
+ "description": v.get("description") or labels.get("description"),
226
+ "labels": {k: labels[k] for k in ("accent", "age", "gender", "use_case", "descriptive") if labels.get(k)},
227
+ }
228
+
229
+
230
  def list_voices() -> list[dict]:
231
+ """Voices on the account: premade, added library voices, clones."""
232
  if not settings.elevenlabs_api_key:
233
  return PREMADE
234
  try:
235
+ data = _request("GET", "/voices", attempts=2, timeout=20).json()
236
+ except TTSError:
 
237
  return PREMADE
238
+ return [_voice(v) for v in data.get("voices", [])]
239
+
240
+
241
+ def search_library(*, search: str = "", gender: str = "", age: str = "", accent: str = "", language: str = "",
242
+ use_case: str = "", page: int = 0, page_size: int = 24) -> dict:
243
+ """ElevenLabs Voice Library: community voices licensed for commercial use on paid ElevenLabs plans."""
244
+ params = {"page_size": page_size, "page": page, "sort": "trending"}
245
+ for k, v in {"search": search, "gender": gender, "age": age, "accent": accent, "language": language,
246
+ "use_cases": use_case}.items():
247
+ if v:
248
+ params[k] = v
249
+ data = _request("GET", "/shared-voices", params=params, attempts=2, timeout=20).json()
250
+ voices = [
251
+ {
252
+ "public_owner_id": v["public_owner_id"],
253
+ "voice_id": v["voice_id"],
254
+ "name": v.get("name"),
255
+ "description": v.get("description"),
256
+ "preview_url": v.get("preview_url"),
257
+ "gender": v.get("gender"),
258
+ "age": v.get("age"),
259
+ "accent": v.get("accent"),
260
+ "language": v.get("language"),
261
+ "use_case": v.get("use_case"),
262
+ "descriptive": v.get("descriptive"),
263
+ "category": v.get("category"),
264
+ "cloned_by_count": v.get("cloned_by_count"),
265
+ "free_users_allowed": v.get("free_users_allowed", True),
266
+ "notice_period": v.get("notice_period"),
267
+ }
268
+ for v in data.get("voices", [])
269
  ]
270
+ return {"voices": voices, "has_more": bool(data.get("has_more"))}
271
+
272
+
273
+ def add_library_voice(public_owner_id: str, voice_id: str, name: str) -> str:
274
+ resp = _request("POST", f"/voices/add/{public_owner_id}/{voice_id}", json={"new_name": name[:100]},
275
+ attempts=2, timeout=30)
276
+ return resp.json()["voice_id"]
277
 
278
 
279
+ def clone_voice(name: str, samples: list[tuple[str, bytes, str]], *, remove_background_noise: bool = True,
280
+ description: str = "") -> str:
281
+ """Instant Voice Clone from one or more (filename, bytes, content_type) samples."""
282
+ files = [("files", (fn, data, ct)) for fn, data, ct in samples]
283
+ resp = _request("POST", "/voices/add", attempts=2, timeout=180, files=files, data={
284
+ "name": name[:100],
285
+ "description": description or "Notestack consented voice clone",
286
+ "remove_background_noise": "true" if remove_background_noise else "false",
287
+ "labels": json.dumps({"source": "notestack"}),
288
+ })
289
  return resp.json()["voice_id"]
290
 
291
 
292
  def delete_voice(voice_id: str) -> None:
293
+ _request("DELETE", f"/voices/{voice_id}", attempts=2, timeout=30)
294
+
295
+
296
+ def subscription() -> dict:
297
+ """Character quota, so the UI can warn before a long render."""
298
+ try:
299
+ data = _request("GET", "/user/subscription", attempts=1, timeout=15).json()
300
+ except TTSError:
301
+ return {}
302
+ return {"tier": data.get("tier"), "character_count": data.get("character_count"),
303
+ "character_limit": data.get("character_limit"),
304
+ "can_use_instant_voice_cloning": data.get("can_use_instant_voice_cloning"),
305
+ "voice_slots_used": data.get("voice_slots_used"), "voice_limit": data.get("voice_limit")}
backend/app/worker.py CHANGED
@@ -37,6 +37,7 @@ from app.models import (
37
  Upload,
38
  User,
39
  VoiceConsent,
 
40
  Workspace,
41
  WorkspaceMember,
42
  )
@@ -46,10 +47,10 @@ from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article
46
  from app.services import launchpad, tts
47
  from app.services.email import email_service
48
  from app.services.email_verification import purge_old_codes
49
- from app.services.jobs import claim_next, create_job, retry_or_fail, stale_jobs, update_job
50
  from app.services.plans import effective_plan
51
  from app.services.renderer import PermanentJobError, request_render
52
- from app.services.storage import storage
53
 
54
  log = logging.getLogger("notestack.worker")
55
 
@@ -131,16 +132,41 @@ def handle_voice_clone(db: Session, job: Job):
131
  consent = db.get(VoiceConsent, uuid.UUID(job.params["consent_id"]))
132
  if not consent or consent.revoked_at:
133
  return Done({}, "Consent was revoked")
134
- update_job(db, job, progress=0.2, message="Sending your sample to ElevenLabs")
135
- head = storage.head(consent.sample_key) or {}
 
 
 
 
 
136
  try:
137
- voice_id = tts.clone_voice(f"Notestack {consent.workspace_id}", storage.get_bytes(consent.sample_key),
138
- consent.sample_key.rsplit("/", 1)[-1], head.get("ContentType") or "audio/webm")
139
  except tts.TTSError as exc:
140
  raise PermanentJobError(str(exc)) from exc
141
  consent.elevenlabs_voice_id = voice_id
142
  db.commit()
143
- return Done({"voice_id": voice_id}, "Your voice is ready")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
 
146
  def handle_resurface(db: Session, job: Job):
 
37
  Upload,
38
  User,
39
  VoiceConsent,
40
+ VoiceProfile,
41
  Workspace,
42
  WorkspaceMember,
43
  )
 
47
  from app.services import launchpad, tts
48
  from app.services.email import email_service
49
  from app.services.email_verification import purge_old_codes
50
+ from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job
51
  from app.services.plans import effective_plan
52
  from app.services.renderer import PermanentJobError, request_render
53
+ from app.services.storage import keys, storage
54
 
55
  log = logging.getLogger("notestack.worker")
56
 
 
132
  consent = db.get(VoiceConsent, uuid.UUID(job.params["consent_id"]))
133
  if not consent or consent.revoked_at:
134
  return Done({}, "Consent was revoked")
135
+ sample_keys = job.params.get("sample_keys") or [consent.sample_key]
136
+ update_job(db, job, progress=0.15, message=f"Sending {len(sample_keys)} recording(s) to ElevenLabs")
137
+ samples = []
138
+ for key in sample_keys:
139
+ head = storage.head(key) or {}
140
+ samples.append((key.rsplit("/", 1)[-1], storage.get_bytes(key), head.get("ContentType") or "audio/webm"))
141
+ owner = db.get(User, consent.user_id)
142
  try:
143
+ voice_id = tts.clone_voice(f"{(owner.name if owner and owner.name else 'My')} (Notestack)", samples,
144
+ remove_background_noise=bool(job.params.get("remove_background_noise", True)))
145
  except tts.TTSError as exc:
146
  raise PermanentJobError(str(exc)) from exc
147
  consent.elevenlabs_voice_id = voice_id
148
  db.commit()
149
+
150
+ # Make the new voice host A and record a short preview so the writer can judge it straight away.
151
+ update_job(db, job, progress=0.7, message="Recording a preview in your voice")
152
+ vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id))
153
+ if not vp:
154
+ vp = VoiceProfile(workspace_id=consent.workspace_id)
155
+ db.add(vp)
156
+ vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id}
157
+ db.commit()
158
+ preview_text = job.params.get("preview_text") or (
159
+ "Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.")
160
+ preview_key = None
161
+ try:
162
+ clip = tts.synthesize(consent.workspace_id, preview_text[:600], voice_id)
163
+ preview_key = storage.put_bytes(keys.artifact(consent.workspace_id, consent.id, "voice-preview", "mp3"),
164
+ clip, "audio/mpeg")
165
+ record_usage(db, workspace_id=consent.workspace_id, kind="tts", provider="elevenlabs",
166
+ quantity=round(tts.mp3_seconds(clip), 1), unit="seconds", job=job)
167
+ except tts.TTSError:
168
+ log.warning("voice preview failed for %s", voice_id, exc_info=True)
169
+ return Done({"voice_id": voice_id, "preview_key": preview_key}, "Your voice is ready and set as host A")
170
 
171
 
172
  def handle_resurface(db: Session, job: Job):
backend/tests/test_buildout.py CHANGED
@@ -305,7 +305,16 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
305
  connect(client, auth, run_jobs)
306
  doc = docs(client, auth)[0]
307
  monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
308
- monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice: b"\x00" * 16000) # 1 second at 128 kbps
 
 
 
 
 
 
 
 
 
309
  llm["PodcastScript"] = {"lines": [
310
  {"speaker": "host_a", "text": "Welcome in.", "sources": []},
311
  {"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
@@ -317,6 +326,11 @@ def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatc
317
  done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
318
  assert done["status"] == "ready", done["content"]
319
  assert done["content"]["duration_s"] == 2.0
 
 
 
 
 
320
  assert done["url"] and client.get(done["url"].split("8000", 1)[1]).content == b"\x00" * 32000
321
 
322
  ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
@@ -610,3 +624,79 @@ def test_artifact_ownership(client, auth, db_session):
610
  db_session.add(other)
611
  db_session.commit()
612
  assert client.get(f"/api/artifacts/{other.id}", headers=auth).status_code == 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  connect(client, auth, run_jobs)
306
  doc = docs(client, auth)[0]
307
  monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
308
+ calls = []
309
+
310
+ def fake_synth(ws, text, voice, vs=None, prev=None, nxt=None):
311
+ calls.append((text, voice, prev, nxt, vs.stability if vs else None))
312
+ return b"\x00" * 16000 # 1 second at 128 kbps
313
+
314
+ monkeypatch.setattr(tts, "synthesize", fake_synth)
315
+ client.put("/api/voice", json={"host_voices": {"host_a": "voiceA", "host_b": "voiceB"},
316
+ "delivery": {"host_b": {"stability": 0.9, "similarity_boost": 0.7,
317
+ "style": 0.1, "speed": 1.1}}}, headers=auth)
318
  llm["PodcastScript"] = {"lines": [
319
  {"speaker": "host_a", "text": "Welcome in.", "sources": []},
320
  {"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
 
326
  done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
327
  assert done["status"] == "ready", done["content"]
328
  assert done["content"]["duration_s"] == 2.0
329
+ # Each line hears its neighbour; hosts use their own voice and delivery settings.
330
+ by_text = {c[0]: c for c in calls}
331
+ assert by_text["Welcome in."][1] == "voiceA" and by_text["Welcome in."][3] == "Prices went up."
332
+ assert by_text["Prices went up."][1] == "voiceB" and by_text["Prices went up."][2] == "Welcome in."
333
+ assert by_text["Prices went up."][4] == 0.9
334
  assert done["url"] and client.get(done["url"].split("8000", 1)[1]).content == b"\x00" * 32000
335
 
336
  ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
 
624
  db_session.add(other)
625
  db_session.commit()
626
  assert client.get(f"/api/artifacts/{other.id}", headers=auth).status_code == 404
627
+
628
+
629
+ # Voice pipeline (ElevenLabs stubbed)
630
+
631
+
632
+ def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_jobs):
633
+ from app.config import settings
634
+ from app.services import tts
635
+
636
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
637
+ monkeypatch.setattr(tts, "search_library", lambda **kw: {"voices": [{
638
+ "public_owner_id": "owner-1", "voice_id": "lib-voice-1",
639
+ "name": "Narrator"}], "has_more": False})
640
+ monkeypatch.setattr(tts, "add_library_voice", lambda owner, vid, name: "added-" + vid)
641
+ monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: b"\x00" * 8000)
642
+
643
+ assert client.get("/api/voice/library", params={"gender": "female"}, headers=auth).json()["voices"][0]["name"] == \
644
+ "Narrator"
645
+ r = client.post("/api/voice/library/add", json={
646
+ "public_owner_id": "owner-1", "voice_id": "lib-voice-1", "name": "Narrator",
647
+ "use_as": "host_b"}, headers=auth).json()
648
+ assert r["voice_id"] == "added-lib-voice-1" and r["host_voices"]["host_b"] == "added-lib-voice-1"
649
+
650
+ prev = client.post("/api/voice/preview", json={"voice_id": "added-lib-voice-1", "text": "Hello there",
651
+ "delivery": {"stability": 0.3, "similarity_boost": 0.8,
652
+ "style": 0, "speed": 0.9}}, headers=auth).json()
653
+ assert prev["seconds"] == 0.5 and client.get(prev["url"].split("8000", 1)[1]).status_code == 200
654
+
655
+ connect(client, auth, run_jobs)
656
+ script = client.get("/api/voice/reading-script", headers=auth).json()
657
+ assert script["words"] > 5 and script["title"]
658
+
659
+
660
+ def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatch, run_jobs):
661
+ from app.config import settings
662
+ from app.services import tts
663
+
664
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
665
+ sent = {}
666
+
667
+ def fake_clone(name, samples, remove_background_noise=True, description=""):
668
+ sent.update(n=len(samples), noise=remove_background_noise, name=name)
669
+ return "clone-1"
670
+
671
+ monkeypatch.setattr(tts, "clone_voice", fake_clone)
672
+ monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: b"\x00" * 16000)
673
+ ids = []
674
+ for i in range(2):
675
+ start = client.post("/api/storage/uploads", json={"filename": f"take{i}.webm", "content_type": "audio/webm",
676
+ "size_bytes": 4}, headers=auth).json()
677
+ client.put(start["upload_url"].split("8000", 1)[1], content=b"RIFF")
678
+ client.post(f"/api/storage/uploads/{start['upload_id']}/complete", headers=auth)
679
+ ids.append(start["upload_id"])
680
+ consent_text = client.get("/api/voice", headers=auth).json()["clone"]["consent_text"]
681
+ r = client.post("/api/voice/consent", json={"upload_ids": ids, "agreed": True, "consent_text": consent_text,
682
+ "remove_background_noise": False}, headers=auth)
683
+ assert r.status_code == 200, r.text
684
+ assert run_jobs()[0].status == "done"
685
+ assert sent == {"n": 2, "noise": False, "name": "Ada Lovelace (Notestack)"}
686
+ voice = client.get("/api/voice", headers=auth).json()
687
+ assert voice["clone"]["status"] == "ready" and voice["host_voices"]["host_a"] == "clone-1"
688
+ assert voice["clone"]["preview_url"]
689
+
690
+
691
+ def test_tts_retries_rate_limits(monkeypatch):
692
+ from app.config import settings
693
+ from app.services import tts
694
+
695
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
696
+ monkeypatch.setattr(tts.time, "sleep", lambda s: None)
697
+ responses = [httpx.Response(429, json={"detail": "slow down"}), httpx.Response(200, content=b"mp3")]
698
+ monkeypatch.setattr(httpx, "request", lambda *a, **k: responses.pop(0))
699
+ assert tts._request("POST", "/text-to-speech/x").content == b"mp3"
700
+ monkeypatch.setattr(httpx, "request", lambda *a, **k: httpx.Response(401, json={"detail": {"message": "bad"}}))
701
+ with pytest.raises(tts.TTSError, match="API key"):
702
+ tts._request("GET", "/voices")
frontend/src/api/endpoints.ts CHANGED
@@ -8,10 +8,12 @@ import type {
8
  Doc,
9
  DocDetail,
10
  Job,
 
11
  Notebook,
12
  NotebookSummary,
13
  Page,
14
  Platform,
 
15
  ResurfaceItem,
16
  Settings,
17
  SocialAccounts,
@@ -20,6 +22,7 @@ import type {
20
  TopicMap,
21
  Usage,
22
  Voice,
 
23
  VoiceProfileData,
24
  VoiceState,
25
  } from "./types";
@@ -99,14 +102,31 @@ export const topicsApi = {
99
 
100
  export const voiceApi = {
101
  get: () => api<VoiceState>("/api/voice"),
102
- update: (body: { profile?: VoiceProfileData; host_voices?: { host_a: string; host_b: string } }) =>
103
- put<VoiceState>("/api/voice", body),
 
 
 
104
  build: (document_ids: string[]) => post<Job>("/api/voice/build", { document_ids }),
105
  voices: () => api<Voice[]>("/api/voice/voices"),
106
- consent: async (file: File, consent_text: string) => {
107
- const { upload_id } = await uploadFile(file);
108
- return post<VoiceState & { job: Job }>("/api/voice/consent", { upload_id, agreed: true, consent_text });
 
109
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  revoke: () => del<VoiceState>("/api/voice/consent"),
111
  };
112
 
 
8
  Doc,
9
  DocDetail,
10
  Job,
11
+ LibraryVoice,
12
  Notebook,
13
  NotebookSummary,
14
  Page,
15
  Platform,
16
+ Quota,
17
  ResurfaceItem,
18
  Settings,
19
  SocialAccounts,
 
22
  TopicMap,
23
  Usage,
24
  Voice,
25
+ Delivery,
26
  VoiceProfileData,
27
  VoiceState,
28
  } from "./types";
 
102
 
103
  export const voiceApi = {
104
  get: () => api<VoiceState>("/api/voice"),
105
+ update: (body: {
106
+ profile?: VoiceProfileData;
107
+ host_voices?: Partial<{ host_a: string; host_b: string }>;
108
+ delivery?: Partial<{ host_a: Delivery; host_b: Delivery }>;
109
+ }) => put<VoiceState>("/api/voice", body),
110
  build: (document_ids: string[]) => post<Job>("/api/voice/build", { document_ids }),
111
  voices: () => api<Voice[]>("/api/voice/voices"),
112
+ consent: async (files: File[], consent_text: string, remove_background_noise: boolean) => {
113
+ const upload_ids = [];
114
+ for (const f of files) upload_ids.push((await uploadFile(f)).upload_id);
115
+ return post<VoiceState & { job: Job }>("/api/voice/consent", { upload_ids, agreed: true, consent_text, remove_background_noise });
116
  },
117
+ library: (p: { search?: string; gender?: string; age?: string; accent?: string; language?: string; use_case?: string; page?: number }) =>
118
+ api<{ voices: LibraryVoice[]; has_more: boolean }>(`/api/voice/library${qs(p)}`),
119
+ addFromLibrary: (v: LibraryVoice, use_as?: "host_a" | "host_b") =>
120
+ post<VoiceState & { voice_id: string }>("/api/voice/library/add", {
121
+ public_owner_id: v.public_owner_id,
122
+ voice_id: v.voice_id,
123
+ name: v.name,
124
+ use_as,
125
+ }),
126
+ preview: (voice_id: string, opts: { text?: string; host?: "host_a" | "host_b"; delivery?: Delivery } = {}) =>
127
+ post<{ url: string; seconds: number }>("/api/voice/preview", { voice_id, ...opts }),
128
+ readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
129
+ quota: () => api<Quota>("/api/voice/quota"),
130
  revoke: () => del<VoiceState>("/api/voice/consent"),
131
  };
132
 
frontend/src/api/types.ts CHANGED
@@ -142,17 +142,58 @@ export type VoiceState = {
142
  sample_doc_ids: string[];
143
  updated_at: string | null;
144
  host_voices: { host_a: string; host_b: string };
 
 
 
145
  clone: {
146
  allowed: boolean;
147
  consent_text: string;
148
- status: "none" | "processing" | "ready";
149
  voice_id: string | null;
150
  created_at: string | null;
 
 
151
  };
152
  tts_configured: boolean;
153
  };
154
 
155
- export type Voice = { voice_id: string; name: string; category?: string | null; preview_url?: string | null };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
158
 
 
142
  sample_doc_ids: string[];
143
  updated_at: string | null;
144
  host_voices: { host_a: string; host_b: string };
145
+ delivery: { host_a: Delivery; host_b: Delivery };
146
+ model: string;
147
+ models: Record<string, string>;
148
  clone: {
149
  allowed: boolean;
150
  consent_text: string;
151
+ status: "none" | "processing" | "ready" | "failed";
152
  voice_id: string | null;
153
  created_at: string | null;
154
+ error: string | null;
155
+ preview_url: string | null;
156
  };
157
  tts_configured: boolean;
158
  };
159
 
160
+ export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
161
+
162
+ export type Voice = {
163
+ voice_id: string;
164
+ name: string;
165
+ category?: string | null;
166
+ preview_url?: string | null;
167
+ description?: string | null;
168
+ labels?: Record<string, string>;
169
+ };
170
+
171
+ export type LibraryVoice = {
172
+ public_owner_id: string;
173
+ voice_id: string;
174
+ name: string;
175
+ description: string | null;
176
+ preview_url: string | null;
177
+ gender: string | null;
178
+ age: string | null;
179
+ accent: string | null;
180
+ language: string | null;
181
+ use_case: string | null;
182
+ descriptive: string | null;
183
+ category: string | null;
184
+ cloned_by_count: number | null;
185
+ free_users_allowed: boolean;
186
+ notice_period: number | null;
187
+ };
188
+
189
+ export type Quota = {
190
+ tier?: string;
191
+ character_count?: number;
192
+ character_limit?: number;
193
+ can_use_instant_voice_cloning?: boolean;
194
+ voice_slots_used?: number;
195
+ voice_limit?: number;
196
+ };
197
 
198
  export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
199
 
frontend/src/components/ArtifactCard.tsx CHANGED
@@ -3,28 +3,9 @@ import { Link } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact, Citation, Segment } from "../api/types";
5
  import { useJob } from "../hooks/useJob";
 
6
  import { ConfirmButton, errorMessage, formatDate, formatDuration, JobProgress, StatusPill } from "./ui";
7
 
8
- /** Answer text with [n] markers turned into buttons that open the cited passage. */
9
- export function CitedText({ text, citations, onCite }: { text: string; citations: Citation[]; onCite?: (c: Citation) => void }) {
10
- const byMarker = new Map(citations.map((c) => [c.marker, c]));
11
- const parts = text.split(/(\[\d+\])/g);
12
- return (
13
- <>
14
- {parts.map((part, i) => {
15
- const m = part.match(/^\[(\d+)\]$/);
16
- const cite = m ? byMarker.get(Number(m[1])) : undefined;
17
- if (!cite) return <span key={i}>{part}</span>;
18
- return (
19
- <button key={i} type="button" className="cite-marker mono" title={cite.title} onClick={() => onCite?.(cite)}>
20
- {cite.marker}
21
- </button>
22
- );
23
- })}
24
- </>
25
- );
26
- }
27
-
28
  export function CitationList({ citations, onCite }: { citations: Citation[]; onCite?: (c: Citation) => void }) {
29
  if (!citations.length) return null;
30
  return (
@@ -80,13 +61,7 @@ function Body({ artifact, onCite }: { artifact: Artifact; onCite?: (c: Citation)
80
  ))}
81
  </div>
82
  ) : null}
83
- {String(c.summary ?? "")
84
- .split(/\n\n+/)
85
- .map((p, i) => (
86
- <p key={i}>
87
- <CitedText text={p} citations={c.citations ?? []} onCite={onCite} />
88
- </p>
89
- ))}
90
  <CitationList citations={c.citations ?? []} onCite={onCite} />
91
  </div>
92
  );
 
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact, Citation, Segment } from "../api/types";
5
  import { useJob } from "../hooks/useJob";
6
+ import { Markdown } from "./Markdown";
7
  import { ConfirmButton, errorMessage, formatDate, formatDuration, JobProgress, StatusPill } from "./ui";
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  export function CitationList({ citations, onCite }: { citations: Citation[]; onCite?: (c: Citation) => void }) {
10
  if (!citations.length) return null;
11
  return (
 
61
  ))}
62
  </div>
63
  ) : null}
64
+ <Markdown text={String(c.summary ?? "")} citations={c.citations ?? []} onCite={onCite} />
 
 
 
 
 
 
65
  <CitationList citations={c.citations ?? []} onCite={onCite} />
66
  </div>
67
  );
frontend/src/components/FeatureShowcase.tsx ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ /** Landing page feature tour: one full section per feature, alternating sides, each with a small
5
+ * product visual that animates when the section arrives (the parent adds .arrived via Landing's observer). */
6
+
7
+ type Feature = {
8
+ id: string;
9
+ eyebrow: string;
10
+ title: string;
11
+ body: string;
12
+ points: string[];
13
+ visual: ReactNode;
14
+ };
15
+
16
+ function ChatVisual() {
17
+ return (
18
+ <div className="fx fx-chat">
19
+ <div className="fx-bubble fx-q">What have I written about pricing?</div>
20
+ <div className="fx-steps mono">
21
+ <span>Searching for pric(e|ing)|paid tier</span>
22
+ <span>Reading On Pricing, lines 12 to 20</span>
23
+ </div>
24
+ <p className="fx-answer">
25
+ You raised prices twice and the <b>paid tier doubled</b>
26
+ <span className="fx-cite">1</span>, because readers took the work more seriously<span className="fx-cite">2</span>.
27
+ </p>
28
+ <div className="fx-source">
29
+ <span className="mono">[1] On Pricing · lines 12 to 14</span>
30
+ <span className="fx-line">The paid tier doubled after I raised the price to ten dollars.</span>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ function MapVisual() {
37
+ const nodes = [
38
+ { x: 50, y: 48, r: 14, label: "Pricing" },
39
+ { x: 22, y: 30, r: 9, label: "Habits" },
40
+ { x: 78, y: 26, r: 10, label: "Audience" },
41
+ { x: 30, y: 76, r: 8, label: "Craft" },
42
+ { x: 74, y: 74, r: 11, label: "Paid tier" },
43
+ ];
44
+ const edges = [
45
+ [0, 1],
46
+ [0, 2],
47
+ [0, 4],
48
+ [1, 3],
49
+ [2, 4],
50
+ ];
51
+ return (
52
+ <div className="fx fx-map">
53
+ <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
54
+ {edges.map(([a, b], i) => (
55
+ <line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={nodes[a].x} y1={nodes[a].y} x2={nodes[b].x} y2={nodes[b].y} />
56
+ ))}
57
+ {nodes.map((n, i) => (
58
+ <g key={n.label} className="fx-node" style={{ ["--d" as string]: `${0.3 + i * 0.12}s` }}>
59
+ <circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" />
60
+ <circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
61
+ <text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
62
+ {n.label}
63
+ </text>
64
+ </g>
65
+ ))}
66
+ </svg>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function AudioVisual() {
72
+ return (
73
+ <div className="fx fx-audio">
74
+ <div className="fx-wave" aria-hidden="true">
75
+ {Array.from({ length: 36 }).map((_, i) => (
76
+ <span key={i} style={{ ["--d" as string]: `${(i % 9) * 0.09}s`, ["--h" as string]: `${30 + ((i * 37) % 60)}%` }} />
77
+ ))}
78
+ </div>
79
+ <div className="fx-lines">
80
+ <p>
81
+ <span className="fx-host a">A</span>So the big idea is that price is a signal.
82
+ </p>
83
+ <p>
84
+ <span className="fx-host b">B</span>And she has the numbers: the paid tier doubled.
85
+ </p>
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ function VideoVisual() {
92
+ return (
93
+ <div className="fx fx-video">
94
+ <div className="fx-frame v916">
95
+ <span className="mono">9:16</span>
96
+ <b>I doubled my price.</b>
97
+ </div>
98
+ <div className="fx-frame v169">
99
+ <span className="mono">16:9</span>
100
+ <b>Why price is a signal</b>
101
+ </div>
102
+ <div className="fx-frame v11">
103
+ <span className="mono">1:1</span>
104
+ <i className="fx-mini-wave" />
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ function KitVisual() {
111
+ const cards = [
112
+ ["X thread", "I doubled my price. My paid tier doubled too."],
113
+ ["LinkedIn", "Most writers underprice. I did for two years."],
114
+ ["Notes", "Price is a signal. Readers told me so."],
115
+ ["Bluesky", "Raised my price to $10. Paid went up."],
116
+ ];
117
+ return (
118
+ <div className="fx fx-kit">
119
+ {cards.map(([label, body], i) => (
120
+ <div key={label} className="fx-card" style={{ ["--d" as string]: `${i * 0.12}s`, ["--r" as string]: `${(i - 1.5) * 4}deg` }}>
121
+ <span className="mono">{label}</span>
122
+ <p>{body}</p>
123
+ </div>
124
+ ))}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function VoiceVisual() {
130
+ const sliders = [
131
+ ["Stability", 62],
132
+ ["Similarity", 80],
133
+ ["Style", 28],
134
+ ] as const;
135
+ return (
136
+ <div className="fx fx-voice">
137
+ <div className="fx-profile">
138
+ <span className="mono">Your voice</span>
139
+ <p>Warm, direct, short paragraphs. Opens with a number. Never uses jargon.</p>
140
+ </div>
141
+ {sliders.map(([label, v], i) => (
142
+ <div key={label} className="fx-slider" style={{ ["--v" as string]: `${v}%`, ["--d" as string]: `${0.2 + i * 0.15}s` }}>
143
+ <span>{label}</span>
144
+ <i />
145
+ </div>
146
+ ))}
147
+ <div className="fx-clone mono">
148
+ <span className="fx-rec" /> Recording take 2 · 1:24
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function PadVisual() {
155
+ const lit = [2, 5, 9, 12, 16, 19];
156
+ return (
157
+ <div className="fx fx-pad">
158
+ <div className="fx-cal">
159
+ {Array.from({ length: 21 }).map((_, i) => (
160
+ <span key={i} className={lit.includes(i) ? "on" : ""} style={{ ["--d" as string]: `${lit.indexOf(i) * 0.12}s` }}>
161
+ {i + 1}
162
+ </span>
163
+ ))}
164
+ </div>
165
+ <div className="fx-launch mono">
166
+ <span>X</span>
167
+ <span>LinkedIn</span>
168
+ <span>Bluesky</span>
169
+ <span>Notes reminder</span>
170
+ </div>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ const FEATURES: Feature[] = [
176
+ {
177
+ id: "research",
178
+ eyebrow: "Research notebook",
179
+ title: "Ask your archive anything. It answers with receipts.",
180
+ body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
181
+ points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "Summaries of whole notebooks, cited"],
182
+ visual: <ChatVisual />,
183
+ },
184
+ {
185
+ id: "map",
186
+ eyebrow: "Topic map",
187
+ title: "See the constellations in your writing.",
188
+ body: "Every post is tagged with what it is really about, then linked to the topics it travels with. Zoom through years of work and spin any topic into a notebook.",
189
+ points: ["Topics merge automatically as you publish", "Spot the themes you keep coming back to", "One click from a topic to a notebook"],
190
+ visual: <MapVisual />,
191
+ },
192
+ {
193
+ id: "audio",
194
+ eyebrow: "Audio overview",
195
+ title: "Two hosts talk through your ideas.",
196
+ body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick from a library of commercial voices, or clone your own with consent.",
197
+ points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Your cloned voice as host, if you want"],
198
+ visual: <AudioVisual />,
199
+ },
200
+ {
201
+ id: "video",
202
+ eyebrow: "Video studio",
203
+ title: "Shorts, explainers and audiograms, from one post.",
204
+ body: "Notestack storyboards the post, narrates it and renders it with word by word captions and your brand colors. Vertical for Reels and TikTok, wide for YouTube, square for feeds.",
205
+ points: ["9:16 shorts with a hook in two seconds", "16:9 explainers and 1:1 audiograms", "Your logo and accent color on every frame"],
206
+ visual: <VideoVisual />,
207
+ },
208
+ {
209
+ id: "kit",
210
+ eyebrow: "Launch Kit",
211
+ title: "One post becomes a launch on every platform.",
212
+ body: "Hooks, an X thread, a LinkedIn post, Substack Notes, a Bluesky thread, an SEO pack, a carousel and quote cards, all written in your voice from claims the post actually makes.",
213
+ points: ["Edit anything, copy in one click", "Character counts per platform", "Quote cards rendered and ready"],
214
+ visual: <KitVisual />,
215
+ },
216
+ {
217
+ id: "voice",
218
+ eyebrow: "Voice profile",
219
+ title: "Sounds like you, on the page and out loud.",
220
+ body: "Notestack learns your tone from your own posts, so drafts read like you wrote them. For audio, tune two hosts or record a couple of minutes to clone your voice.",
221
+ points: ["Editable writing voice", "Commercially licensed voice library", "Consented cloning you can revoke anytime"],
222
+ visual: <VoiceVisual />,
223
+ },
224
+ {
225
+ id: "pad",
226
+ eyebrow: "Launchpad",
227
+ title: "Schedule the countdown. We handle liftoff.",
228
+ body: "Plan every post on one calendar. Notestack publishes to X, LinkedIn and Bluesky on time, emails you Substack Notes to paste, and tracks the clicks that follow.",
229
+ points: ["Auto posting with connected accounts", "Tracked links and engagement", "Resurface evergreen posts when they matter"],
230
+ visual: <PadVisual />,
231
+ },
232
+ ];
233
+
234
+ export default function FeatureShowcase() {
235
+ return (
236
+ <div className="showcase">
237
+ {FEATURES.map((f, i) => (
238
+ <section key={f.id} id={`feature-${f.id}`} className={`showcase-row arrive${i % 2 ? " flip" : ""}`}>
239
+ <div className="showcase-copy">
240
+ <p className="eyebrow">
241
+ <span className="mono showcase-n">{String(i + 1).padStart(2, "0")}</span> {f.eyebrow}
242
+ </p>
243
+ <h3>{f.title}</h3>
244
+ <p className="muted">{f.body}</p>
245
+ <ul>
246
+ {f.points.map((p) => (
247
+ <li key={p}>{p}</li>
248
+ ))}
249
+ </ul>
250
+ {i === FEATURES.length - 1 && (
251
+ <Link to="/auth?mode=signup" className="btn btn-primary">
252
+ Launch my archive
253
+ </Link>
254
+ )}
255
+ </div>
256
+ <div className="showcase-visual card">{f.visual}</div>
257
+ </section>
258
+ ))}
259
+ </div>
260
+ );
261
+ }
frontend/src/components/Markdown.tsx ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+ import type { Citation } from "../api/types";
3
+
4
+ /** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns
5
+ * [n] citation markers into buttons that open the cited lines.
6
+ * Supports: headings, paragraphs, bullet and numbered lists (one nested level), blockquotes, fenced
7
+ * code, tables, horizontal rules, bold, italic, strikethrough, inline code and links. */
8
+
9
+ type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void };
10
+
11
+ const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(~~[^~]+~~)|(\[[^\]]+\]\((https?:\/\/[^)\s]+)\))|(\[\d+\](?:\[\d+\])*)|(https?:\/\/[^\s)]+)/g;
12
+
13
+ function inline(text: string, cite: Cite, keyBase: string): ReactNode[] {
14
+ const out: ReactNode[] = [];
15
+ const byMarker = new Map((cite.citations ?? []).map((c) => [c.marker, c]));
16
+ let last = 0;
17
+ let i = 0;
18
+ for (const m of text.matchAll(INLINE)) {
19
+ const idx = m.index ?? 0;
20
+ if (idx > last) out.push(text.slice(last, idx));
21
+ const key = `${keyBase}-${i++}`;
22
+ const [whole, code, bold, italic, strike, link, href, markers, bare] = m;
23
+ if (code) out.push(<code key={key}>{code.slice(1, -1)}</code>);
24
+ else if (bold) out.push(<strong key={key}>{inline(bold.slice(2, -2), cite, key)}</strong>);
25
+ else if (italic) out.push(<em key={key}>{inline(italic.slice(1, -1), cite, key)}</em>);
26
+ else if (strike) out.push(<del key={key}>{inline(strike.slice(2, -2), cite, key)}</del>);
27
+ else if (link && href) {
28
+ const label = link.slice(1, link.indexOf("]("));
29
+ out.push(
30
+ <a key={key} href={href} target="_blank" rel="noreferrer">
31
+ {inline(label, cite, key)}
32
+ </a>,
33
+ );
34
+ } else if (markers) {
35
+ for (const n of markers.matchAll(/\[(\d+)\]/g)) {
36
+ const c = byMarker.get(Number(n[1]));
37
+ out.push(
38
+ c ? (
39
+ <button key={`${key}-${n[1]}`} type="button" className="cite-marker mono" title={c.title} onClick={() => cite.onCite?.(c)}>
40
+ {c.marker}
41
+ </button>
42
+ ) : (
43
+ <sup key={`${key}-${n[1]}`} className="mono muted">
44
+ {n[1]}
45
+ </sup>
46
+ ),
47
+ );
48
+ }
49
+ } else if (bare) {
50
+ out.push(
51
+ <a key={key} href={bare} target="_blank" rel="noreferrer">
52
+ {bare}
53
+ </a>,
54
+ );
55
+ } else out.push(whole);
56
+ last = idx + whole.length;
57
+ }
58
+ if (last < text.length) out.push(text.slice(last));
59
+ return out;
60
+ }
61
+
62
+ type ListItem = { text: string; children: ListItem[] };
63
+ type Block =
64
+ | { kind: "h"; level: number; text: string }
65
+ | { kind: "p"; text: string }
66
+ | { kind: "ul" | "ol"; items: ListItem[]; start: number }
67
+ | { kind: "quote"; text: string }
68
+ | { kind: "code"; text: string; lang: string }
69
+ | { kind: "table"; head: string[]; rows: string[][] }
70
+ | { kind: "hr" };
71
+
72
+ const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
73
+ const splitRow = (line: string) =>
74
+ line
75
+ .trim()
76
+ .replace(/^\|/, "")
77
+ .replace(/\|$/, "")
78
+ .split("|")
79
+ .map((c) => c.trim());
80
+
81
+ function parse(src: string): Block[] {
82
+ const lines = src.replace(/\r\n/g, "\n").split("\n");
83
+ const blocks: Block[] = [];
84
+ let i = 0;
85
+ while (i < lines.length) {
86
+ const line = lines[i];
87
+ const trimmed = line.trim();
88
+ if (!trimmed) {
89
+ i++;
90
+ continue;
91
+ }
92
+ const fence = trimmed.match(/^```(\w*)/);
93
+ if (fence) {
94
+ const body: string[] = [];
95
+ i++;
96
+ while (i < lines.length && !lines[i].trim().startsWith("```")) body.push(lines[i++]);
97
+ i++;
98
+ blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] });
99
+ continue;
100
+ }
101
+ const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
102
+ if (heading) {
103
+ blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
104
+ i++;
105
+ continue;
106
+ }
107
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
108
+ blocks.push({ kind: "hr" });
109
+ i++;
110
+ continue;
111
+ }
112
+ if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
113
+ const head = splitRow(trimmed);
114
+ i += 2;
115
+ const rows: string[][] = [];
116
+ while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++]));
117
+ blocks.push({ kind: "table", head, rows });
118
+ continue;
119
+ }
120
+ if (trimmed.startsWith(">")) {
121
+ const body: string[] = [];
122
+ while (i < lines.length && lines[i].trim().startsWith(">")) body.push(lines[i++].trim().replace(/^>\s?/, ""));
123
+ blocks.push({ kind: "quote", text: body.join("\n") });
124
+ continue;
125
+ }
126
+ const first = line.match(LIST);
127
+ if (first) {
128
+ const ordered = /\d/.test(first[2]);
129
+ const baseIndent = first[1].length;
130
+ const items: ListItem[] = [];
131
+ while (i < lines.length) {
132
+ const m = lines[i].match(LIST);
133
+ if (m) {
134
+ const item = { text: m[3], children: [] as ListItem[] };
135
+ if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item);
136
+ else items.push(item);
137
+ i++;
138
+ } else if (lines[i].trim() && /^\s{2,}/.test(lines[i]) && items.length) {
139
+ // Continuation line of the previous item.
140
+ const target = items[items.length - 1];
141
+ const deepest = target.children.length ? target.children[target.children.length - 1] : target;
142
+ deepest.text += ` ${lines[i].trim()}`;
143
+ i++;
144
+ } else if (!lines[i].trim() && i + 1 < lines.length && LIST.test(lines[i + 1])) {
145
+ i++; // loose list: blank line between items
146
+ } else break;
147
+ }
148
+ blocks.push({ kind: ordered ? "ol" : "ul", items, start: ordered ? parseInt(first[2], 10) || 1 : 1 });
149
+ continue;
150
+ }
151
+ const para: string[] = [];
152
+ while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,4}\s|>|```|\|)/.test(lines[i].trim())) {
153
+ para.push(lines[i++].trim());
154
+ }
155
+ if (para.length === 0) {
156
+ para.push(trimmed);
157
+ i++;
158
+ }
159
+ blocks.push({ kind: "p", text: para.join("\n") });
160
+ }
161
+ return blocks;
162
+ }
163
+
164
+ function withBreaks(text: string, cite: Cite, key: string): ReactNode[] {
165
+ return text.split("\n").flatMap((part, j, all) => [
166
+ ...inline(part, cite, `${key}-${j}`),
167
+ ...(j < all.length - 1 ? [<br key={`${key}-br-${j}`} />] : []),
168
+ ]);
169
+ }
170
+
171
+ function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode {
172
+ const children = items.map((it, j) => (
173
+ <li key={`${key}-${j}`}>
174
+ {inline(it.text, cite, `${key}-${j}`)}
175
+ {it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
176
+ </li>
177
+ ));
178
+ return ordered ? (
179
+ <ol key={key} start={start}>
180
+ {children}
181
+ </ol>
182
+ ) : (
183
+ <ul key={key}>{children}</ul>
184
+ );
185
+ }
186
+
187
+ export function Markdown({ text, citations, onCite, className = "" }: { text: string; className?: string } & Cite) {
188
+ const cite = { citations, onCite };
189
+ return (
190
+ <div className={`md ${className}`}>
191
+ {parse(text).map((b, i) => {
192
+ const key = `b${i}`;
193
+ switch (b.kind) {
194
+ case "h": {
195
+ const Tag = (["h3", "h3", "h4", "h5"] as const)[b.level - 1];
196
+ return <Tag key={key}>{inline(b.text, cite, key)}</Tag>;
197
+ }
198
+ case "p":
199
+ return <p key={key}>{withBreaks(b.text, cite, key)}</p>;
200
+ case "ul":
201
+ case "ol":
202
+ return renderItems(b.items, b.kind === "ol", cite, key, b.start);
203
+ case "quote":
204
+ return <blockquote key={key}>{withBreaks(b.text, cite, key)}</blockquote>;
205
+ case "code":
206
+ return (
207
+ <pre key={key} data-lang={b.lang || undefined}>
208
+ <code>{b.text}</code>
209
+ </pre>
210
+ );
211
+ case "table":
212
+ return (
213
+ <div key={key} className="md-table">
214
+ <table>
215
+ <thead>
216
+ <tr>
217
+ {b.head.map((h, j) => (
218
+ <th key={j}>{inline(h, cite, `${key}-h${j}`)}</th>
219
+ ))}
220
+ </tr>
221
+ </thead>
222
+ <tbody>
223
+ {b.rows.map((r, j) => (
224
+ <tr key={j}>
225
+ {r.map((c, k) => (
226
+ <td key={k}>{inline(c, cite, `${key}-${j}-${k}`)}</td>
227
+ ))}
228
+ </tr>
229
+ ))}
230
+ </tbody>
231
+ </table>
232
+ </div>
233
+ );
234
+ case "hr":
235
+ return <hr key={key} />;
236
+ default:
237
+ return null;
238
+ }
239
+ })}
240
+ </div>
241
+ );
242
+ }
frontend/src/components/SkyCanvas.tsx CHANGED
@@ -8,6 +8,13 @@ import { useEffect, useRef } from "react";
8
  type Star = { x: number; y: number; r: number; a: number; layer: number; phase: number; speed: number };
9
  type Sparkle = Star & { size: number; blue: boolean };
10
  type Meteor = { x: number; y: number; vx: number; vy: number; life: number };
 
 
 
 
 
 
 
11
 
12
  const LAYERS = [
13
  { density: 0.00018, parallax: 6, drift: 0.004 },
@@ -21,7 +28,7 @@ function rand(min: number, max: number) {
21
  return min + Math.random() * (max - min);
22
  }
23
 
24
- export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
25
  const ref = useRef<HTMLCanvasElement>(null);
26
 
27
  useEffect(() => {
@@ -40,6 +47,22 @@ export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
40
  const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
41
  let raf = 0;
42
  let scroll = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  const build = () => {
45
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -116,6 +139,72 @@ export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
116
  pointer.y += (pointer.ty - pointer.y) * 0.04;
117
  ctx.clearRect(0, 0, w, h);
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  for (const s of stars) {
120
  const layer = LAYERS[s.layer];
121
  const ox = reduced ? 0 : -pointer.x * layer.parallax;
@@ -198,7 +287,7 @@ export default function SkyCanvas({ intensity = 1 }: { intensity?: number }) {
198
  window.removeEventListener("scroll", onScroll);
199
  document.removeEventListener("visibilitychange", onVisibility);
200
  };
201
- }, [intensity]);
202
 
203
  return (
204
  <div className="sky" aria-hidden="true">
 
8
  type Star = { x: number; y: number; r: number; a: number; layer: number; phase: number; speed: number };
9
  type Sparkle = Star & { size: number; blue: boolean };
10
  type Meteor = { x: number; y: number; vx: number; vy: number; life: number };
11
+ type DeepStar = { x: number; y: number; z: number; blue: boolean };
12
+ type Nebula = { x: number; y: number; z: number; size: number; hue: number };
13
+
14
+ // Travel mode: scrolling the page flies the camera forward through a 3D field.
15
+ const DEEP_STARS = 300;
16
+ const NEBULAE = 5;
17
+ const TRAVEL_PER_PX = 0.00022; // depth units per scrolled pixel: a whole page is a gentle glide
18
 
19
  const LAYERS = [
20
  { density: 0.00018, parallax: 6, drift: 0.004 },
 
28
  return min + Math.random() * (max - min);
29
  }
30
 
31
+ export default function SkyCanvas({ intensity = 1, travel = false }: { intensity?: number; travel?: boolean }) {
32
  const ref = useRef<HTMLCanvasElement>(null);
33
 
34
  useEffect(() => {
 
47
  const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
48
  let raf = 0;
49
  let scroll = 0;
50
+ let camZ = 0;
51
+ let lastScrollY = window.scrollY;
52
+ let velocity = 0;
53
+ const deep: DeepStar[] = Array.from({ length: travel ? DEEP_STARS : 0 }, () => ({
54
+ x: rand(-1, 1),
55
+ y: rand(-1, 1),
56
+ z: Math.random(),
57
+ blue: Math.random() < 0.28,
58
+ }));
59
+ const nebulae: Nebula[] = Array.from({ length: travel ? NEBULAE : 0 }, (_, i) => ({
60
+ x: rand(-0.9, 0.9),
61
+ y: rand(-0.7, 0.7),
62
+ z: i / NEBULAE,
63
+ size: rand(0.5, 1.1),
64
+ hue: Math.random(),
65
+ }));
66
 
67
  const build = () => {
68
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
 
139
  pointer.y += (pointer.ty - pointer.y) * 0.04;
140
  ctx.clearRect(0, 0, w, h);
141
 
142
+ if (travel && !reduced) {
143
+ // Camera depth follows scroll position; velocity (smoothed) stretches stars into streaks.
144
+ const y = window.scrollY;
145
+ velocity += (y - lastScrollY - velocity) * 0.06;
146
+ lastScrollY = y;
147
+ const idle = t * 0.0035; // a slow cruise even when not scrolling
148
+ camZ = y * TRAVEL_PER_PX + idle;
149
+ const cx = w / 2 + pointer.x * -40;
150
+ const cy = h / 2 + pointer.y * -30;
151
+ const focal = Math.max(w, h) * 0.55;
152
+ const warp = Math.min(1, Math.max(0, Math.abs(velocity) - 6) / 60);
153
+
154
+ // Nebula clouds swell and slide past as you approach them.
155
+ for (const n of nebulae) {
156
+ const z = (((n.z - camZ * 0.25) % 1) + 1) % 1;
157
+ const depth = 0.08 + z * 1.6;
158
+ const px = cx + (n.x * focal) / depth;
159
+ const py = cy + (n.y * focal) / depth;
160
+ const r = (n.size * focal * 0.5) / depth;
161
+ const fade = Math.min(1, (1 - z) * 2) * Math.min(1, z * 6);
162
+ const alpha = 0.1 * fade;
163
+ if (alpha < 0.005 || r < 4) continue;
164
+ const g = ctx.createRadialGradient(px, py, 0, px, py, r);
165
+ const color = n.hue > 0.5 ? BLUE : "120, 170, 255";
166
+ g.addColorStop(0, `rgba(${color}, ${alpha})`);
167
+ g.addColorStop(0.5, `rgba(${BLUE}, ${alpha * 0.35})`);
168
+ g.addColorStop(1, `rgba(${BLUE}, 0)`);
169
+ ctx.fillStyle = g;
170
+ ctx.beginPath();
171
+ ctx.arc(px, py, r, 0, Math.PI * 2);
172
+ ctx.fill();
173
+ }
174
+
175
+ // Stars stream toward the camera.
176
+ for (const s of deep) {
177
+ const z = (((s.z - camZ) % 1) + 1) % 1; // 1 far, 0 at the lens
178
+ const depth = 0.03 + z;
179
+ const px = cx + (s.x * focal) / depth;
180
+ const py = cy + (s.y * focal) / depth;
181
+ if (px < -50 || px > w + 50 || py < -50 || py > h + 50) continue;
182
+ const near = 1 - z;
183
+ const alpha = Math.min(0.85, near * near * 1.3) * intensity;
184
+ const size = 0.3 + near * near * 1.6;
185
+ const color = s.blue ? BLUE : "255, 255, 255";
186
+ if (warp > 0.08) {
187
+ // Where the star was a moment ago, further away: the streak points back to the vanishing point.
188
+ const dz = Math.min(0.06, Math.abs(velocity) * TRAVEL_PER_PX * 3) * warp;
189
+ const back = 0.03 + Math.min(1, z + (velocity > 0 ? dz : -dz));
190
+ const bx = cx + (s.x * focal) / back;
191
+ const by = cy + (s.y * focal) / back;
192
+ ctx.strokeStyle = `rgba(${color}, ${alpha * 0.8})`;
193
+ ctx.lineWidth = size;
194
+ ctx.lineCap = "round";
195
+ ctx.beginPath();
196
+ ctx.moveTo(bx, by);
197
+ ctx.lineTo(px, py);
198
+ ctx.stroke();
199
+ } else {
200
+ ctx.fillStyle = `rgba(${color}, ${alpha})`;
201
+ ctx.beginPath();
202
+ ctx.arc(px, py, size, 0, Math.PI * 2);
203
+ ctx.fill();
204
+ }
205
+ }
206
+ }
207
+
208
  for (const s of stars) {
209
  const layer = LAYERS[s.layer];
210
  const ox = reduced ? 0 : -pointer.x * layer.parallax;
 
287
  window.removeEventListener("scroll", onScroll);
288
  document.removeEventListener("visibilitychange", onVisibility);
289
  };
290
+ }, [intensity, travel]);
291
 
292
  return (
293
  <div className="sky" aria-hidden="true">
frontend/src/lib/sound.ts CHANGED
@@ -1,9 +1,12 @@
1
- /** Soft UI click: a short, quiet blip synthesized with Web Audio, so there is no asset to load.
2
- * Plays on buttons and button-styled links. Viewers can turn it off in Settings (stored per browser). */
 
 
3
 
4
  const KEY = "ns_click_sounds";
5
 
6
  let ctx: AudioContext | null = null;
 
7
 
8
  export function soundsEnabled(): boolean {
9
  try {
@@ -21,6 +24,64 @@ export function setSoundsEnabled(on: boolean): void {
21
  }
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export function playClick(kind: "tap" | "primary" = "tap"): void {
25
  if (!soundsEnabled()) return;
26
  try {
@@ -28,20 +89,19 @@ export function playClick(kind: "tap" | "primary" = "tap"): void {
28
  if (!AC) return;
29
  ctx ??= new AC();
30
  if (ctx.state === "suspended") void ctx.resume();
31
- const now = ctx.currentTime;
32
- const osc = ctx.createOscillator();
33
- const gain = ctx.createGain();
34
- osc.type = "sine";
35
- // Primary actions get a small rising two-tone; everything else a single soft tick.
36
- const [from, to] = kind === "primary" ? [660, 990] : [520, 420];
37
- osc.frequency.setValueAtTime(from, now);
38
- osc.frequency.exponentialRampToValueAtTime(to, now + 0.07);
39
- gain.gain.setValueAtTime(0.0001, now);
40
- gain.gain.exponentialRampToValueAtTime(kind === "primary" ? 0.05 : 0.035, now + 0.008);
41
- gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.11);
42
- osc.connect(gain).connect(ctx.destination);
43
- osc.start(now);
44
- osc.stop(now + 0.12);
45
  } catch {
46
  /* audio is decoration only */
47
  }
 
1
+ /** Space-flavored UI sounds, synthesized with Web Audio (no assets to load):
2
+ * - tap: a soft sonar ping (sine with a shimmering detuned partner) that fades into a faint echo
3
+ * - primary: a short rising "launch" chirp with a breath of filtered noise, also echoing
4
+ * Everything is very quiet. Viewers can turn sounds off in Settings (stored per browser). */
5
 
6
  const KEY = "ns_click_sounds";
7
 
8
  let ctx: AudioContext | null = null;
9
+ let bus: GainNode | null = null;
10
 
11
  export function soundsEnabled(): boolean {
12
  try {
 
24
  }
25
  }
26
 
27
+ /** Master bus with a dark feedback echo, built once. */
28
+ function output(ac: AudioContext): GainNode {
29
+ if (bus) return bus;
30
+ bus = ac.createGain();
31
+ bus.gain.value = 0.9;
32
+ const delay = ac.createDelay(1);
33
+ delay.delayTime.value = 0.19;
34
+ const feedback = ac.createGain();
35
+ feedback.gain.value = 0.32;
36
+ const tone = ac.createBiquadFilter();
37
+ tone.type = "lowpass";
38
+ tone.frequency.value = 1800;
39
+ const wet = ac.createGain();
40
+ wet.gain.value = 0.35;
41
+ bus.connect(ac.destination);
42
+ bus.connect(delay);
43
+ delay.connect(tone).connect(feedback).connect(delay);
44
+ tone.connect(wet).connect(ac.destination);
45
+ return bus;
46
+ }
47
+
48
+ function voice(ac: AudioContext, out: AudioNode, type: OscillatorType, from: number, to: number, start: number,
49
+ dur: number, peak: number, detune = 0): void {
50
+ const osc = ac.createOscillator();
51
+ const gain = ac.createGain();
52
+ osc.type = type;
53
+ osc.detune.value = detune;
54
+ osc.frequency.setValueAtTime(from, start);
55
+ osc.frequency.exponentialRampToValueAtTime(to, start + dur * 0.8);
56
+ gain.gain.setValueAtTime(0.0001, start);
57
+ gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
58
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
59
+ osc.connect(gain).connect(out);
60
+ osc.start(start);
61
+ osc.stop(start + dur + 0.02);
62
+ }
63
+
64
+ function whoosh(ac: AudioContext, out: AudioNode, start: number, dur: number, peak: number): void {
65
+ const len = Math.floor(ac.sampleRate * dur);
66
+ const buffer = ac.createBuffer(1, len, ac.sampleRate);
67
+ const data = buffer.getChannelData(0);
68
+ for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
69
+ const src = ac.createBufferSource();
70
+ src.buffer = buffer;
71
+ const band = ac.createBiquadFilter();
72
+ band.type = "bandpass";
73
+ band.Q.value = 6;
74
+ band.frequency.setValueAtTime(600, start);
75
+ band.frequency.exponentialRampToValueAtTime(3200, start + dur);
76
+ const gain = ac.createGain();
77
+ gain.gain.setValueAtTime(0.0001, start);
78
+ gain.gain.exponentialRampToValueAtTime(peak, start + dur * 0.3);
79
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
80
+ src.connect(band).connect(gain).connect(out);
81
+ src.start(start);
82
+ src.stop(start + dur);
83
+ }
84
+
85
  export function playClick(kind: "tap" | "primary" = "tap"): void {
86
  if (!soundsEnabled()) return;
87
  try {
 
89
  if (!AC) return;
90
  ctx ??= new AC();
91
  if (ctx.state === "suspended") void ctx.resume();
92
+ const now = ctx.currentTime + 0.005;
93
+ const out = output(ctx);
94
+ if (kind === "primary") {
95
+ // Launch: two detuned voices sweep up an octave and a half, a thin noise trail rises with them.
96
+ voice(ctx, out, "sine", 330, 880, now, 0.28, 0.035);
97
+ voice(ctx, out, "triangle", 330, 880, now, 0.28, 0.012, 9);
98
+ voice(ctx, out, "sine", 1320, 1760, now + 0.09, 0.2, 0.01);
99
+ whoosh(ctx, out, now, 0.26, 0.012);
100
+ } else {
101
+ // Sonar ping: a clean tone with a slightly detuned shimmer, dropping a touch as it fades.
102
+ voice(ctx, out, "sine", 1046, 988, now, 0.22, 0.025);
103
+ voice(ctx, out, "sine", 1568, 1480, now, 0.16, 0.006, 7);
104
+ }
 
105
  } catch {
106
  /* audio is decoration only */
107
  }
frontend/src/pages/Landing.tsx CHANGED
@@ -1,7 +1,9 @@
1
- import { useState, type FormEvent } from "react";
2
  import { Link, useNavigate } from "react-router-dom";
3
  import SkyCanvas from "../components/SkyCanvas";
 
4
  import PricingTiers from "../components/PricingTiers";
 
5
  import { PublicFooter, PublicNav } from "../components/PublicChrome";
6
  import { blogPosts } from "../content/blogPosts";
7
  import { useAuth } from "../hooks/useAuth";
@@ -62,26 +64,105 @@ const STEPS = [
62
  { n: "03", title: "Launch", body: "Ask, listen, render and publish, all grounded in what you wrote." },
63
  ];
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  export default function Landing() {
66
  const [url, setUrl] = useState("");
67
  const navigate = useNavigate();
68
  const { user } = useAuth();
 
69
 
70
  const onSubmit = (e: FormEvent) => {
71
  e.preventDefault();
 
 
72
  const value = url.trim();
73
- if (!value) return;
74
- try {
75
- sessionStorage.setItem(PENDING_SOURCE_KEY, value);
76
- } catch {
77
- /* ignore */
 
78
  }
79
  navigate(user ? "/app" : "/auth?mode=signup");
80
  };
81
 
82
  return (
83
  <div className="page">
84
- <SkyCanvas />
85
  <PublicNav />
86
 
87
  <main>
@@ -91,7 +172,7 @@ export default function Landing() {
91
  <h1 className="hero-title">
92
  Your archive,
93
  <br />
94
- <span className="glow-text">in orbit.</span>
95
  </h1>
96
  <p className="hero-sub muted">
97
  Notestack turns your posts into a research notebook you can question, podcasts you can publish and
@@ -117,23 +198,18 @@ export default function Landing() {
117
  </form>
118
  <p className="mono hero-meta">Free during early access. 20+ posts indexed in under 3 minutes.</p>
119
  </div>
120
- <div className="horizon" aria-hidden="true" />
 
 
 
 
 
 
121
  </section>
122
 
123
  <section className="section demo">
124
  <div className="container">
125
- <div className="demo-frame card">
126
- <div className="demo-bar mono">
127
- <span>T-minus 00:42</span>
128
- <span className="muted">Rendering: vertical short</span>
129
- </div>
130
- <div className="demo-body">
131
- <div className="orbit-loader" aria-hidden="true">
132
- <span />
133
- </div>
134
- <p className="muted">Product demo video lands here.</p>
135
- </div>
136
- </div>
137
  </div>
138
  </section>
139
 
@@ -142,8 +218,8 @@ export default function Landing() {
142
  <p className="eyebrow">What you get</p>
143
  <h2 className="section-title">Everything your writing can become</h2>
144
  <div className="feature-grid">
145
- {FEATURES.map(({ icon: Icon, label, title, body }) => (
146
- <article key={label} className="card feature">
147
  <div className="feature-label">
148
  <Icon />
149
  <span className="mono">{label}</span>
@@ -156,13 +232,21 @@ export default function Landing() {
156
  </div>
157
  </section>
158
 
 
 
 
 
 
 
 
 
159
  <section className="section">
160
  <div className="container">
161
  <p className="eyebrow">How it works</p>
162
  <h2 className="section-title">Three steps to liftoff</h2>
163
  <ol className="steps">
164
- {STEPS.map((s) => (
165
- <li key={s.n} className="step">
166
  <span className="step-n mono">{s.n}</span>
167
  <h3>{s.title}</h3>
168
  <p className="muted">{s.body}</p>
@@ -185,8 +269,8 @@ export default function Landing() {
185
  <p className="eyebrow">From the blog</p>
186
  <h2 className="section-title">Field notes</h2>
187
  <div className="blog-grid">
188
- {blogPosts.slice(0, 3).map((p) => (
189
- <Link key={p.slug} to={`/blogs/${p.slug}`} className="card blog-card">
190
  <span className="mono muted">
191
  {p.category} · {p.readTime}
192
  </span>
@@ -198,7 +282,7 @@ export default function Landing() {
198
  </div>
199
  </section>
200
 
201
- <section className="section final-cta">
202
  <div className="container">
203
  <h2 className="section-title">Clear skies ahead.</h2>
204
  <p className="muted">Paste your URL above or start with an empty notebook.</p>
 
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
  import { Link, useNavigate } from "react-router-dom";
3
  import SkyCanvas from "../components/SkyCanvas";
4
+ import FeatureShowcase from "../components/FeatureShowcase";
5
  import PricingTiers from "../components/PricingTiers";
6
+ import "../styles/showcase.css";
7
  import { PublicFooter, PublicNav } from "../components/PublicChrome";
8
  import { blogPosts } from "../content/blogPosts";
9
  import { useAuth } from "../hooks/useAuth";
 
64
  { n: "03", title: "Launch", body: "Ask, listen, render and publish, all grounded in what you wrote." },
65
  ];
66
 
67
+ /** The narrated demo. Browsers only autoplay muted, so a button turns the captain's log on. */
68
+ function DemoVideo() {
69
+ const ref = useRef<HTMLVideoElement>(null);
70
+ const [muted, setMuted] = useState(true);
71
+ const toggle = () => {
72
+ const v = ref.current;
73
+ if (!v) return;
74
+ if (muted) {
75
+ v.currentTime = 0; // start the narration from the top
76
+ v.muted = false;
77
+ void v.play();
78
+ } else {
79
+ v.muted = true;
80
+ }
81
+ setMuted(!muted);
82
+ };
83
+ return (
84
+ <div className="demo-frame card arrive">
85
+ <div className="demo-bar mono">
86
+ <span>Mission log · 00:41</span>
87
+ <button type="button" className="demo-sound" onClick={toggle} aria-pressed={!muted}>
88
+ {muted ? "Play with sound" : "Mute"}
89
+ </button>
90
+ </div>
91
+ <div className="demo-stage">
92
+ <video
93
+ ref={ref}
94
+ className="demo-video"
95
+ src="/demo.mp4"
96
+ poster="/demo-poster.jpg"
97
+ autoPlay
98
+ muted
99
+ loop
100
+ playsInline
101
+ preload="metadata"
102
+ aria-label="Notestack product demo, narrated: paste your Substack, ask your archive, hear an audio overview and launch posts"
103
+ />
104
+ {muted && (
105
+ <button type="button" className="demo-unmute" onClick={toggle} aria-label="Play the demo with sound">
106
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
107
+ <path d="M4 9v6h4l5 4V5L8 9H4z" />
108
+ <path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12" />
109
+ </svg>
110
+ Hear the captain's log
111
+ </button>
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ /** Sections drift in out of the depth as they reach the viewport, like planets on approach. */
119
+ function useArrivals() {
120
+ useEffect(() => {
121
+ const els = Array.from(document.querySelectorAll<HTMLElement>(".arrive"));
122
+ if (!("IntersectionObserver" in window) || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
123
+ els.forEach((el) => el.classList.add("arrived"));
124
+ return;
125
+ }
126
+ const io = new IntersectionObserver(
127
+ (entries) => {
128
+ for (const e of entries) {
129
+ if (e.isIntersecting) {
130
+ e.target.classList.add("arrived");
131
+ io.unobserve(e.target);
132
+ }
133
+ }
134
+ },
135
+ { threshold: 0.35, rootMargin: "0px 0px -12% 0px" },
136
+ );
137
+ els.forEach((el) => io.observe(el));
138
+ return () => io.disconnect();
139
+ }, []);
140
+ }
141
+
142
  export default function Landing() {
143
  const [url, setUrl] = useState("");
144
  const navigate = useNavigate();
145
  const { user } = useAuth();
146
+ useArrivals();
147
 
148
  const onSubmit = (e: FormEvent) => {
149
  e.preventDefault();
150
+ // Always go to sign in / sign up. A pasted URL rides along and starts connecting after signup
151
+ // (Welcome picks it up); signed in writers go straight to Mission Control, which does the same.
152
  const value = url.trim();
153
+ if (value) {
154
+ try {
155
+ sessionStorage.setItem(PENDING_SOURCE_KEY, value);
156
+ } catch {
157
+ /* ignore */
158
+ }
159
  }
160
  navigate(user ? "/app" : "/auth?mode=signup");
161
  };
162
 
163
  return (
164
  <div className="page">
165
+ <SkyCanvas travel />
166
  <PublicNav />
167
 
168
  <main>
 
172
  <h1 className="hero-title">
173
  Your archive,
174
  <br />
175
+ <span className="glow-text shimmer">in orbit.</span>
176
  </h1>
177
  <p className="hero-sub muted">
178
  Notestack turns your posts into a research notebook you can question, podcasts you can publish and
 
198
  </form>
199
  <p className="mono hero-meta">Free during early access. 20+ posts indexed in under 3 minutes.</p>
200
  </div>
201
+ <div className="horizon" aria-hidden="true">
202
+ <span className="horizon-flare" />
203
+ <span className="horizon-glint g1" />
204
+ <span className="horizon-glint g2" />
205
+ <span className="horizon-glint g3" />
206
+ </div>
207
+ <div className="hero-sweep" aria-hidden="true" />
208
  </section>
209
 
210
  <section className="section demo">
211
  <div className="container">
212
+ <DemoVideo />
 
 
 
 
 
 
 
 
 
 
 
213
  </div>
214
  </section>
215
 
 
218
  <p className="eyebrow">What you get</p>
219
  <h2 className="section-title">Everything your writing can become</h2>
220
  <div className="feature-grid">
221
+ {FEATURES.map(({ icon: Icon, label, title, body }, i) => (
222
+ <article key={label} className="card feature arrive" style={{ ["--i" as string]: i % 3 }}>
223
  <div className="feature-label">
224
  <Icon />
225
  <span className="mono">{label}</span>
 
232
  </div>
233
  </section>
234
 
235
+ <section id="tour" className="section tour">
236
+ <div className="container">
237
+ <p className="eyebrow">The tour</p>
238
+ <h2 className="section-title">Seven stops across your archive</h2>
239
+ <FeatureShowcase />
240
+ </div>
241
+ </section>
242
+
243
  <section className="section">
244
  <div className="container">
245
  <p className="eyebrow">How it works</p>
246
  <h2 className="section-title">Three steps to liftoff</h2>
247
  <ol className="steps">
248
+ {STEPS.map((s, i) => (
249
+ <li key={s.n} className="step arrive" style={{ ["--i" as string]: i }}>
250
  <span className="step-n mono">{s.n}</span>
251
  <h3>{s.title}</h3>
252
  <p className="muted">{s.body}</p>
 
269
  <p className="eyebrow">From the blog</p>
270
  <h2 className="section-title">Field notes</h2>
271
  <div className="blog-grid">
272
+ {blogPosts.slice(0, 3).map((p, i) => (
273
+ <Link key={p.slug} to={`/blogs/${p.slug}`} className="card blog-card arrive" style={{ ["--i" as string]: i }}>
274
  <span className="mono muted">
275
  {p.category} · {p.readTime}
276
  </span>
 
282
  </div>
283
  </section>
284
 
285
+ <section className="section final-cta arrive">
286
  <div className="container">
287
  <h2 className="section-title">Clear skies ahead.</h2>
288
  <p className="muted">Paste your URL above or start with an empty notebook.</p>
frontend/src/pages/MissionControl.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { Link } from "react-router-dom";
3
  import { api } from "../api/client";
4
  import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi, voiceApi } from "../api/endpoints";
5
  import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types";
@@ -9,6 +9,15 @@ import { useAuth } from "../hooks/useAuth";
9
  import { useJobMap } from "../hooks/useJob";
10
  import { AddSource } from "./Sources";
11
  import { PENDING_SOURCE_KEY } from "./Landing";
 
 
 
 
 
 
 
 
 
12
 
13
  type Plan = { name: string };
14
 
@@ -39,6 +48,7 @@ export default function MissionControl() {
39
  const [hasVoice, setHasVoice] = useState(false);
40
  const [error, setError] = useState<string | null>(null);
41
  const started = useRef(false);
 
42
 
43
  const refresh = useCallback(async () => {
44
  const [s, n, p, a, c, v] = await Promise.all([
@@ -50,12 +60,17 @@ export default function MissionControl() {
50
  voiceApi.get(),
51
  ]);
52
  setSources(s);
 
 
 
 
 
53
  setNotebooks(n);
54
  setPlan(p.plan);
55
  setRecent(a.items);
56
  setUpcoming(c.slice(0, 5));
57
  setHasVoice(Boolean(v.profile));
58
- }, []);
59
 
60
  const { jobs, watch } = useJobMap(() => refresh());
61
 
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Link, useNavigate } from "react-router-dom";
3
  import { api } from "../api/client";
4
  import { artifactsApi, jobsApi, launchpadApi, notebooksApi, sourcesApi, voiceApi } from "../api/endpoints";
5
  import type { Artifact, CalendarItem, Job, NotebookSummary, Source } from "../api/types";
 
9
  import { useJobMap } from "../hooks/useJob";
10
  import { AddSource } from "./Sources";
11
  import { PENDING_SOURCE_KEY } from "./Landing";
12
+ import { ONBOARDED_KEY } from "./Welcome";
13
+
14
+ function onboarded(): boolean {
15
+ try {
16
+ return localStorage.getItem(ONBOARDED_KEY) === "1";
17
+ } catch {
18
+ return true;
19
+ }
20
+ }
21
 
22
  type Plan = { name: string };
23
 
 
48
  const [hasVoice, setHasVoice] = useState(false);
49
  const [error, setError] = useState<string | null>(null);
50
  const started = useRef(false);
51
+ const navigate = useNavigate();
52
 
53
  const refresh = useCallback(async () => {
54
  const [s, n, p, a, c, v] = await Promise.all([
 
60
  voiceApi.get(),
61
  ]);
62
  setSources(s);
63
+ // First visit with nothing connected: run the guided setup once.
64
+ if (s.length === 0 && !onboarded()) {
65
+ navigate("/welcome", { replace: true });
66
+ return;
67
+ }
68
  setNotebooks(n);
69
  setPlan(p.plan);
70
  setRecent(a.items);
71
  setUpcoming(c.slice(0, 5));
72
  setHasVoice(Boolean(v.profile));
73
+ }, [navigate]);
74
 
75
  const { jobs, watch } = useJobMap(() => refresh());
76
 
frontend/src/pages/NotebookView.tsx CHANGED
@@ -3,7 +3,8 @@ import { Link, useParams } from "react-router-dom";
3
  import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
  import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
6
- import { ArtifactCard, CitationList, CitedText } from "../components/ArtifactCard";
 
7
  import { DocPicker } from "../components/DocPicker";
8
  import { TelescopeIcon } from "../components/icons/Icons";
9
  import { Reader } from "../components/Reader";
@@ -295,11 +296,12 @@ export default function NotebookView() {
295
  </details>
296
  )}
297
  {t.status && !t.steps?.length && <p className="mono muted">{t.status}...</p>}
298
- {t.text && (
299
- <p className="turn-text">
300
- {t.role === "assistant" ? <CitedText text={t.text} citations={t.citations ?? []} onCite={cite} /> : t.text}
301
- </p>
302
- )}
 
303
  {t.citations && <CitationList citations={t.citations} onCite={cite} />}
304
  </div>
305
  ))}
 
3
  import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
  import type { Artifact, ChatSummary, Citation, Notebook } from "../api/types";
6
+ import { ArtifactCard, CitationList } from "../components/ArtifactCard";
7
+ import { Markdown } from "../components/Markdown";
8
  import { DocPicker } from "../components/DocPicker";
9
  import { TelescopeIcon } from "../components/icons/Icons";
10
  import { Reader } from "../components/Reader";
 
296
  </details>
297
  )}
298
  {t.status && !t.steps?.length && <p className="mono muted">{t.status}...</p>}
299
+ {t.text &&
300
+ (t.role === "assistant" ? (
301
+ <Markdown text={t.text} citations={t.citations ?? []} onCite={cite} className="turn-md" />
302
+ ) : (
303
+ <p className="turn-text">{t.text}</p>
304
+ ))}
305
  {t.citations && <CitationList citations={t.citations} onCite={cite} />}
306
  </div>
307
  ))}
frontend/src/pages/TopicMap.tsx CHANGED
@@ -1,15 +1,103 @@
1
- import { useEffect, useMemo, useState } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { notebooksApi, topicsApi } from "../api/endpoints";
4
  import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types";
5
  import { Reader } from "../components/Reader";
6
  import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
7
  import { useJob } from "../hooks/useJob";
 
8
 
9
  const W = 900;
10
  const H = 620;
11
 
12
  type P = { id: string; x: number; y: number; vx: number; vy: number; r: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  /** Small deterministic force layout: repulsion between all nodes, springs along edges, pull to center. */
15
  function layout(data: TopicMapData): Map<string, P> {
@@ -80,6 +168,8 @@ export default function TopicMap() {
80
  const [reading, setReading] = useState<string | null>(null);
81
  const [error, setError] = useState<string | null>(null);
82
  const navigate = useNavigate();
 
 
83
 
84
  const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map."));
85
  useEffect(() => {
@@ -113,7 +203,15 @@ export default function TopicMap() {
113
  }
114
  };
115
 
116
- const open = async (id: string) => setSelected(await topicsApi.get(id));
 
 
 
 
 
 
 
 
117
 
118
  return (
119
  <div className="page-wrap">
@@ -142,7 +240,39 @@ export default function TopicMap() {
142
  {data && data.nodes.length > 0 && (
143
  <div className="map-layout">
144
  <div className="card map-canvas">
145
- <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Topic constellation">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  <defs>
147
  <radialGradient id="star" cx="50%" cy="50%" r="50%">
148
  <stop offset="0%" stopColor="#ffffff" />
@@ -164,7 +294,7 @@ export default function TopicMap() {
164
  y2={b.y}
165
  stroke={lit ? "#217cff" : "#ffffff"}
166
  strokeOpacity={lit ? 0.9 : focus ? 0.05 : 0.14}
167
- strokeWidth={lit ? 1.6 : 1}
168
  />
169
  );
170
  })}
@@ -180,6 +310,7 @@ export default function TopicMap() {
180
  onMouseEnter={() => setHover(n.id)}
181
  onMouseLeave={() => setHover(null)}
182
  onClick={() => open(n.id)}
 
183
  tabIndex={0}
184
  role="button"
185
  aria-label={`${n.name}, ${n.post_count} posts`}
@@ -187,7 +318,7 @@ export default function TopicMap() {
187
  >
188
  <circle cx={p.x} cy={p.y} r={p.r * 1.9} fill="url(#star)" opacity={active ? 0.9 : 0.45} />
189
  <circle cx={p.x} cy={p.y} r={Math.max(3, p.r * 0.45)} fill="#ffffff" />
190
- <text x={p.x} y={p.y + p.r + 16} textAnchor="middle" className="map-label">
191
  {n.name}
192
  </text>
193
  </g>
@@ -203,7 +334,7 @@ export default function TopicMap() {
203
  <ol className="topic-rank">
204
  {data.nodes.slice(0, 12).map((n) => (
205
  <li key={n.id}>
206
- <button className="link-btn" onClick={() => open(n.id)}>
207
  {n.name}
208
  </button>
209
  <span className="mono muted"> {n.post_count}</span>
 
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { notebooksApi, topicsApi } from "../api/endpoints";
4
  import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types";
5
  import { Reader } from "../components/Reader";
6
  import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
7
  import { useJob } from "../hooks/useJob";
8
+ import { playClick } from "../lib/sound";
9
 
10
  const W = 900;
11
  const H = 620;
12
 
13
  type P = { id: string; x: number; y: number; vx: number; vy: number; r: number };
14
+ type View = { x: number; y: number; w: number; h: number };
15
+
16
+ const HOME: View = { x: 0, y: 0, w: W, h: H };
17
+ const MIN_W = W / 8;
18
+ const MAX_W = W * 1.6;
19
+
20
+ /** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */
21
+ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
22
+ const [view, setView] = useState<View>(HOME);
23
+ const drag = useRef<{ px: number; py: number; view: View; moved: boolean } | null>(null);
24
+
25
+ const toSvg = useCallback(
26
+ (clientX: number, clientY: number, v: View) => {
27
+ const rect = svgRef.current!.getBoundingClientRect();
28
+ return { x: v.x + ((clientX - rect.left) / rect.width) * v.w, y: v.y + ((clientY - rect.top) / rect.height) * v.h };
29
+ },
30
+ [svgRef],
31
+ );
32
+
33
+ const scaleView = (v: View, factor: number, fx: number, fy: number): View => {
34
+ const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor));
35
+ const h = (w / W) * H;
36
+ // Keep the focus point fixed on screen while scaling.
37
+ return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h };
38
+ };
39
+
40
+ const zoomAt = useCallback((factor: number, cx?: number, cy?: number) => {
41
+ setView((v) => scaleView(v, factor, cx ?? v.x + v.w / 2, cy ?? v.y + v.h / 2));
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ const el = svgRef.current;
46
+ if (!el) return;
47
+ const onWheel = (e: WheelEvent) => {
48
+ e.preventDefault();
49
+ setView((v) => {
50
+ const p = toSvg(e.clientX, e.clientY, v);
51
+ return scaleView(v, Math.exp(Math.max(-0.4, Math.min(0.4, e.deltaY * 0.0015))), p.x, p.y);
52
+ });
53
+ };
54
+ el.addEventListener("wheel", onWheel, { passive: false });
55
+ return () => el.removeEventListener("wheel", onWheel);
56
+ });
57
+
58
+ const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
59
+ if ((e.target as Element).closest(".map-node")) return;
60
+ drag.current = { px: e.clientX, py: e.clientY, view, moved: false };
61
+ svgRef.current?.setPointerCapture(e.pointerId);
62
+ };
63
+ const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {
64
+ const d = drag.current;
65
+ if (!d || !svgRef.current) return;
66
+ const rect = svgRef.current.getBoundingClientRect();
67
+ const dx = ((e.clientX - d.px) / rect.width) * d.view.w;
68
+ const dy = ((e.clientY - d.py) / rect.height) * d.view.h;
69
+ if (Math.abs(dx) + Math.abs(dy) > 2) d.moved = true;
70
+ setView({ ...d.view, x: d.view.x - dx, y: d.view.y - dy });
71
+ };
72
+ const onPointerUp = (e: ReactPointerEvent<SVGSVGElement>) => {
73
+ drag.current = null;
74
+ if (svgRef.current?.hasPointerCapture(e.pointerId)) svgRef.current.releasePointerCapture(e.pointerId);
75
+ };
76
+ const onDoubleClick = (e: ReactMouseEvent<SVGSVGElement>) => {
77
+ if ((e.target as Element).closest(".map-node")) return;
78
+ const p = toSvg(e.clientX, e.clientY, view);
79
+ zoomAt(0.6, p.x, p.y);
80
+ };
81
+ const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h }));
82
+ const reset = () => {
83
+ setView(HOME);
84
+ };
85
+ const focusOn = (x: number, y: number) =>
86
+ setView((v) => {
87
+ const w = Math.min(v.w, W / 2);
88
+ const h = (w / W) * H;
89
+ return { x: x - w / 2, y: y - h / 2, w, h };
90
+ });
91
+
92
+ return {
93
+ view,
94
+ zoomAt,
95
+ pan,
96
+ reset,
97
+ focusOn,
98
+ handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp, onDoubleClick },
99
+ };
100
+ }
101
 
102
  /** Small deterministic force layout: repulsion between all nodes, springs along edges, pull to center. */
103
  function layout(data: TopicMapData): Map<string, P> {
 
168
  const [reading, setReading] = useState<string | null>(null);
169
  const [error, setError] = useState<string | null>(null);
170
  const navigate = useNavigate();
171
+ const svgRef = useRef<SVGSVGElement>(null);
172
+ const { view, zoomAt, pan, reset, focusOn, handlers } = useZoomPan(svgRef);
173
 
174
  const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map."));
175
  useEffect(() => {
 
203
  }
204
  };
205
 
206
+ const open = async (id: string, center = false) => {
207
+ playClick("tap");
208
+ if (center) {
209
+ const p = positions.get(id);
210
+ if (p) focusOn(p.x, p.y);
211
+ }
212
+ setSelected(await topicsApi.get(id));
213
+ };
214
+ const scale = view.w / W; // keeps labels and strokes a constant size on screen
215
 
216
  return (
217
  <div className="page-wrap">
 
240
  {data && data.nodes.length > 0 && (
241
  <div className="map-layout">
242
  <div className="card map-canvas">
243
+ <div className="map-controls">
244
+ <button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in">
245
+ +
246
+ </button>
247
+ <button className="icon-btn" onClick={() => zoomAt(1.33)} aria-label="Zoom out" title="Zoom out">
248
+ -
249
+ </button>
250
+ <button className="icon-btn mono" onClick={reset} aria-label="Reset view" title="Reset view">
251
+ 1:1
252
+ </button>
253
+ </div>
254
+ <p className="map-hint mono muted">Scroll to zoom · drag to pan · double click to dive in</p>
255
+ <svg
256
+ ref={svgRef}
257
+ viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
258
+ role="img"
259
+ aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
260
+ tabIndex={0}
261
+ className="map-svg"
262
+ {...handlers}
263
+ onKeyDown={(e) => {
264
+ const step = 0.12;
265
+ if (e.key === "+" || e.key === "=") zoomAt(0.8);
266
+ else if (e.key === "-") zoomAt(1.25);
267
+ else if (e.key === "0") reset();
268
+ else if (e.key === "ArrowLeft") pan(-step, 0);
269
+ else if (e.key === "ArrowRight") pan(step, 0);
270
+ else if (e.key === "ArrowUp") pan(0, -step);
271
+ else if (e.key === "ArrowDown") pan(0, step);
272
+ else return;
273
+ e.preventDefault();
274
+ }}
275
+ >
276
  <defs>
277
  <radialGradient id="star" cx="50%" cy="50%" r="50%">
278
  <stop offset="0%" stopColor="#ffffff" />
 
294
  y2={b.y}
295
  stroke={lit ? "#217cff" : "#ffffff"}
296
  strokeOpacity={lit ? 0.9 : focus ? 0.05 : 0.14}
297
+ strokeWidth={(lit ? 1.6 : 1) * scale}
298
  />
299
  );
300
  })}
 
310
  onMouseEnter={() => setHover(n.id)}
311
  onMouseLeave={() => setHover(null)}
312
  onClick={() => open(n.id)}
313
+ onDoubleClick={() => open(n.id, true)}
314
  tabIndex={0}
315
  role="button"
316
  aria-label={`${n.name}, ${n.post_count} posts`}
 
318
  >
319
  <circle cx={p.x} cy={p.y} r={p.r * 1.9} fill="url(#star)" opacity={active ? 0.9 : 0.45} />
320
  <circle cx={p.x} cy={p.y} r={Math.max(3, p.r * 0.45)} fill="#ffffff" />
321
+ <text x={p.x} y={p.y + p.r + 16 * scale} textAnchor="middle" className="map-label" style={{ fontSize: 12 * scale, strokeWidth: 4 * scale }}>
322
  {n.name}
323
  </text>
324
  </g>
 
334
  <ol className="topic-rank">
335
  {data.nodes.slice(0, 12).map((n) => (
336
  <li key={n.id}>
337
+ <button className="link-btn" onClick={() => open(n.id, true)}>
338
  {n.name}
339
  </button>
340
  <span className="mono muted"> {n.post_count}</span>
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -1,10 +1,12 @@
1
- import { useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
- import type { Job, Voice, VoiceProfileData, VoiceState } from "../api/types";
4
  import { DocPicker } from "../components/DocPicker";
5
  import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
 
 
 
8
  const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
9
  { key: "tone", label: "Tone", hint: "warm, wry, direct" },
10
  { key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
@@ -13,24 +15,303 @@ const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[
13
  { key: "avoid", label: "Never does", hint: "jargon, hashtags" },
14
  ];
15
 
16
- function Recorder({ onFile }: { onFile: (f: File) => void }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  const [recording, setRecording] = useState(false);
18
  const [seconds, setSeconds] = useState(0);
 
19
  const [error, setError] = useState<string | null>(null);
20
  const rec = useRef<MediaRecorder | null>(null);
21
  const timer = useRef<number>();
 
22
 
23
  const start = async () => {
24
  setError(null);
25
  try {
26
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 
 
 
 
 
 
 
 
 
 
 
 
27
  const chunks: Blob[] = [];
28
  const mr = new MediaRecorder(stream);
 
29
  mr.ondataavailable = (e) => chunks.push(e.data);
30
  mr.onstop = () => {
31
  stream.getTracks().forEach((t) => t.stop());
 
 
32
  const type = mr.mimeType.split(";")[0] || "audio/webm";
33
- onFile(new File(chunks, `voice-sample.${type.includes("mp4") ? "m4a" : "webm"}`, { type }));
 
 
34
  };
35
  mr.start();
36
  rec.current = mr;
@@ -38,7 +319,7 @@ function Recorder({ onFile }: { onFile: (f: File) => void }) {
38
  setSeconds(0);
39
  timer.current = window.setInterval(() => setSeconds((s) => s + 1), 1000);
40
  } catch {
41
- setError("Microphone access was blocked. You can upload a recording instead.");
42
  }
43
  };
44
  const stop = () => {
@@ -48,42 +329,106 @@ function Recorder({ onFile }: { onFile: (f: File) => void }) {
48
  };
49
 
50
  return (
51
- <div className="row">
52
- {recording ? (
53
- <button type="button" className="btn btn-danger" onClick={stop}>
54
- Stop recording ({seconds}s)
55
- </button>
56
- ) : (
57
- <button type="button" className="btn" onClick={start}>
58
- Record a sample
59
- </button>
60
- )}
 
 
 
 
 
 
 
 
 
61
  {error && <p className="error-text">{error}</p>}
62
  </div>
63
  );
64
  }
65
 
66
  function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: () => void; onDone: (job: Job) => void }) {
67
- const [file, setFile] = useState<File | null>(null);
 
68
  const [agreed, setAgreed] = useState(false);
 
69
  const [busy, setBusy] = useState(false);
70
  const [error, setError] = useState<string | null>(null);
71
- const preview = file ? URL.createObjectURL(file) : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  return (
73
- <Modal title="Clone your voice" onClose={onClose}>
74
  <div className="stack">
75
- <p className="muted">
76
- Read one of your posts aloud for one to three minutes in a quiet room. The clone is used only for audio and video in this workspace.
77
- </p>
78
- <Recorder onFile={setFile} />
 
 
 
 
 
 
 
 
 
 
79
  <label className="field">
80
- <span>Or upload a recording</span>
81
- <input className="input file-input" type="file" accept="audio/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  </label>
83
- {preview && <audio controls src={preview} className="player" />}
84
  <blockquote className="consent">{state.clone.consent_text}</blockquote>
85
  <label className="check">
86
- <input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />I agree to the statement above.
87
  </label>
88
  {error && <p className="error-text">{error}</p>}
89
  <div className="row end">
@@ -92,12 +437,12 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
92
  </button>
93
  <button
94
  className="btn btn-primary"
95
- disabled={!file || !agreed || busy}
96
  onClick={async () => {
97
  setBusy(true);
98
  setError(null);
99
  try {
100
- const res = await voiceApi.consent(file!, state.clone.consent_text);
101
  onDone(res.job);
102
  onClose();
103
  } catch (e) {
@@ -119,13 +464,16 @@ export default function VoiceProfile() {
119
  const [state, setState] = useState<VoiceState | null>(null);
120
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
121
  const [voices, setVoices] = useState<Voice[]>([]);
 
122
  const [picking, setPicking] = useState(false);
123
  const [samples, setSamples] = useState<string[]>([]);
124
  const [buildJob, setBuildJob] = useState<Job | null>(null);
125
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
126
  const [cloning, setCloning] = useState(false);
 
127
  const [saved, setSaved] = useState(false);
128
  const [error, setError] = useState<string | null>(null);
 
129
 
130
  const load = () =>
131
  voiceApi.get().then((s) => {
@@ -133,26 +481,28 @@ export default function VoiceProfile() {
133
  setDraft(s.profile);
134
  setSamples(s.sample_doc_ids);
135
  });
 
136
 
137
  useEffect(() => {
138
  load();
139
- voiceApi.voices().then(setVoices, () => setVoices([]));
 
140
  }, []);
141
 
142
  const build = useJob(buildJob, () => load());
143
  const clone = useJob(cloneJob, () => {
144
  load();
145
- voiceApi.voices().then(setVoices);
146
  });
147
 
148
  if (!state) return <Loading />;
149
  const building = build && (build.status === "queued" || build.status === "running");
 
150
 
151
- const save = async (patch: Parameters<typeof voiceApi.update>[0]) => {
152
  setError(null);
153
  try {
154
- const s = await voiceApi.update(patch);
155
- setState(s);
156
  setSaved(true);
157
  setTimeout(() => setSaved(false), 1500);
158
  } catch (e) {
@@ -160,22 +510,23 @@ export default function VoiceProfile() {
160
  }
161
  };
162
 
 
 
 
163
  return (
164
  <div className="page-wrap">
165
- <PageHeader eyebrow="Voice profile" title="How you sound on the page">
166
  <button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}>
167
- {state.profile ? "Rebuild from posts" : "Build my voice profile"}
168
  </button>
169
  </PageHeader>
170
- <p className="muted lede">
171
- Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.
172
- </p>
173
  {build && (building || build.status === "failed") && <JobProgress job={build} />}
174
  {error && <p className="error-text">{error}</p>}
175
 
176
  <div className="two-col">
177
  <section className="card stack">
178
  <h2>Writing voice</h2>
 
179
  {!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>}
180
  {draft && (
181
  <>
@@ -203,8 +554,10 @@ export default function VoiceProfile() {
203
  </label>
204
  ))}
205
  <div className="row between">
206
- <span className="mono muted">Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts</span>
207
- <button className="btn btn-primary btn-small" onClick={() => save({ profile: draft })}>
 
 
208
  {saved ? "Saved" : "Save changes"}
209
  </button>
210
  </div>
@@ -212,62 +565,70 @@ export default function VoiceProfile() {
212
  )}
213
  </section>
214
 
215
- <section className="card stack">
216
- <h2>Speaking voices</h2>
217
- {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
218
- {(["host_a", "host_b"] as const).map((host) => (
219
- <label key={host} className="field">
220
- <span>{host === "host_a" ? "Host A (leads, narrates videos)" : "Host B"}</span>
221
- <div className="row">
222
- <select
223
- className="input input-sm"
224
- value={state.host_voices[host]}
225
- onChange={(e) => save({ host_voices: { ...state.host_voices, [host]: e.target.value } })}
226
- >
227
- {voices.map((v) => (
228
- <option key={v.voice_id} value={v.voice_id}>
229
- {v.name}
230
- {v.category ? ` (${v.category})` : ""}
231
- </option>
232
- ))}
233
- {!voices.some((v) => v.voice_id === state.host_voices[host]) && <option value={state.host_voices[host]}>Current voice</option>}
234
- </select>
235
- {voices.find((v) => v.voice_id === state.host_voices[host])?.preview_url && (
236
- <button type="button" className="btn btn-small" onClick={() => new Audio(voices.find((v) => v.voice_id === state.host_voices[host])!.preview_url!).play()}>
237
- Preview
238
- </button>
239
- )}
240
- </div>
241
- </label>
242
- ))}
243
 
244
- <h3>Your own voice</h3>
245
- {!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>}
246
- {state.clone.allowed && state.clone.status === "none" && (
247
- <>
248
- <p className="muted">Clone your voice with a short recording and your consent. You can revoke it anytime.</p>
249
- <button className="btn" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
250
- Clone my voice
251
- </button>
252
- </>
253
- )}
254
- {clone && (clone.status === "queued" || clone.status === "running" || clone.status === "failed") && <JobProgress job={clone} compact />}
255
- {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
256
- {state.clone.status === "ready" && (
257
- <>
258
- <p className="muted">Your cloned voice is ready. Pick "My voice" above to use it as a host.</p>
259
- <ConfirmButton
260
- confirmLabel="Revoke and delete my voice?"
261
- onConfirm={async () => {
262
- setState(await voiceApi.revoke());
263
- voiceApi.voices().then(setVoices);
264
- }}
265
- >
266
- Revoke consent
267
- </ConfirmButton>
268
- </>
269
- )}
270
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  </div>
272
 
273
  {picking && (
@@ -292,6 +653,16 @@ export default function VoiceProfile() {
292
  </Modal>
293
  )}
294
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
 
 
 
 
 
 
 
 
 
 
295
  </div>
296
  );
297
  }
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
+ import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, VoiceState } from "../api/types";
4
  import { DocPicker } from "../components/DocPicker";
5
  import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
 
8
+ type Host = "host_a" | "host_b";
9
+
10
  const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
11
  { key: "tone", label: "Tone", hint: "warm, wry, direct" },
12
  { key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
 
15
  { key: "avoid", label: "Never does", hint: "jargon, hashtags" },
16
  ];
17
 
18
+ const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; step: number; hint: string }[] = [
19
+ { key: "stability", label: "Stability", min: 0, max: 1, step: 0.05, hint: "Lower is more expressive, higher is steadier" },
20
+ { key: "similarity_boost", label: "Clarity and similarity", min: 0, max: 1, step: 0.05, hint: "How closely it sticks to the original voice" },
21
+ { key: "style", label: "Style", min: 0, max: 1, step: 0.05, hint: "Exaggerates the speaker's style; keep low for news" },
22
+ { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
23
+ ];
24
+
25
+ const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" };
26
+
27
+ /** Plays one URL at a time across the page. */
28
+ function usePlayer() {
29
+ const audio = useRef<HTMLAudioElement | null>(null);
30
+ const [playing, setPlaying] = useState<string | null>(null);
31
+ const play = useCallback((url: string, id: string) => {
32
+ audio.current?.pause();
33
+ if (playing === id) {
34
+ setPlaying(null);
35
+ return;
36
+ }
37
+ const a = new Audio(url);
38
+ audio.current = a;
39
+ a.onended = () => setPlaying(null);
40
+ void a.play();
41
+ setPlaying(id);
42
+ }, [playing]);
43
+ useEffect(() => () => audio.current?.pause(), []);
44
+ return { play, playing };
45
+ }
46
+
47
+ function fmtSeconds(s: number) {
48
+ return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
49
+ }
50
+
51
+ // Speaking voices
52
+
53
+ function HostCard({
54
+ host,
55
+ state,
56
+ voices,
57
+ onSaved,
58
+ onBrowse,
59
+ }: {
60
+ host: Host;
61
+ state: VoiceState;
62
+ voices: Voice[];
63
+ onSaved: (s: VoiceState) => void;
64
+ onBrowse: (host: Host) => void;
65
+ }) {
66
+ const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
67
+ const [busy, setBusy] = useState(false);
68
+ const [error, setError] = useState<string | null>(null);
69
+ const [dirty, setDirty] = useState(false);
70
+ const { play, playing } = usePlayer();
71
+ const current = state.host_voices[host];
72
+ const groups = voices.reduce<Record<string, Voice[]>>((acc, v) => {
73
+ const k = v.category ?? "other";
74
+ (acc[k] ??= []).push(v);
75
+ return acc;
76
+ }, {});
77
+
78
+ useEffect(() => {
79
+ setDelivery(state.delivery[host]);
80
+ setDirty(false);
81
+ }, [state.delivery, host]);
82
+
83
+ const preview = async () => {
84
+ setBusy(true);
85
+ setError(null);
86
+ try {
87
+ const { url } = await voiceApi.preview(current, { delivery });
88
+ play(url, "preview");
89
+ } catch (e) {
90
+ setError(errorMessage(e));
91
+ } finally {
92
+ setBusy(false);
93
+ }
94
+ };
95
+
96
+ return (
97
+ <div className="host-card">
98
+ <div className="row between">
99
+ <strong>{HOST_LABEL[host]}</strong>
100
+ <button className="link-btn small" onClick={() => onBrowse(host)}>
101
+ Browse voice library
102
+ </button>
103
+ </div>
104
+ <div className="row">
105
+ <select
106
+ className="input input-sm"
107
+ value={current}
108
+ onChange={async (e) => onSaved(await voiceApi.update({ host_voices: { [host]: e.target.value } }))}
109
+ aria-label={`${HOST_LABEL[host]} voice`}
110
+ >
111
+ {Object.entries(groups).map(([cat, list]) => (
112
+ <optgroup key={cat} label={cat}>
113
+ {list.map((v) => (
114
+ <option key={v.voice_id} value={v.voice_id}>
115
+ {v.name}
116
+ {v.labels?.accent ? `, ${v.labels.accent}` : ""}
117
+ {v.labels?.gender ? `, ${v.labels.gender}` : ""}
118
+ </option>
119
+ ))}
120
+ </optgroup>
121
+ ))}
122
+ {!voices.some((v) => v.voice_id === current) && <option value={current}>Current voice</option>}
123
+ </select>
124
+ <button className="btn btn-small" onClick={preview} disabled={busy || !state.tts_configured}>
125
+ {busy ? "Rendering..." : playing ? "Stop" : "Preview"}
126
+ </button>
127
+ </div>
128
+ <details className="delivery">
129
+ <summary className="mono muted small">Delivery settings</summary>
130
+ {SLIDERS.map((s) => (
131
+ <label key={s.key} className="slider" title={s.hint}>
132
+ <span className="row between">
133
+ <span>{s.label}</span>
134
+ <span className="mono muted">{Number(delivery[s.key]).toFixed(2)}</span>
135
+ </span>
136
+ <input
137
+ type="range"
138
+ min={s.min}
139
+ max={s.max}
140
+ step={s.step}
141
+ value={Number(delivery[s.key])}
142
+ onChange={(e) => {
143
+ setDelivery({ ...delivery, [s.key]: Number(e.target.value) });
144
+ setDirty(true);
145
+ }}
146
+ />
147
+ </label>
148
+ ))}
149
+ {dirty && (
150
+ <div className="row end">
151
+ <button className="btn btn-small" onClick={() => (setDelivery(state.delivery[host]), setDirty(false))}>
152
+ Reset
153
+ </button>
154
+ <button
155
+ className="btn btn-small btn-primary"
156
+ onClick={async () => {
157
+ onSaved(await voiceApi.update({ delivery: { [host]: delivery } }));
158
+ setDirty(false);
159
+ }}
160
+ >
161
+ Save delivery
162
+ </button>
163
+ </div>
164
+ )}
165
+ </details>
166
+ {error && <p className="error-text">{error}</p>}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ // Voice library (commercially licensed voices from ElevenLabs)
172
+
173
+ function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
174
+ const [filters, setFilters] = useState({ search: "", gender: "", age: "", accent: "", use_case: "" });
175
+ const [voices, setVoices] = useState<LibraryVoice[] | null>(null);
176
+ const [page, setPage] = useState(0);
177
+ const [hasMore, setHasMore] = useState(false);
178
+ const [adding, setAdding] = useState<string | null>(null);
179
+ const [error, setError] = useState<string | null>(null);
180
+ const { play, playing } = usePlayer();
181
+
182
+ const load = useCallback(
183
+ async (p = 0) => {
184
+ setError(null);
185
+ try {
186
+ const res = await voiceApi.library({ ...filters, page: p });
187
+ setVoices((cur) => (p ? [...(cur ?? []), ...res.voices] : res.voices));
188
+ setHasMore(res.has_more);
189
+ setPage(p);
190
+ } catch (e) {
191
+ setError(errorMessage(e));
192
+ setVoices([]);
193
+ }
194
+ },
195
+ [filters],
196
+ );
197
+
198
+ useEffect(() => {
199
+ const t = setTimeout(() => load(0), 250);
200
+ return () => clearTimeout(t);
201
+ }, [load]);
202
+
203
+ const select = (key: keyof typeof filters, options: [string, string][]) => (
204
+ <select className="input input-sm" value={filters[key]} onChange={(e) => setFilters({ ...filters, [key]: e.target.value })} aria-label={key}>
205
+ {options.map(([v, l]) => (
206
+ <option key={v} value={v}>
207
+ {l}
208
+ </option>
209
+ ))}
210
+ </select>
211
+ );
212
+
213
+ return (
214
+ <Modal title={`Voice library for ${host === "host_a" ? "Host A" : "Host B"}`} onClose={onClose} wide>
215
+ <div className="stack">
216
+ <p className="muted small">
217
+ Voices from the ElevenLabs Voice Library, licensed for commercial use on paid ElevenLabs plans. Adding one puts it in your ElevenLabs account.
218
+ </p>
219
+ <div className="library-filters">
220
+ <input className="input input-sm" placeholder="Search: calm narrator, british, podcast" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} aria-label="Search voices" />
221
+ {select("gender", [["", "Any gender"], ["female", "Female"], ["male", "Male"], ["neutral", "Neutral"]])}
222
+ {select("age", [["", "Any age"], ["young", "Young"], ["middle_aged", "Middle aged"], ["old", "Older"]])}
223
+ {select("accent", [["", "Any accent"], ["american", "American"], ["british", "British"], ["australian", "Australian"], ["irish", "Irish"], ["indian", "Indian"], ["african", "African"]])}
224
+ {select("use_case", [["", "Any use"], ["conversational", "Conversational"], ["narrative_story", "Narration"], ["informative_educational", "Educational"], ["social_media", "Social media"], ["entertainment_tv", "Entertainment"]])}
225
+ </div>
226
+ {error && <p className="error-text">{error}</p>}
227
+ {!voices && <Loading label="Searching voices" />}
228
+ <ul className="library-grid">
229
+ {voices?.map((v) => (
230
+ <li key={`${v.public_owner_id}-${v.voice_id}`} className="library-voice">
231
+ <div className="row between">
232
+ <strong>{v.name}</strong>
233
+ {v.preview_url && (
234
+ <button className="btn btn-small" onClick={() => play(v.preview_url!, v.voice_id)} aria-label={`Play ${v.name}`}>
235
+ {playing === v.voice_id ? "Stop" : "Play"}
236
+ </button>
237
+ )}
238
+ </div>
239
+ <p className="mono muted small">{[v.gender, v.age?.replace("_", " "), v.accent, v.use_case?.replace(/_/g, " ")].filter(Boolean).join(" · ")}</p>
240
+ {v.description && <p className="muted small clamp-3">{v.description}</p>}
241
+ {v.notice_period ? <p className="mono muted small">Owner notice period: {v.notice_period} days</p> : null}
242
+ <button
243
+ className="btn btn-small btn-primary"
244
+ disabled={adding !== null}
245
+ onClick={async () => {
246
+ setAdding(v.voice_id);
247
+ setError(null);
248
+ try {
249
+ onAdded(await voiceApi.addFromLibrary(v, host));
250
+ onClose();
251
+ } catch (e) {
252
+ setError(errorMessage(e));
253
+ } finally {
254
+ setAdding(null);
255
+ }
256
+ }}
257
+ >
258
+ {adding === v.voice_id ? "Adding..." : `Use as ${host === "host_a" ? "Host A" : "Host B"}`}
259
+ </button>
260
+ </li>
261
+ ))}
262
+ {voices?.length === 0 && !error && <li className="muted">No voices match those filters.</li>}
263
+ </ul>
264
+ {hasMore && (
265
+ <button className="btn" onClick={() => load(page + 1)}>
266
+ More voices
267
+ </button>
268
+ )}
269
+ </div>
270
+ </Modal>
271
+ );
272
+ }
273
+
274
+ // Cloning
275
+
276
+ type Take = { file: File; url: string; seconds: number };
277
+
278
+ function Recorder({ onTake }: { onTake: (t: Take) => void }) {
279
  const [recording, setRecording] = useState(false);
280
  const [seconds, setSeconds] = useState(0);
281
+ const [level, setLevel] = useState(0);
282
  const [error, setError] = useState<string | null>(null);
283
  const rec = useRef<MediaRecorder | null>(null);
284
  const timer = useRef<number>();
285
+ const raf = useRef<number>();
286
 
287
  const start = async () => {
288
  setError(null);
289
  try {
290
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: true } });
291
+ const ac = new AudioContext();
292
+ const analyser = ac.createAnalyser();
293
+ ac.createMediaStreamSource(stream).connect(analyser);
294
+ const buf = new Uint8Array(analyser.fftSize);
295
+ const meter = () => {
296
+ analyser.getByteTimeDomainData(buf);
297
+ let peak = 0;
298
+ for (const b of buf) peak = Math.max(peak, Math.abs(b - 128));
299
+ setLevel(peak / 128);
300
+ raf.current = requestAnimationFrame(meter);
301
+ };
302
+ meter();
303
  const chunks: Blob[] = [];
304
  const mr = new MediaRecorder(stream);
305
+ const started = Date.now();
306
  mr.ondataavailable = (e) => chunks.push(e.data);
307
  mr.onstop = () => {
308
  stream.getTracks().forEach((t) => t.stop());
309
+ cancelAnimationFrame(raf.current!);
310
+ void ac.close();
311
  const type = mr.mimeType.split(";")[0] || "audio/webm";
312
+ const file = new File(chunks, `take-${Date.now()}.${type.includes("mp4") ? "m4a" : "webm"}`, { type });
313
+ onTake({ file, url: URL.createObjectURL(file), seconds: (Date.now() - started) / 1000 });
314
+ setLevel(0);
315
  };
316
  mr.start();
317
  rec.current = mr;
 
319
  setSeconds(0);
320
  timer.current = window.setInterval(() => setSeconds((s) => s + 1), 1000);
321
  } catch {
322
+ setError("Microphone access was blocked. You can upload recordings instead.");
323
  }
324
  };
325
  const stop = () => {
 
329
  };
330
 
331
  return (
332
+ <div className="stack">
333
+ <div className="row">
334
+ {recording ? (
335
+ <button type="button" className="btn btn-danger armed" onClick={stop}>
336
+ Stop take ({fmtSeconds(seconds)})
337
+ </button>
338
+ ) : (
339
+ <button type="button" className="btn btn-primary" onClick={start}>
340
+ Record a take
341
+ </button>
342
+ )}
343
+ {recording && (
344
+ <div className="level" aria-label="Input level">
345
+ <div className="level-fill" style={{ width: `${Math.min(100, level * 160)}%` }} />
346
+ </div>
347
+ )}
348
+ </div>
349
+ {recording && level < 0.04 && seconds > 2 && <p className="muted small">We can barely hear you. Move closer to the microphone.</p>}
350
+ {level > 0.95 && <p className="muted small">Too loud: back off a little so it does not clip.</p>}
351
  {error && <p className="error-text">{error}</p>}
352
  </div>
353
  );
354
  }
355
 
356
  function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: () => void; onDone: (job: Job) => void }) {
357
+ const [script, setScript] = useState<{ title: string; text: string } | null>(null);
358
+ const [takes, setTakes] = useState<Take[]>([]);
359
  const [agreed, setAgreed] = useState(false);
360
+ const [denoise, setDenoise] = useState(true);
361
  const [busy, setBusy] = useState(false);
362
  const [error, setError] = useState<string | null>(null);
363
+ const total = takes.reduce((n, t) => n + t.seconds, 0);
364
+
365
+ useEffect(() => {
366
+ voiceApi.readingScript().then(setScript, () => setScript(null));
367
+ }, []);
368
+
369
+ const addFiles = (files: FileList | null) => {
370
+ for (const file of Array.from(files ?? [])) {
371
+ const url = URL.createObjectURL(file);
372
+ const probe = new Audio(url);
373
+ probe.onloadedmetadata = () =>
374
+ setTakes((t) => [...t, { file, url, seconds: Number.isFinite(probe.duration) ? probe.duration : 60 }]);
375
+ }
376
+ };
377
+
378
+ const quality = total < 30 ? "Too short: record at least 30 seconds" : total < 60 ? "Usable, but 1 to 3 minutes sounds much better" : total <= 300 ? "Great length" : "Plenty: more than 5 minutes adds little";
379
+
380
  return (
381
+ <Modal title="Clone your voice" onClose={onClose} wide>
382
  <div className="stack">
383
+ <ol className="clone-tips muted small">
384
+ <li>Quiet room, no music, phone on silent. Keep the same distance from the mic.</li>
385
+ <li>Read naturally, the way you would on a podcast. Pauses are fine.</li>
386
+ <li>Record in one or more takes: 1 to 3 minutes in total works best.</li>
387
+ </ol>
388
+ {script && (
389
+ <div className="reading-script">
390
+ <p className="eyebrow">Read this, from "{script.title}"</p>
391
+ {script.text.split(/\n\n+/).map((p, i) => (
392
+ <p key={i}>{p}</p>
393
+ ))}
394
+ </div>
395
+ )}
396
+ <Recorder onTake={(t) => setTakes((list) => [...list, t])} />
397
  <label className="field">
398
+ <span>Or upload recordings (mp3, m4a, wav, webm)</span>
399
+ <input className="input file-input" type="file" accept="audio/*" multiple onChange={(e) => addFiles(e.target.files)} />
400
+ </label>
401
+ {takes.length > 0 && (
402
+ <ul className="takes">
403
+ {takes.map((t, i) => (
404
+ <li key={t.url} className="row">
405
+ <span className="mono muted">Take {i + 1} · {fmtSeconds(t.seconds)}</span>
406
+ <audio controls src={t.url} className="player take-player" />
407
+ <button className="icon-btn" aria-label={`Remove take ${i + 1}`} onClick={() => setTakes(takes.filter((x) => x !== t))}>
408
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
409
+ <path d="M6 6l12 12M18 6L6 18" />
410
+ </svg>
411
+ </button>
412
+ </li>
413
+ ))}
414
+ </ul>
415
+ )}
416
+ <div className="meter">
417
+ <div className="row between">
418
+ <span>Total {fmtSeconds(total)}</span>
419
+ <span className="mono muted small">{takes.length ? quality : "No takes yet"}</span>
420
+ </div>
421
+ <div className="ascent-track">
422
+ <div className="ascent-fill" style={{ width: `${Math.min(100, (total / 180) * 100)}%` }} />
423
+ </div>
424
+ </div>
425
+ <label className="check">
426
+ <input type="checkbox" checked={denoise} onChange={(e) => setDenoise(e.target.checked)} />
427
+ Remove background noise (turn off for studio quality recordings)
428
  </label>
 
429
  <blockquote className="consent">{state.clone.consent_text}</blockquote>
430
  <label className="check">
431
+ <input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />I agree to the statement above, and this is my own voice.
432
  </label>
433
  {error && <p className="error-text">{error}</p>}
434
  <div className="row end">
 
437
  </button>
438
  <button
439
  className="btn btn-primary"
440
+ disabled={!takes.length || total < 10 || !agreed || busy}
441
  onClick={async () => {
442
  setBusy(true);
443
  setError(null);
444
  try {
445
+ const res = await voiceApi.consent(takes.map((t) => t.file), state.clone.consent_text, denoise);
446
  onDone(res.job);
447
  onClose();
448
  } catch (e) {
 
464
  const [state, setState] = useState<VoiceState | null>(null);
465
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
466
  const [voices, setVoices] = useState<Voice[]>([]);
467
+ const [quota, setQuota] = useState<Quota>({});
468
  const [picking, setPicking] = useState(false);
469
  const [samples, setSamples] = useState<string[]>([]);
470
  const [buildJob, setBuildJob] = useState<Job | null>(null);
471
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
472
  const [cloning, setCloning] = useState(false);
473
+ const [browsing, setBrowsing] = useState<Host | null>(null);
474
  const [saved, setSaved] = useState(false);
475
  const [error, setError] = useState<string | null>(null);
476
+ const { play, playing } = usePlayer();
477
 
478
  const load = () =>
479
  voiceApi.get().then((s) => {
 
481
  setDraft(s.profile);
482
  setSamples(s.sample_doc_ids);
483
  });
484
+ const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
485
 
486
  useEffect(() => {
487
  load();
488
+ loadVoices();
489
+ voiceApi.quota().then(setQuota, () => setQuota({}));
490
  }, []);
491
 
492
  const build = useJob(buildJob, () => load());
493
  const clone = useJob(cloneJob, () => {
494
  load();
495
+ loadVoices();
496
  });
497
 
498
  if (!state) return <Loading />;
499
  const building = build && (build.status === "queued" || build.status === "running");
500
+ const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
501
 
502
+ const saveProfile = async () => {
503
  setError(null);
504
  try {
505
+ setState(await voiceApi.update({ profile: draft! }));
 
506
  setSaved(true);
507
  setTimeout(() => setSaved(false), 1500);
508
  } catch (e) {
 
510
  }
511
  };
512
 
513
+ const used = quota.character_count ?? 0;
514
+ const limit = quota.character_limit ?? 0;
515
+
516
  return (
517
  <div className="page-wrap">
518
+ <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud">
519
  <button className="btn btn-primary" disabled={Boolean(building)} onClick={() => setPicking(true)}>
520
+ {state.profile ? "Rebuild writing voice" : "Build my writing voice"}
521
  </button>
522
  </PageHeader>
 
 
 
523
  {build && (building || build.status === "failed") && <JobProgress job={build} />}
524
  {error && <p className="error-text">{error}</p>}
525
 
526
  <div className="two-col">
527
  <section className="card stack">
528
  <h2>Writing voice</h2>
529
+ <p className="muted small">Launch Kits, carousels, hooks and scripts are written in this voice. Edit anything that does not sound like you.</p>
530
  {!draft && <p className="muted">No profile yet. Build one from 5 to 10 posts that feel most like you.</p>}
531
  {draft && (
532
  <>
 
554
  </label>
555
  ))}
556
  <div className="row between">
557
+ <span className="mono muted small">
558
+ Built {formatDate(state.updated_at, true)} from {state.sample_doc_ids.length} posts
559
+ </span>
560
+ <button className="btn btn-primary btn-small" onClick={saveProfile}>
561
  {saved ? "Saved" : "Save changes"}
562
  </button>
563
  </div>
 
565
  )}
566
  </section>
567
 
568
+ <div className="stack">
569
+ <section className="card stack">
570
+ <div className="row between">
571
+ <h2>Speaking voices</h2>
572
+ {limit > 0 && (
573
+ <span className="mono muted small" title="ElevenLabs characters this billing period">
574
+ {used.toLocaleString()} / {limit.toLocaleString()} chars
575
+ </span>
576
+ )}
577
+ </div>
578
+ {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
579
+ <p className="muted small">
580
+ Audio overviews use both hosts; videos are narrated by Host A. Model: {state.models[state.model] ?? state.model}.
581
+ </p>
582
+ {(["host_a", "host_b"] as Host[]).map((h) => (
583
+ <HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
584
+ ))}
585
+ </section>
 
 
 
 
 
 
 
 
 
 
586
 
587
+ <section className="card stack">
588
+ <h2>Your own voice</h2>
589
+ {!state.clone.allowed && <p className="muted">Voice cloning is on the Writer and Studio plans.</p>}
590
+ {quota.can_use_instant_voice_cloning === false && (
591
+ <p className="muted small">Your ElevenLabs plan does not include instant voice cloning. Upgrade it at elevenlabs.io.</p>
592
+ )}
593
+ {state.clone.allowed && (state.clone.status === "none" || state.clone.status === "failed") && !cloneRunning && (
594
+ <>
595
+ <p className="muted">
596
+ Record one to three minutes of your own writing, read aloud. Your voice becomes Host A, so audio overviews and videos sound like you.
597
+ </p>
598
+ {state.clone.status === "failed" && state.clone.error && <p className="error-text">{state.clone.error}</p>}
599
+ <button className="btn btn-primary" onClick={() => setCloning(true)} disabled={!state.tts_configured}>
600
+ Clone my voice
601
+ </button>
602
+ </>
603
+ )}
604
+ {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} />}
605
+ {state.clone.status === "processing" && !clone && <p className="muted">Your voice is being created.</p>}
606
+ {state.clone.status === "ready" && (
607
+ <>
608
+ <p className="muted">Your voice is live and set as Host A. Adjust its delivery above.</p>
609
+ <div className="row">
610
+ {state.clone.preview_url && (
611
+ <button className="btn btn-small" onClick={() => play(state.clone.preview_url!, "clone")}>
612
+ {playing === "clone" ? "Stop" : "Hear my voice"}
613
+ </button>
614
+ )}
615
+ <button className="btn btn-small" onClick={() => setCloning(true)}>
616
+ Record again
617
+ </button>
618
+ <ConfirmButton
619
+ confirmLabel="Revoke and delete my voice?"
620
+ onConfirm={async () => {
621
+ setState(await voiceApi.revoke());
622
+ loadVoices();
623
+ }}
624
+ >
625
+ Revoke consent
626
+ </ConfirmButton>
627
+ </div>
628
+ </>
629
+ )}
630
+ </section>
631
+ </div>
632
  </div>
633
 
634
  {picking && (
 
653
  </Modal>
654
  )}
655
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
656
+ {browsing && (
657
+ <LibraryModal
658
+ host={browsing}
659
+ onClose={() => setBrowsing(null)}
660
+ onAdded={(s) => {
661
+ setState(s);
662
+ loadVoices();
663
+ }}
664
+ />
665
+ )}
666
  </div>
667
  );
668
  }
frontend/src/styles/app.css CHANGED
@@ -1076,3 +1076,191 @@
1076
  display: grid;
1077
  gap: 2px;
1078
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
  display: grid;
1077
  gap: 2px;
1078
  }
1079
+
1080
+ /* ---------- Landing: flare, shimmer, arrivals, demo video ---------- */
1081
+
1082
+ /* A soft light rides along the horizon; small glints twinkle where it passes. */
1083
+ .horizon {
1084
+ overflow: visible;
1085
+ }
1086
+
1087
+ .horizon-flare {
1088
+ position: absolute;
1089
+ top: -3px;
1090
+ left: 50%;
1091
+ width: 16vw;
1092
+ height: 3px;
1093
+ margin-left: -8vw;
1094
+ border-radius: 999px;
1095
+ background: radial-gradient(50% 50% at 50% 50%, #ffffff 0%, var(--blue) 35%, transparent 70%);
1096
+ filter: blur(0.5px);
1097
+ box-shadow: 0 0 26px 4px var(--blue-60), 0 0 60px 8px var(--blue-20);
1098
+ transform-origin: 50% 75vw;
1099
+ animation: flare-ride 18s cubic-bezier(0.45, 0, 0.55, 1) infinite;
1100
+ opacity: 0;
1101
+ }
1102
+
1103
+ .horizon-flare::after {
1104
+ content: "";
1105
+ position: absolute;
1106
+ left: 50%;
1107
+ top: 50%;
1108
+ width: 56px;
1109
+ height: 56px;
1110
+ margin: -28px 0 0 -28px;
1111
+ border-radius: 50%;
1112
+ background: radial-gradient(circle, #ffffff 0%, rgba(255, 255, 255, 0.6) 10%, var(--blue-60) 26%, transparent 64%);
1113
+ }
1114
+
1115
+ @keyframes flare-ride {
1116
+ 0% {
1117
+ transform: rotate(-26deg);
1118
+ opacity: 0;
1119
+ }
1120
+ 15% {
1121
+ opacity: 0.9;
1122
+ }
1123
+ 50% {
1124
+ opacity: 1;
1125
+ }
1126
+ 85% {
1127
+ opacity: 0.8;
1128
+ }
1129
+ 100% {
1130
+ transform: rotate(26deg);
1131
+ opacity: 0;
1132
+ }
1133
+ }
1134
+
1135
+ .horizon-glint {
1136
+ position: absolute;
1137
+ top: -10px;
1138
+ width: 12px;
1139
+ height: 12px;
1140
+ margin-left: -6px;
1141
+ background:
1142
+ linear-gradient(90deg, transparent 45%, #ffffff 50%, transparent 55%),
1143
+ linear-gradient(0deg, transparent 45%, #ffffff 50%, transparent 55%);
1144
+ filter: drop-shadow(0 0 6px var(--blue)) drop-shadow(0 0 2px #ffffff);
1145
+ opacity: 0;
1146
+ animation: glint 18s ease-in-out infinite;
1147
+ }
1148
+
1149
+ .horizon-glint.g1 {
1150
+ left: 38%;
1151
+ animation-delay: 5.2s;
1152
+ }
1153
+
1154
+ .horizon-glint.g2 {
1155
+ left: 50%;
1156
+ top: -12px;
1157
+ animation-delay: 8.8s;
1158
+ }
1159
+
1160
+ .horizon-glint.g3 {
1161
+ left: 62%;
1162
+ animation-delay: 12.4s;
1163
+ }
1164
+
1165
+ @keyframes glint {
1166
+ 0%,
1167
+ 100% {
1168
+ opacity: 0;
1169
+ transform: scale(0.2) rotate(0deg);
1170
+ }
1171
+ 4% {
1172
+ opacity: 1;
1173
+ transform: scale(1.1) rotate(45deg);
1174
+ }
1175
+ 10% {
1176
+ opacity: 0;
1177
+ transform: scale(0.4) rotate(90deg);
1178
+ }
1179
+ }
1180
+
1181
+ /* A light sweep crosses the hero every few seconds, catching the headline. */
1182
+ .hero-sweep {
1183
+ position: absolute;
1184
+ inset: 0;
1185
+ z-index: 1;
1186
+ pointer-events: none;
1187
+ background: linear-gradient(105deg, transparent 42%, rgba(255, 255, 255, 0.035) 48%, var(--blue-05) 50%, transparent 55%);
1188
+ background-size: 250% 100%;
1189
+ animation: sweep 16s ease-in-out infinite;
1190
+ mix-blend-mode: screen;
1191
+ }
1192
+
1193
+ @keyframes sweep {
1194
+ 0% {
1195
+ background-position: 130% 0;
1196
+ }
1197
+ 45%,
1198
+ 100% {
1199
+ background-position: -40% 0;
1200
+ }
1201
+ }
1202
+
1203
+ .glow-text.shimmer {
1204
+ background: linear-gradient(100deg, #ffffff 35%, #cfe2ff 45%, #ffffff 50%, var(--blue) 55%, #ffffff 65%);
1205
+ background-size: 300% 100%;
1206
+ -webkit-background-clip: text;
1207
+ background-clip: text;
1208
+ -webkit-text-fill-color: transparent;
1209
+ animation: shimmer 16s ease-in-out infinite;
1210
+ filter: drop-shadow(0 0 18px var(--blue-35)) drop-shadow(0 0 40px var(--blue-20));
1211
+ }
1212
+
1213
+ @keyframes shimmer {
1214
+ 0% {
1215
+ background-position: 100% 0;
1216
+ }
1217
+ 45%,
1218
+ 100% {
1219
+ background-position: 0% 0;
1220
+ }
1221
+ }
1222
+
1223
+ /* Sections arrive out of the depth. */
1224
+ .arrive {
1225
+ opacity: 0;
1226
+ transform: perspective(900px) translateZ(-90px) translateY(28px);
1227
+ filter: blur(4px);
1228
+ transition:
1229
+ opacity 1.4s ease,
1230
+ transform 1.8s cubic-bezier(0.22, 1, 0.36, 1),
1231
+ filter 1.4s ease;
1232
+ transition-delay: calc(var(--i, 0) * 260ms);
1233
+ }
1234
+
1235
+ .arrive.arrived {
1236
+ opacity: 1;
1237
+ transform: none;
1238
+ filter: none;
1239
+ }
1240
+
1241
+ .demo-video {
1242
+ display: block;
1243
+ width: 100%;
1244
+ aspect-ratio: 16 / 9;
1245
+ background: var(--black);
1246
+ }
1247
+
1248
+ @media (prefers-reduced-motion: reduce) {
1249
+ .horizon-flare,
1250
+ .horizon-glint,
1251
+ .hero-sweep,
1252
+ .glow-text.shimmer {
1253
+ animation: none;
1254
+ }
1255
+
1256
+ .horizon-flare {
1257
+ opacity: 0.8;
1258
+ transform: none;
1259
+ }
1260
+
1261
+ .arrive {
1262
+ opacity: 1;
1263
+ transform: none;
1264
+ filter: none;
1265
+ }
1266
+ }
frontend/src/styles/pages.css CHANGED
@@ -1506,3 +1506,692 @@ select.input option {
1506
  animation: none;
1507
  }
1508
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1506
  animation: none;
1507
  }
1508
  }
1509
+
1510
+ /* Markdown in answers and summaries */
1511
+ .md {
1512
+ display: grid;
1513
+ gap: 0.85em;
1514
+ line-height: 1.7;
1515
+ font-size: 0.975rem;
1516
+ color: var(--text);
1517
+ overflow-wrap: anywhere;
1518
+ }
1519
+
1520
+ .md > * {
1521
+ margin: 0;
1522
+ }
1523
+
1524
+ .md h3,
1525
+ .md h4,
1526
+ .md h5 {
1527
+ font-family: var(--font-display);
1528
+ line-height: 1.3;
1529
+ margin-top: 0.4em;
1530
+ }
1531
+
1532
+ .md h3 {
1533
+ font-size: 1.15rem;
1534
+ }
1535
+
1536
+ .md h4 {
1537
+ font-size: 1.02rem;
1538
+ }
1539
+
1540
+ .md h5 {
1541
+ font-size: 0.95rem;
1542
+ color: var(--text-2);
1543
+ }
1544
+
1545
+ .md ul,
1546
+ .md ol {
1547
+ padding-left: 1.4em;
1548
+ display: grid;
1549
+ gap: 0.4em;
1550
+ }
1551
+
1552
+ .md li > ul,
1553
+ .md li > ol {
1554
+ margin-top: 0.4em;
1555
+ }
1556
+
1557
+ .md ul li::marker {
1558
+ color: var(--blue);
1559
+ }
1560
+
1561
+ .md ol li::marker {
1562
+ color: var(--blue);
1563
+ font-family: var(--font-mono);
1564
+ font-size: 0.85em;
1565
+ }
1566
+
1567
+ .md strong {
1568
+ color: var(--white);
1569
+ font-weight: 650;
1570
+ }
1571
+
1572
+ .md em {
1573
+ color: var(--text-2);
1574
+ }
1575
+
1576
+ .md a {
1577
+ color: var(--white);
1578
+ text-decoration-color: var(--blue-60);
1579
+ text-underline-offset: 3px;
1580
+ }
1581
+
1582
+ .md code {
1583
+ font-family: var(--font-mono);
1584
+ font-size: 0.85em;
1585
+ padding: 1px 6px;
1586
+ border-radius: 6px;
1587
+ background: var(--surface-2);
1588
+ border: 1px solid var(--line);
1589
+ }
1590
+
1591
+ .md pre {
1592
+ padding: 14px 16px;
1593
+ border-radius: var(--radius-sm);
1594
+ background: rgba(255, 255, 255, 0.04);
1595
+ border: 1px solid var(--line);
1596
+ overflow-x: auto;
1597
+ }
1598
+
1599
+ .md pre code {
1600
+ padding: 0;
1601
+ border: none;
1602
+ background: none;
1603
+ font-size: 0.8125rem;
1604
+ line-height: 1.6;
1605
+ }
1606
+
1607
+ .md blockquote {
1608
+ padding: 4px 0 4px 16px;
1609
+ border-left: 3px solid var(--blue);
1610
+ color: var(--text-2);
1611
+ }
1612
+
1613
+ .md hr {
1614
+ border: none;
1615
+ height: 1px;
1616
+ background: linear-gradient(90deg, transparent, var(--blue-35), transparent);
1617
+ }
1618
+
1619
+ .md-table {
1620
+ overflow-x: auto;
1621
+ }
1622
+
1623
+ .md table {
1624
+ width: 100%;
1625
+ border-collapse: collapse;
1626
+ font-size: 0.875rem;
1627
+ }
1628
+
1629
+ .md th,
1630
+ .md td {
1631
+ padding: 8px 10px;
1632
+ border-bottom: 1px solid var(--line);
1633
+ text-align: left;
1634
+ vertical-align: top;
1635
+ }
1636
+
1637
+ .md th {
1638
+ font-family: var(--font-mono);
1639
+ font-size: 0.75rem;
1640
+ letter-spacing: 0.06em;
1641
+ text-transform: uppercase;
1642
+ color: var(--blue);
1643
+ }
1644
+
1645
+ .turn-md {
1646
+ padding: 4px 2px;
1647
+ }
1648
+
1649
+ .turn-assistant + .turn-user {
1650
+ margin-top: 8px;
1651
+ }
1652
+
1653
+ /* Topic map zoom and pan */
1654
+ .map-canvas {
1655
+ position: relative;
1656
+ overflow: hidden;
1657
+ }
1658
+
1659
+ .map-svg {
1660
+ cursor: grab;
1661
+ touch-action: none;
1662
+ user-select: none;
1663
+ aspect-ratio: 900 / 620;
1664
+ }
1665
+
1666
+ .map-svg:active {
1667
+ cursor: grabbing;
1668
+ }
1669
+
1670
+ .map-svg:focus-visible {
1671
+ outline: 1px solid var(--blue-60);
1672
+ outline-offset: -1px;
1673
+ }
1674
+
1675
+ .map-controls {
1676
+ position: absolute;
1677
+ top: 12px;
1678
+ right: 12px;
1679
+ z-index: 1;
1680
+ display: grid;
1681
+ gap: 4px;
1682
+ padding: 4px;
1683
+ border: 1px solid var(--line);
1684
+ border-radius: 12px;
1685
+ background: rgba(0, 0, 0, 0.7);
1686
+ }
1687
+
1688
+ .map-controls .icon-btn {
1689
+ font: 600 1rem var(--font-mono);
1690
+ color: var(--white);
1691
+ }
1692
+
1693
+ .map-controls .icon-btn.mono {
1694
+ font-size: 0.65rem;
1695
+ }
1696
+
1697
+ .map-hint {
1698
+ position: absolute;
1699
+ left: 16px;
1700
+ bottom: 10px;
1701
+ z-index: 1;
1702
+ font-size: 0.7rem;
1703
+ pointer-events: none;
1704
+ }
1705
+
1706
+ /* Voice page */
1707
+ .host-card {
1708
+ display: grid;
1709
+ gap: 10px;
1710
+ padding: 14px;
1711
+ border: 1px solid var(--line);
1712
+ border-radius: var(--radius-sm);
1713
+ }
1714
+
1715
+ .host-card select {
1716
+ flex: 1;
1717
+ min-width: 0;
1718
+ }
1719
+
1720
+ .delivery summary {
1721
+ cursor: pointer;
1722
+ }
1723
+
1724
+ .delivery[open] {
1725
+ display: grid;
1726
+ gap: 10px;
1727
+ }
1728
+
1729
+ .slider {
1730
+ display: grid;
1731
+ gap: 4px;
1732
+ font-size: 0.85rem;
1733
+ }
1734
+
1735
+ .slider input[type="range"] {
1736
+ width: 100%;
1737
+ accent-color: var(--blue);
1738
+ }
1739
+
1740
+ .library-filters {
1741
+ display: grid;
1742
+ gap: 8px;
1743
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1744
+ }
1745
+
1746
+ .library-filters .input:first-child {
1747
+ grid-column: 1 / -1;
1748
+ }
1749
+
1750
+ .library-grid {
1751
+ list-style: none;
1752
+ margin: 0;
1753
+ padding: 0;
1754
+ display: grid;
1755
+ gap: 10px;
1756
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1757
+ max-height: 52vh;
1758
+ overflow: auto;
1759
+ }
1760
+
1761
+ .library-voice {
1762
+ display: grid;
1763
+ gap: 6px;
1764
+ align-content: start;
1765
+ padding: 12px;
1766
+ border: 1px solid var(--line);
1767
+ border-radius: var(--radius-sm);
1768
+ }
1769
+
1770
+ .library-voice:hover {
1771
+ border-color: var(--blue-35);
1772
+ }
1773
+
1774
+ .clone-tips {
1775
+ margin: 0;
1776
+ padding-left: 18px;
1777
+ display: grid;
1778
+ gap: 4px;
1779
+ }
1780
+
1781
+ .reading-script {
1782
+ max-height: 220px;
1783
+ overflow: auto;
1784
+ padding: 14px 16px;
1785
+ border: 1px solid var(--blue-35);
1786
+ border-radius: var(--radius-sm);
1787
+ background: var(--blue-05);
1788
+ font-family: var(--font-display);
1789
+ font-size: 1.05rem;
1790
+ line-height: 1.6;
1791
+ display: grid;
1792
+ gap: 10px;
1793
+ }
1794
+
1795
+ .reading-script p {
1796
+ margin: 0;
1797
+ }
1798
+
1799
+ .takes {
1800
+ list-style: none;
1801
+ margin: 0;
1802
+ padding: 0;
1803
+ display: grid;
1804
+ gap: 6px;
1805
+ }
1806
+
1807
+ .take-player {
1808
+ flex: 1;
1809
+ height: 32px;
1810
+ min-width: 160px;
1811
+ }
1812
+
1813
+ .level {
1814
+ flex: 1;
1815
+ height: 6px;
1816
+ border-radius: 999px;
1817
+ background: var(--surface-2);
1818
+ overflow: hidden;
1819
+ }
1820
+
1821
+ .level-fill {
1822
+ height: 100%;
1823
+ background: var(--blue);
1824
+ box-shadow: 0 0 10px var(--blue);
1825
+ transition: width 0.08s linear;
1826
+ }
1827
+
1828
+ /* ---------- Density pass: tighter spacing, pages fit the window instead of forcing a scroll ---------- */
1829
+ :root {
1830
+ --main-pad-top: 20px;
1831
+ --main-pad-bottom: 20px;
1832
+ }
1833
+
1834
+ .shell-main {
1835
+ padding: var(--main-pad-top) var(--gutter) var(--main-pad-bottom);
1836
+ }
1837
+
1838
+ @media (min-width: 900px) {
1839
+ .shell-main {
1840
+ padding-left: 28px;
1841
+ padding-right: 28px;
1842
+ }
1843
+
1844
+ /* Beats `.shell > :not(.sky) { position: relative }`, so the nav stays put while content scrolls. */
1845
+ .shell > .sidebar {
1846
+ position: sticky;
1847
+ top: 0;
1848
+ height: 100vh;
1849
+ overflow-y: auto;
1850
+ padding: 14px 12px;
1851
+ gap: 14px;
1852
+ }
1853
+
1854
+ .side-link {
1855
+ padding: 7px 10px;
1856
+ font-size: 0.9rem;
1857
+ }
1858
+ }
1859
+
1860
+ .card {
1861
+ padding: 18px;
1862
+ }
1863
+
1864
+ .page-wrap,
1865
+ .mc {
1866
+ gap: 14px;
1867
+ }
1868
+
1869
+ .page-head,
1870
+ .mc-head {
1871
+ margin-bottom: 0;
1872
+ align-items: center;
1873
+ }
1874
+
1875
+ .page-head h1,
1876
+ .mc-head h1 {
1877
+ font-size: clamp(1.35rem, 2.6vw, 1.75rem);
1878
+ margin-top: 4px;
1879
+ }
1880
+
1881
+ .page-head .eyebrow,
1882
+ .mc-head .eyebrow {
1883
+ font-size: 0.7rem;
1884
+ }
1885
+
1886
+ .lede {
1887
+ font-size: 0.9rem;
1888
+ margin-top: -6px;
1889
+ }
1890
+
1891
+ .stack {
1892
+ gap: 10px;
1893
+ }
1894
+
1895
+ .two-col,
1896
+ .grid-cards,
1897
+ .gallery,
1898
+ .mc-grid {
1899
+ gap: 14px;
1900
+ align-items: start; /* no stretched cards with dead space below short content */
1901
+ }
1902
+
1903
+ .mc-card {
1904
+ gap: 10px;
1905
+ }
1906
+
1907
+ .empty-state {
1908
+ padding: 32px 16px;
1909
+ gap: 10px;
1910
+ }
1911
+
1912
+ .empty-state.small {
1913
+ padding: 20px 12px;
1914
+ }
1915
+
1916
+ .empty-state .empty-art {
1917
+ width: 72px;
1918
+ height: 54px;
1919
+ }
1920
+
1921
+ .artifact {
1922
+ padding: 14px;
1923
+ gap: 10px;
1924
+ }
1925
+
1926
+ .video-short {
1927
+ max-height: 360px;
1928
+ }
1929
+
1930
+ .picker-list {
1931
+ max-height: 260px;
1932
+ }
1933
+
1934
+ .doc-row {
1935
+ padding: 8px 0;
1936
+ }
1937
+
1938
+ .source-row {
1939
+ padding: 10px 12px;
1940
+ }
1941
+
1942
+ .loading {
1943
+ padding: 12px 0;
1944
+ }
1945
+
1946
+ /* Notebook: exactly the window height, panes scroll inside. */
1947
+ @media (min-width: 1100px) {
1948
+ .nbv {
1949
+ height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
1950
+ min-height: 480px;
1951
+ }
1952
+ }
1953
+
1954
+ .nbv-chat {
1955
+ min-height: 360px;
1956
+ }
1957
+
1958
+ /* Topic map: canvas fills the space left in the window, never taller. */
1959
+ @media (min-width: 1100px) {
1960
+ .map-layout {
1961
+ height: calc(100vh - 150px);
1962
+ min-height: 420px;
1963
+ }
1964
+
1965
+ .map-canvas,
1966
+ .map-side {
1967
+ height: 100%;
1968
+ max-height: none;
1969
+ }
1970
+
1971
+ .map-canvas {
1972
+ display: grid;
1973
+ place-items: center;
1974
+ }
1975
+
1976
+ /* Keep the 900:620 aspect so pointer math matches the viewBox; fit inside the available box. */
1977
+ .map-svg {
1978
+ height: 100%;
1979
+ width: auto;
1980
+ max-width: 100%;
1981
+ aspect-ratio: 900 / 620;
1982
+ }
1983
+ }
1984
+
1985
+ @media (max-width: 1099px) {
1986
+ .map-svg {
1987
+ max-height: 62vh;
1988
+ }
1989
+ }
1990
+
1991
+ /* Launch Kit: the post picker and kit list share the side column without pushing the page. */
1992
+ @media (min-width: 1100px) {
1993
+ .kit-layout > aside {
1994
+ position: sticky;
1995
+ top: 0;
1996
+ max-height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
1997
+ overflow-y: auto;
1998
+ }
1999
+
2000
+ .kit-layout .picker-list {
2001
+ max-height: 220px;
2002
+ }
2003
+ }
2004
+
2005
+ /* Launchpad: a month fits on one screen. */
2006
+ .cal-day {
2007
+ min-height: 72px;
2008
+ padding: 4px 5px;
2009
+ gap: 3px;
2010
+ }
2011
+
2012
+ .cal-head {
2013
+ margin-bottom: 8px;
2014
+ }
2015
+
2016
+ .accounts {
2017
+ gap: 8px;
2018
+ }
2019
+
2020
+ /* Modals: compact padding, body scrolls instead of the page. */
2021
+ .modal {
2022
+ gap: 12px;
2023
+ padding: 18px;
2024
+ }
2025
+
2026
+ /* ---------- Containment: text wraps inside its box instead of spilling past borders ---------- */
2027
+ .shell-main,
2028
+ .page-wrap > *,
2029
+ .mc > *,
2030
+ .two-col > *,
2031
+ .grid-cards > *,
2032
+ .gallery > *,
2033
+ .mc-grid > *,
2034
+ .kit-layout > *,
2035
+ .map-layout > *,
2036
+ .nbv > *,
2037
+ .stack > *,
2038
+ .row > *,
2039
+ .field,
2040
+ .artifact-head > div,
2041
+ .source-title,
2042
+ .nb-card,
2043
+ .library-voice,
2044
+ .host-card,
2045
+ .post-edit,
2046
+ .slide-edit,
2047
+ .resurface-row,
2048
+ .recent-list li,
2049
+ .recent-list a {
2050
+ min-width: 0;
2051
+ }
2052
+
2053
+ .card,
2054
+ .modal,
2055
+ .drawer,
2056
+ .md,
2057
+ .artifact-body,
2058
+ .cites,
2059
+ .transcript,
2060
+ .doc-title,
2061
+ .picker-title,
2062
+ .source-title,
2063
+ .nbv-doc,
2064
+ .kit-list,
2065
+ .topic-rank,
2066
+ .hooks,
2067
+ .claims,
2068
+ .seo dd,
2069
+ .account,
2070
+ .angle,
2071
+ .cite-span,
2072
+ .error-text,
2073
+ .notice {
2074
+ overflow-wrap: anywhere;
2075
+ word-break: normal;
2076
+ }
2077
+
2078
+ /* Small buttons may wrap to two lines rather than punch out of narrow panes. */
2079
+ .btn-small {
2080
+ white-space: normal;
2081
+ height: auto;
2082
+ min-height: 34px;
2083
+ padding: 6px 14px;
2084
+ line-height: 1.25;
2085
+ text-align: center;
2086
+ }
2087
+
2088
+ .btn {
2089
+ max-width: 100%;
2090
+ }
2091
+
2092
+ select.input,
2093
+ .input,
2094
+ .textarea {
2095
+ max-width: 100%;
2096
+ min-width: 0;
2097
+ text-overflow: ellipsis;
2098
+ }
2099
+
2100
+ .row > select.input {
2101
+ flex: 1 1 160px;
2102
+ }
2103
+
2104
+ .chip {
2105
+ max-width: 100%;
2106
+ overflow: hidden;
2107
+ text-overflow: ellipsis;
2108
+ white-space: nowrap;
2109
+ }
2110
+
2111
+ .small-link,
2112
+ .source-card a.mono {
2113
+ display: block;
2114
+ overflow: hidden;
2115
+ text-overflow: ellipsis;
2116
+ white-space: nowrap;
2117
+ }
2118
+
2119
+ .account {
2120
+ max-width: 100%;
2121
+ }
2122
+
2123
+ .account .mono {
2124
+ overflow: hidden;
2125
+ text-overflow: ellipsis;
2126
+ white-space: nowrap;
2127
+ max-width: 220px;
2128
+ display: inline-block;
2129
+ vertical-align: bottom;
2130
+ }
2131
+
2132
+ .pill {
2133
+ flex-shrink: 0;
2134
+ }
2135
+
2136
+ .artifact-head h3,
2137
+ .nb-card h3,
2138
+ .kit-view h2 {
2139
+ overflow-wrap: anywhere;
2140
+ }
2141
+
2142
+ .metrics,
2143
+ .picker-actions,
2144
+ .artifact-actions {
2145
+ flex-wrap: wrap;
2146
+ }
2147
+
2148
+ .map-side h2,
2149
+ .map-side .doc-title {
2150
+ overflow-wrap: anywhere;
2151
+ }
2152
+
2153
+ .sidebar-user .mono {
2154
+ overflow: hidden;
2155
+ text-overflow: ellipsis;
2156
+ white-space: nowrap;
2157
+ }
2158
+
2159
+ .reader-line span:last-child {
2160
+ min-width: 0;
2161
+ overflow-wrap: anywhere;
2162
+ }
2163
+
2164
+ /* Content uses the full width next to the sidebar (open or collapsed); centred only on very wide screens. */
2165
+ .page-wrap,
2166
+ .mc,
2167
+ .nbv {
2168
+ width: 100%;
2169
+ max-width: 1760px;
2170
+ margin-inline: auto;
2171
+ }
2172
+
2173
+ .shell-main {
2174
+ width: 100%;
2175
+ }
2176
+
2177
+ @media (min-width: 900px) {
2178
+ .shell.collapsed .shell-main {
2179
+ padding-left: 24px;
2180
+ padding-right: 24px;
2181
+ }
2182
+ }
2183
+
2184
+ @media (min-width: 1100px) {
2185
+ /* Wider screens give the side columns room instead of leaving it empty. */
2186
+ .nbv {
2187
+ grid-template-columns: minmax(240px, 300px) minmax(0, 1fr) minmax(320px, 400px);
2188
+ }
2189
+
2190
+ .kit-layout {
2191
+ grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
2192
+ }
2193
+
2194
+ .map-layout {
2195
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
2196
+ }
2197
+ }
frontend/src/styles/showcase.css ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ---------- Landing: feature tour ---------- */
2
+ .showcase {
3
+ display: grid;
4
+ gap: clamp(64px, 12vh, 140px);
5
+ margin-top: 24px;
6
+ }
7
+
8
+ .showcase-row {
9
+ display: grid;
10
+ gap: 28px;
11
+ align-items: center;
12
+ }
13
+
14
+ @media (min-width: 900px) {
15
+ .showcase-row {
16
+ grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
17
+ gap: 56px;
18
+ }
19
+
20
+ .showcase-row.flip .showcase-copy {
21
+ order: 2;
22
+ }
23
+ }
24
+
25
+ .showcase-copy {
26
+ display: grid;
27
+ gap: 14px;
28
+ align-content: start;
29
+ justify-items: start;
30
+ }
31
+
32
+ .showcase-copy h3 {
33
+ font-size: clamp(1.5rem, 3vw, 2.1rem);
34
+ line-height: 1.12;
35
+ }
36
+
37
+ .showcase-copy ul {
38
+ margin: 4px 0 0;
39
+ padding: 0;
40
+ list-style: none;
41
+ display: grid;
42
+ gap: 8px;
43
+ }
44
+
45
+ .showcase-copy li {
46
+ position: relative;
47
+ padding-left: 22px;
48
+ color: var(--text-2);
49
+ }
50
+
51
+ .showcase-copy li::before {
52
+ content: "";
53
+ position: absolute;
54
+ left: 2px;
55
+ top: 0.55em;
56
+ width: 8px;
57
+ height: 8px;
58
+ border-radius: 50%;
59
+ background: var(--blue);
60
+ box-shadow: 0 0 10px var(--blue);
61
+ }
62
+
63
+ .showcase-n {
64
+ color: var(--white);
65
+ opacity: 0.6;
66
+ margin-right: 6px;
67
+ }
68
+
69
+ .showcase-visual {
70
+ padding: 0;
71
+ overflow: hidden;
72
+ border-color: var(--blue-20);
73
+ box-shadow: var(--glow-lg);
74
+ aspect-ratio: 16 / 11;
75
+ background: radial-gradient(70% 60% at 50% 40%, var(--blue-10), transparent 70%), rgba(0, 0, 0, 0.6);
76
+ }
77
+
78
+ /* Visual primitives. Everything starts hidden and plays once the row is .arrived. */
79
+ .fx {
80
+ height: 100%;
81
+ padding: clamp(16px, 3vw, 28px);
82
+ display: grid;
83
+ gap: 12px;
84
+ align-content: center;
85
+ font-size: 0.9rem;
86
+ }
87
+
88
+ .fx .mono {
89
+ font-size: 0.72rem;
90
+ color: var(--blue);
91
+ }
92
+
93
+ .fx > *,
94
+ .fx-card,
95
+ .fx-node,
96
+ .fx-edge,
97
+ .fx-cal span {
98
+ transition:
99
+ opacity 0.8s ease,
100
+ transform 0.9s cubic-bezier(0.22, 1, 0.36, 1),
101
+ stroke-dashoffset 1.2s ease,
102
+ background 0.6s ease,
103
+ border-color 0.6s ease,
104
+ box-shadow 0.6s ease;
105
+ }
106
+
107
+ .showcase-row:not(.arrived) .fx > * {
108
+ opacity: 0;
109
+ transform: translateY(14px);
110
+ }
111
+
112
+ .fx > *:nth-child(2) {
113
+ transition-delay: 0.35s;
114
+ }
115
+
116
+ .fx > *:nth-child(3) {
117
+ transition-delay: 0.7s;
118
+ }
119
+
120
+ .fx > *:nth-child(4) {
121
+ transition-delay: 1.05s;
122
+ }
123
+
124
+ .fx > *:nth-child(5) {
125
+ transition-delay: 1.3s;
126
+ }
127
+
128
+ /* Chat */
129
+ .fx-bubble {
130
+ justify-self: end;
131
+ padding: 8px 14px;
132
+ border-radius: 14px 14px 4px 14px;
133
+ background: var(--blue-10);
134
+ border: 1px solid var(--blue-35);
135
+ }
136
+
137
+ .fx-steps {
138
+ display: grid;
139
+ gap: 2px;
140
+ }
141
+
142
+ .fx-answer {
143
+ line-height: 1.6;
144
+ }
145
+
146
+ .fx-cite {
147
+ display: inline-grid;
148
+ place-items: center;
149
+ min-width: 16px;
150
+ height: 16px;
151
+ margin-left: 3px;
152
+ border-radius: 5px;
153
+ font: 600 0.6rem var(--font-mono);
154
+ vertical-align: super;
155
+ background: var(--blue);
156
+ box-shadow: 0 0 10px var(--blue);
157
+ }
158
+
159
+ .fx-source {
160
+ display: grid;
161
+ gap: 6px;
162
+ padding: 10px 12px;
163
+ border-radius: 10px;
164
+ border: 1px solid var(--line);
165
+ }
166
+
167
+ .fx-line {
168
+ padding: 4px 8px;
169
+ border-radius: 6px;
170
+ background: var(--blue-20);
171
+ box-shadow: inset 3px 0 0 var(--blue);
172
+ }
173
+
174
+ /* Topic map */
175
+ .fx-map {
176
+ padding: 8px;
177
+ }
178
+
179
+ .fx-map svg {
180
+ width: 100%;
181
+ height: 100%;
182
+ }
183
+
184
+ .fx-edge {
185
+ stroke: var(--white);
186
+ stroke-opacity: 0.3;
187
+ stroke-width: 0.4;
188
+ stroke-dasharray: 60;
189
+ stroke-dashoffset: 60;
190
+ transition-delay: var(--d);
191
+ }
192
+
193
+ .fx-halo {
194
+ fill: var(--blue);
195
+ opacity: 0.35;
196
+ filter: blur(2px);
197
+ }
198
+
199
+ .fx-node text {
200
+ fill: var(--white);
201
+ font: 500 3.6px var(--font-body);
202
+ }
203
+
204
+ .fx-node {
205
+ opacity: 0;
206
+ transition-delay: var(--d);
207
+ }
208
+
209
+ .showcase-row.arrived .fx-edge {
210
+ stroke-dashoffset: 0;
211
+ }
212
+
213
+ .showcase-row.arrived .fx-node {
214
+ opacity: 1;
215
+ }
216
+
217
+ .showcase-row.arrived .fx-halo {
218
+ animation: pulse-halo 4s ease-in-out infinite;
219
+ }
220
+
221
+ @keyframes pulse-halo {
222
+ 50% {
223
+ opacity: 0.6;
224
+ }
225
+ }
226
+
227
+ /* Audio */
228
+ .fx-wave {
229
+ display: flex;
230
+ align-items: center;
231
+ gap: 4px;
232
+ height: 110px;
233
+ }
234
+
235
+ .fx-wave span {
236
+ flex: 1;
237
+ height: 12%;
238
+ border-radius: 99px;
239
+ background: var(--blue);
240
+ box-shadow: 0 0 8px var(--blue-60);
241
+ }
242
+
243
+ .fx-wave span:nth-child(2n) {
244
+ background: var(--white);
245
+ }
246
+
247
+ .showcase-row.arrived .fx-wave span {
248
+ animation: bar 1.4s ease-in-out infinite alternate;
249
+ animation-delay: var(--d);
250
+ }
251
+
252
+ @keyframes bar {
253
+ from {
254
+ height: 12%;
255
+ }
256
+ to {
257
+ height: var(--h);
258
+ }
259
+ }
260
+
261
+ .fx-lines {
262
+ display: grid;
263
+ gap: 8px;
264
+ }
265
+
266
+ .fx-lines p {
267
+ display: flex;
268
+ gap: 10px;
269
+ align-items: center;
270
+ }
271
+
272
+ .fx-host {
273
+ display: inline-grid;
274
+ place-items: center;
275
+ flex: none;
276
+ width: 22px;
277
+ height: 22px;
278
+ border-radius: 50%;
279
+ font: 600 0.65rem var(--font-mono);
280
+ }
281
+
282
+ .fx-host.a {
283
+ background: var(--blue);
284
+ }
285
+
286
+ .fx-host.b {
287
+ background: var(--white);
288
+ color: var(--black);
289
+ }
290
+
291
+ /* Video */
292
+ .fx-video {
293
+ grid-template-columns: 0.55fr 1.2fr 0.8fr;
294
+ align-items: center;
295
+ }
296
+
297
+ .fx-frame {
298
+ display: grid;
299
+ gap: 6px;
300
+ align-content: end;
301
+ padding: 10px;
302
+ border-radius: 10px;
303
+ border: 1px solid var(--blue-35);
304
+ background: radial-gradient(80% 60% at 50% 100%, var(--blue-20), transparent), var(--black);
305
+ box-shadow: 0 0 20px var(--blue-10);
306
+ font-size: 0.75rem;
307
+ }
308
+
309
+ .fx-frame b {
310
+ font-family: var(--font-display);
311
+ line-height: 1.2;
312
+ }
313
+
314
+ .v916 {
315
+ aspect-ratio: 9 / 16;
316
+ }
317
+
318
+ .v169 {
319
+ aspect-ratio: 16 / 9;
320
+ }
321
+
322
+ .v11 {
323
+ aspect-ratio: 1;
324
+ }
325
+
326
+ .fx-mini-wave {
327
+ display: block;
328
+ height: 18px;
329
+ background: repeating-linear-gradient(90deg, var(--blue) 0 3px, transparent 3px 6px);
330
+ }
331
+
332
+ /* Launch Kit */
333
+ .fx-kit {
334
+ grid-template-columns: repeat(2, 1fr);
335
+ }
336
+
337
+ .fx-card {
338
+ display: grid;
339
+ gap: 6px;
340
+ padding: 12px;
341
+ border-radius: 12px;
342
+ border: 1px solid var(--line-strong);
343
+ background: rgba(0, 0, 0, 0.7);
344
+ transition-delay: var(--d);
345
+ }
346
+
347
+ .fx-card p {
348
+ font-size: 0.82rem;
349
+ line-height: 1.45;
350
+ }
351
+
352
+ .showcase-row:not(.arrived) .fx-kit .fx-card {
353
+ opacity: 0;
354
+ transform: translateY(40px) rotate(var(--r));
355
+ }
356
+
357
+ /* Voice */
358
+ .fx-profile {
359
+ display: grid;
360
+ gap: 4px;
361
+ padding: 12px;
362
+ border-radius: 12px;
363
+ border: 1px solid var(--blue-35);
364
+ background: var(--blue-05);
365
+ }
366
+
367
+ .fx-slider {
368
+ display: grid;
369
+ grid-template-columns: 90px 1fr;
370
+ align-items: center;
371
+ gap: 10px;
372
+ font-size: 0.8rem;
373
+ color: var(--text-2);
374
+ }
375
+
376
+ .fx-slider i {
377
+ position: relative;
378
+ height: 4px;
379
+ border-radius: 99px;
380
+ background: var(--surface-2);
381
+ }
382
+
383
+ .fx-slider i::after {
384
+ content: "";
385
+ position: absolute;
386
+ left: 0;
387
+ top: -3px;
388
+ width: 10px;
389
+ height: 10px;
390
+ margin-left: -5px;
391
+ border-radius: 50%;
392
+ background: var(--white);
393
+ box-shadow: 0 0 10px var(--blue);
394
+ transition: left 1.2s cubic-bezier(0.22, 1, 0.36, 1);
395
+ transition-delay: var(--d);
396
+ }
397
+
398
+ .showcase-row.arrived .fx-slider i::after {
399
+ left: var(--v);
400
+ }
401
+
402
+ .fx-clone {
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 8px;
406
+ }
407
+
408
+ .fx-rec {
409
+ width: 8px;
410
+ height: 8px;
411
+ border-radius: 50%;
412
+ background: var(--white);
413
+ box-shadow: 0 0 8px var(--white);
414
+ animation: pill-pulse 1.4s ease-in-out infinite;
415
+ }
416
+
417
+ /* Launchpad */
418
+ .fx-cal {
419
+ display: grid;
420
+ grid-template-columns: repeat(7, 1fr);
421
+ gap: 5px;
422
+ }
423
+
424
+ .fx-cal span {
425
+ aspect-ratio: 1.3;
426
+ padding: 3px 5px;
427
+ border-radius: 6px;
428
+ border: 1px solid var(--line);
429
+ font: 0.6rem var(--font-mono);
430
+ color: var(--text-3);
431
+ }
432
+
433
+ .fx-cal span.on {
434
+ transition-delay: var(--d);
435
+ }
436
+
437
+ .showcase-row.arrived .fx-cal span.on {
438
+ border-color: var(--blue);
439
+ background: var(--blue-20);
440
+ box-shadow: 0 0 12px var(--blue-35);
441
+ color: var(--white);
442
+ }
443
+
444
+ .fx-launch {
445
+ display: flex;
446
+ flex-wrap: wrap;
447
+ gap: 6px;
448
+ }
449
+
450
+ .fx-launch span {
451
+ padding: 4px 10px;
452
+ border-radius: 99px;
453
+ border: 1px solid var(--blue-35);
454
+ color: var(--white);
455
+ font-size: 0.72rem;
456
+ }
457
+
458
+ @media (prefers-reduced-motion: reduce) {
459
+ .showcase-row .fx > *,
460
+ .showcase-row .fx-node,
461
+ .showcase-row .fx-card {
462
+ opacity: 1 !important;
463
+ transform: none !important;
464
+ }
465
+
466
+ .fx-edge {
467
+ stroke-dashoffset: 0;
468
+ }
469
+
470
+ .fx-wave span,
471
+ .fx-halo,
472
+ .fx-rec {
473
+ animation: none !important;
474
+ }
475
+ }
476
+
477
+ /* Demo video sound controls */
478
+ .demo-bar {
479
+ align-items: center;
480
+ }
481
+
482
+ .demo-sound {
483
+ border: 1px solid var(--blue-35);
484
+ border-radius: 999px;
485
+ padding: 3px 12px;
486
+ background: transparent;
487
+ color: var(--white);
488
+ font: inherit;
489
+ cursor: pointer;
490
+ }
491
+
492
+ .demo-sound:hover {
493
+ border-color: var(--blue);
494
+ box-shadow: var(--glow-sm);
495
+ }
496
+
497
+ .demo-stage {
498
+ position: relative;
499
+ }
500
+
501
+ .demo-unmute {
502
+ position: absolute;
503
+ right: 16px;
504
+ bottom: 16px;
505
+ display: inline-flex;
506
+ align-items: center;
507
+ gap: 8px;
508
+ padding: 10px 16px;
509
+ border-radius: 999px;
510
+ border: 1px solid var(--blue-60);
511
+ background: rgba(0, 0, 0, 0.72);
512
+ backdrop-filter: blur(6px);
513
+ color: var(--white);
514
+ font: 600 0.875rem var(--font-body);
515
+ cursor: pointer;
516
+ box-shadow: var(--glow-md);
517
+ }
518
+
519
+ .demo-unmute:hover {
520
+ background: var(--blue);
521
+ }
frontend/tsconfig.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/logo.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx"],"version":"5.6.3"}
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/stream.ts","./src/api/types.ts","./src/components/appshell.tsx","./src/components/artifactcard.tsx","./src/components/docpicker.tsx","./src/components/featureshowcase.tsx","./src/components/logo.tsx","./src/components/markdown.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/reader.tsx","./src/components/schedulemodal.tsx","./src/components/skycanvas.tsx","./src/components/ui.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/hooks/usejob.ts","./src/lib/sound.ts","./src/pages/archive.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/launchkit.tsx","./src/pages/launchpad.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx","./src/pages/notebooks.tsx","./src/pages/resurface.tsx","./src/pages/settings.tsx","./src/pages/sources.tsx","./src/pages/studio.tsx","./src/pages/topicmap.tsx","./src/pages/voiceprofile.tsx","./src/pages/welcome.tsx"],"version":"5.6.3"}
renderer/public/demo-vo/durations.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "warp": 3.79,
3
+ "paste": 3.27,
4
+ "orbit": 6.3,
5
+ "research": 4.29,
6
+ "audio": 3.74,
7
+ "launchkit": 6.53,
8
+ "launchpad": 3.22,
9
+ "outro": 2.48
10
+ }
renderer/src/Root.tsx CHANGED
@@ -2,6 +2,7 @@ import { Composition, Still } from "remotion";
2
  import { AudiogramSquare, audiogramDuration } from "./compositions/AudiogramSquare";
3
  import { CarouselSlide } from "./compositions/CarouselSlide";
4
  import { ExplainerLong, explainerDuration } from "./compositions/ExplainerLong";
 
5
  import { QuoteCard } from "./compositions/QuoteCard";
6
  import { ShortVertical, shortVerticalDuration } from "./compositions/ShortVertical";
7
  import {
@@ -42,6 +43,7 @@ const sampleAudiogram: AudiogramSquareProps = {
42
  export function Root() {
43
  return (
44
  <>
 
45
  <Composition
46
  id="ShortVertical"
47
  component={ShortVertical}
 
2
  import { AudiogramSquare, audiogramDuration } from "./compositions/AudiogramSquare";
3
  import { CarouselSlide } from "./compositions/CarouselSlide";
4
  import { ExplainerLong, explainerDuration } from "./compositions/ExplainerLong";
5
+ import { DEMO_DURATION, DEMO_FPS, LandingDemo } from "./compositions/LandingDemo";
6
  import { QuoteCard } from "./compositions/QuoteCard";
7
  import { ShortVertical, shortVerticalDuration } from "./compositions/ShortVertical";
8
  import {
 
43
  export function Root() {
44
  return (
45
  <>
46
+ <Composition id="LandingDemo" component={LandingDemo} width={1920} height={1080} fps={DEMO_FPS} durationInFrames={DEMO_DURATION} />
47
  <Composition
48
  id="ShortVertical"
49
  component={ShortVertical}
renderer/src/compositions/LandingDemo.tsx ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { theme } from "../theme";
4
+
5
+ /** Landing page product demo: a trip through the Notestack galaxy. 1920x1080, 30 fps, ~32 s.
6
+ * Scenes: warp in, paste URL, posts enter orbit, cited research, audio overview, Launch Kit,
7
+ * Launchpad, outro. Strict palette: black, #217cff, white. */
8
+
9
+ export const DEMO_FPS = 30;
10
+ const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
11
+
12
+ // Scene lengths fit the narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"):
13
+ // each line starts `vo` seconds into its scene and ends with room to breathe before the crossfade.
14
+ const SCENES = [
15
+ { id: "warp", len: 5.2, vo: 0.6 },
16
+ { id: "paste", len: 4.6, vo: 0.4 },
17
+ { id: "orbit", len: 7.6, vo: 0.4 },
18
+ { id: "research", len: 5.6, vo: 0.4 },
19
+ { id: "audio", len: 5.1, vo: 0.4 },
20
+ { id: "launchkit", len: 7.8, vo: 0.4 },
21
+ { id: "launchpad", len: 4.6, vo: 0.4 },
22
+ { id: "outro", len: 4.2, vo: 0.5 },
23
+ ] as const;
24
+ const XFADE = 0.5; // seconds of overlap between scenes
25
+
26
+ export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1));
27
+
28
+ const BLUE = theme.blue;
29
+ const ease = Easing.bezier(0.22, 1, 0.36, 1);
30
+
31
+ // Shared pieces
32
+
33
+ /** 3D starfield flying toward the camera. `speed` can ramp for warp streaks. */
34
+ function WarpField({ speed, count = 420, streak = 0 }: { speed: number; count?: number; streak?: number }) {
35
+ const frame = useCurrentFrame();
36
+ const { width, height } = useVideoConfig();
37
+ const cx = width / 2;
38
+ const cy = height / 2;
39
+ return (
40
+ <AbsoluteFill style={{ backgroundColor: theme.black }}>
41
+ <AbsoluteFill
42
+ style={{
43
+ background: `radial-gradient(55% 45% at 50% 50%, ${BLUE}22, transparent 70%), radial-gradient(40% 30% at 80% 20%, ${BLUE}14, transparent 70%)`,
44
+ }}
45
+ />
46
+ <svg width={width} height={height}>
47
+ {Array.from({ length: count }).map((_, i) => {
48
+ const angle = random(`a${i}`) * Math.PI * 2;
49
+ const base = random(`z${i}`);
50
+ const z = (((base - frame * speed * 0.004) % 1) + 1) % 1; // 1 far, 0 at the camera
51
+ const depth = 0.04 + z;
52
+ const r = (random(`r${i}`) * 0.9 + 0.1) * 900;
53
+ const x = cx + (Math.cos(angle) * r) / depth / 6;
54
+ const y = cy + (Math.sin(angle) * r) / depth / 6;
55
+ const size = Math.max(0.4, (1 - z) * 2.6);
56
+ const alpha = Math.min(1, (1 - z) * 1.4);
57
+ const blue = random(`b${i}`) < 0.25;
58
+ if (streak > 0.02) {
59
+ const z2 = z + streak * 0.08;
60
+ const d2 = 0.04 + z2;
61
+ const x2 = cx + (Math.cos(angle) * r) / d2 / 6;
62
+ const y2 = cy + (Math.sin(angle) * r) / d2 / 6;
63
+ return (
64
+ <line key={i} x1={x} y1={y} x2={x2} y2={y2} stroke={blue ? BLUE : theme.white} strokeOpacity={alpha} strokeWidth={size * 0.8} strokeLinecap="round" />
65
+ );
66
+ }
67
+ return <circle key={i} cx={x} cy={y} r={size} fill={blue ? BLUE : theme.white} opacity={alpha} />;
68
+ })}
69
+ </svg>
70
+ </AbsoluteFill>
71
+ );
72
+ }
73
+
74
+ /** A diagonal light flare that sweeps across, with a hot core and anamorphic streak. */
75
+ function Flare({ at, duration = 24, y = 50 }: { at: number; duration?: number; y?: number }) {
76
+ const frame = useCurrentFrame();
77
+ const p = interpolate(frame, [at, at + duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
78
+ if (p <= 0 || p >= 1) return null;
79
+ const x = interpolate(p, [0, 1], [-10, 110]);
80
+ const a = Math.sin(p * Math.PI);
81
+ return (
82
+ <AbsoluteFill style={{ pointerEvents: "none", mixBlendMode: "screen" }}>
83
+ <div
84
+ style={{
85
+ position: "absolute",
86
+ left: `${x}%`,
87
+ top: `${y}%`,
88
+ width: 520,
89
+ height: 520,
90
+ marginLeft: -260,
91
+ marginTop: -260,
92
+ borderRadius: "50%",
93
+ background: `radial-gradient(circle, rgba(255,255,255,${0.9 * a}) 0%, ${BLUE}${Math.round(a * 120).toString(16).padStart(2, "0")} 18%, transparent 60%)`,
94
+ }}
95
+ />
96
+ <div
97
+ style={{
98
+ position: "absolute",
99
+ left: 0,
100
+ right: 0,
101
+ top: `${y}%`,
102
+ height: 3,
103
+ marginTop: -1.5,
104
+ opacity: a * 0.9,
105
+ background: `linear-gradient(90deg, transparent, ${BLUE} ${x - 20}%, #ffffff ${x}%, ${BLUE} ${x + 20}%, transparent)`,
106
+ boxShadow: `0 0 30px ${BLUE}`,
107
+ }}
108
+ />
109
+ </AbsoluteFill>
110
+ );
111
+ }
112
+
113
+ function FadeScene({ len, children }: { len: number; children: ReactNode }) {
114
+ const frame = useCurrentFrame();
115
+ const total = S(len);
116
+ const fade = S(XFADE);
117
+ const opacity = interpolate(frame, [0, fade, total - fade, total], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
118
+ const scale = interpolate(frame, [0, total], [1.04, 1], { easing: ease });
119
+ return <AbsoluteFill style={{ opacity, transform: `scale(${scale})` }}>{children}</AbsoluteFill>;
120
+ }
121
+
122
+ function Caption({ eyebrow, title, delay = 0 }: { eyebrow: string; title: string; delay?: number }) {
123
+ const frame = useCurrentFrame();
124
+ const { fps } = useVideoConfig();
125
+ const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
126
+ return (
127
+ <div style={{ position: "absolute", left: 140, top: 110, opacity: s, transform: `translateY(${(1 - s) * 24}px)` }}>
128
+ <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
129
+ <h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth: 900 }}>{title}</h2>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ const panel: CSSProperties = {
135
+ border: "1px solid rgba(255,255,255,0.14)",
136
+ borderRadius: 22,
137
+ background: "rgba(0,0,0,0.72)",
138
+ boxShadow: `0 0 60px ${BLUE}33, 0 0 2px ${BLUE}`,
139
+ };
140
+
141
+ function typed(text: string, frame: number, start: number, cps = 26): string {
142
+ const n = Math.max(0, Math.floor(((frame - start) / DEMO_FPS) * cps));
143
+ return text.slice(0, n);
144
+ }
145
+
146
+ // Scenes
147
+
148
+ function Warp() {
149
+ const frame = useCurrentFrame();
150
+ const { fps } = useVideoConfig();
151
+ const speed = interpolate(frame, [0, 40, 70, 102], [14, 26, 6, 1.5], { extrapolateRight: "clamp" });
152
+ const streak = interpolate(frame, [0, 35, 70], [1, 1, 0], { extrapolateRight: "clamp" });
153
+ const title = spring({ frame: frame - 42, fps, config: { damping: 14 } });
154
+ const sub = spring({ frame: frame - 58, fps, config: { damping: 18 } });
155
+ return (
156
+ <AbsoluteFill>
157
+ <WarpField speed={speed} streak={streak} />
158
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
159
+ <h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 150, lineHeight: 1, color: theme.white, opacity: title, transform: `scale(${0.8 + title * 0.2})` }}>
160
+ Your archive,
161
+ <br />
162
+ <span style={{ color: theme.white, textShadow: `0 0 30px ${BLUE}, 0 0 80px ${BLUE}` }}>in orbit.</span>
163
+ </h1>
164
+ <p style={{ marginTop: 40, fontFamily: theme.mono, fontSize: 30, letterSpacing: 4, color: "rgba(255,255,255,0.75)", opacity: sub }}>
165
+ NOTESTACK
166
+ </p>
167
+ </AbsoluteFill>
168
+ <Flare at={50} duration={30} y={46} />
169
+ </AbsoluteFill>
170
+ );
171
+ }
172
+
173
+ function Paste() {
174
+ const frame = useCurrentFrame();
175
+ const { fps } = useVideoConfig();
176
+ const url = typed("yourname.substack.com", frame, 18, 18);
177
+ const press = spring({ frame: frame - 62, fps, config: { damping: 10, mass: 0.5 } });
178
+ const ring = interpolate(frame, [64, 100], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
179
+ return (
180
+ <AbsoluteFill>
181
+ <WarpField speed={1.2} />
182
+ <Caption eyebrow="Step 01" title="Paste your Substack URL" />
183
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
184
+ <div style={{ ...panel, display: "flex", alignItems: "center", gap: 18, padding: 14, width: 1100, borderRadius: 999, marginTop: 120 }}>
185
+ <div style={{ flex: 1, fontFamily: theme.body, fontSize: 40, color: url ? theme.white : "rgba(255,255,255,0.4)", paddingLeft: 30 }}>
186
+ {url || "yourname.substack.com"}
187
+ <span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
188
+ </div>
189
+ <div
190
+ style={{
191
+ position: "relative",
192
+ padding: "24px 44px",
193
+ borderRadius: 999,
194
+ background: BLUE,
195
+ color: theme.white,
196
+ fontFamily: theme.body,
197
+ fontWeight: 600,
198
+ fontSize: 32,
199
+ transform: `scale(${1 - 0.06 * Math.sin(press * Math.PI)})`,
200
+ boxShadow: `0 0 ${30 + press * 40}px ${BLUE}`,
201
+ }}
202
+ >
203
+ Launch my archive
204
+ <div
205
+ style={{
206
+ position: "absolute",
207
+ inset: -ring * 60,
208
+ borderRadius: 999,
209
+ border: `2px solid ${BLUE}`,
210
+ opacity: 1 - ring,
211
+ }}
212
+ />
213
+ </div>
214
+ </div>
215
+ </AbsoluteFill>
216
+ </AbsoluteFill>
217
+ );
218
+ }
219
+
220
+ const POSTS = [
221
+ "On Pricing", "Writing Every Day", "The Paid Tier", "Why I Quit Twitter", "Notes on Craft", "Letters to Readers",
222
+ "The Long Game", "Small Audiences", "Habits Beat Talent", "Editing Myself", "A Year of Essays", "What Readers Want",
223
+ ];
224
+
225
+ function Orbit() {
226
+ const frame = useCurrentFrame();
227
+ const { width, height, fps } = useVideoConfig();
228
+ const cx = width / 2 + 180;
229
+ const cy = height / 2 + 60;
230
+ const count = Math.floor(interpolate(frame, [20, 110], [0, 142], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
231
+ const planet = spring({ frame, fps, config: { damping: 20 } });
232
+ return (
233
+ <AbsoluteFill>
234
+ <WarpField speed={0.8} />
235
+ <Caption eyebrow="Step 02" title="Watch your posts come into orbit" />
236
+ <svg width={width} height={height} style={{ position: "absolute" }}>
237
+ <defs>
238
+ <radialGradient id="planet" cx="40%" cy="35%" r="70%">
239
+ <stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
240
+ <stop offset="35%" stopColor={BLUE} />
241
+ <stop offset="100%" stopColor="#000000" />
242
+ </radialGradient>
243
+ </defs>
244
+ <circle cx={cx} cy={cy} r={150 * planet} fill="url(#planet)" style={{ filter: `drop-shadow(0 0 60px ${BLUE})` }} />
245
+ {[260, 360].map((r, i) => (
246
+ <ellipse key={r} cx={cx} cy={cy} rx={r * 1.6} ry={r * 0.45} fill="none" stroke={i ? "#ffffff" : BLUE} strokeOpacity={0.25} strokeWidth={2} />
247
+ ))}
248
+ </svg>
249
+ {POSTS.map((title, i) => {
250
+ const start = 8 + i * 6;
251
+ const p = interpolate(frame, [start, start + 36], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
252
+ const ring = i % 2 ? 360 : 260;
253
+ const angle = (i / POSTS.length) * Math.PI * 2 + frame * 0.012 * (i % 2 ? 1 : -1);
254
+ const tx = cx + Math.cos(angle) * ring * 1.6;
255
+ const ty = cy + Math.sin(angle) * ring * 0.45;
256
+ const fromX = width + 200;
257
+ const fromY = random(`py${i}`) * height;
258
+ const x = interpolate(p, [0, 1], [fromX, tx]);
259
+ const y = interpolate(p, [0, 1], [fromY, ty]);
260
+ const front = Math.sin(angle) > 0;
261
+ return (
262
+ <div
263
+ key={title}
264
+ style={{
265
+ ...panel,
266
+ position: "absolute",
267
+ left: x,
268
+ top: y,
269
+ transform: `translate(-50%, -50%) scale(${front ? 1 : 0.8})`,
270
+ padding: "12px 20px",
271
+ borderRadius: 12,
272
+ fontFamily: theme.body,
273
+ fontSize: 22,
274
+ color: theme.white,
275
+ opacity: p * (front ? 1 : 0.55),
276
+ zIndex: front ? 2 : 0,
277
+ whiteSpace: "nowrap",
278
+ }}
279
+ >
280
+ {title}
281
+ </div>
282
+ );
283
+ })}
284
+ <div style={{ position: "absolute", left: 140, bottom: 120, fontFamily: theme.mono, color: theme.white }}>
285
+ <span style={{ fontSize: 96, fontFamily: theme.display, textShadow: `0 0 30px ${BLUE}` }}>{count}</span>
286
+ <span style={{ fontSize: 28, marginLeft: 20, color: "rgba(255,255,255,0.7)", letterSpacing: 3 }}>POSTS IN ORBIT</span>
287
+ </div>
288
+ </AbsoluteFill>
289
+ );
290
+ }
291
+
292
+ function Research() {
293
+ const frame = useCurrentFrame();
294
+ const { fps } = useVideoConfig();
295
+ const q = typed("What have I written about pricing?", frame, 10, 30);
296
+ const answer = spring({ frame: frame - 52, fps, config: { damping: 18 } });
297
+ const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
298
+ const steps = ["Searching for pric(e|ing)|paid tier", "Reading On Pricing, lines 12 to 20", "Reading The Paid Tier, lines 4 to 9"];
299
+ return (
300
+ <AbsoluteFill>
301
+ <WarpField speed={0.6} count={260} />
302
+ <Caption eyebrow="Research" title="Ask your archive. Every answer cites you." />
303
+ <div style={{ position: "absolute", left: 140, right: 140, top: 360, display: "grid", gridTemplateColumns: "1.25fr 1fr", gap: 40 }}>
304
+ <div style={{ ...panel, padding: 36, display: "grid", gap: 22, alignContent: "start" }}>
305
+ <div style={{ justifySelf: "end", padding: "14px 22px", borderRadius: "18px 18px 4px 18px", background: `${BLUE}22`, border: `1px solid ${BLUE}66`, fontFamily: theme.body, fontSize: 30, color: theme.white }}>
306
+ {q || " "}
307
+ </div>
308
+ <div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
309
+ {steps.map((s, i) => (
310
+ <span key={s} style={{ opacity: interpolate(frame, [26 + i * 8, 34 + i * 8], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
311
+ {s}
312
+ </span>
313
+ ))}
314
+ </div>
315
+ <p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
316
+ You raised prices twice and the <b>paid tier doubled</b>{" "}
317
+ <Marker n={1} glow={cite} /> because readers took the work more seriously{" "}
318
+ <Marker n={2} glow={cite} />.
319
+ </p>
320
+ </div>
321
+ <div style={{ ...panel, padding: 30, opacity: cite, transform: `translateX(${(1 - cite) * 60}px)`, alignSelf: "start" }}>
322
+ <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
323
+ {["The paid tier doubled after I", "raised the price to ten dollars.", "Readers took the work seriously."].map((l, i) => (
324
+ <div key={l} style={{ display: "flex", gap: 18, marginTop: 14, fontFamily: theme.body, fontSize: 26, color: theme.white, background: `${BLUE}26`, boxShadow: `inset 4px 0 0 ${BLUE}`, padding: "6px 12px", borderRadius: 8 }}>
325
+ <span style={{ fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", paddingTop: 5 }}>{12 + i}</span>
326
+ {l}
327
+ </div>
328
+ ))}
329
+ </div>
330
+ </div>
331
+ </AbsoluteFill>
332
+ );
333
+ }
334
+
335
+ function Marker({ n, glow }: { n: number; glow: number }) {
336
+ return (
337
+ <span style={{ display: "inline-block", minWidth: 34, padding: "0 8px", borderRadius: 8, fontFamily: theme.mono, fontSize: 20, verticalAlign: "super", color: theme.white, background: `${BLUE}${glow > 0.5 ? "" : "55"}`, border: `1px solid ${BLUE}`, boxShadow: `0 0 ${glow * 24}px ${BLUE}`, textAlign: "center" }}>
338
+ {n}
339
+ </span>
340
+ );
341
+ }
342
+
343
+ function Audio() {
344
+ const frame = useCurrentFrame();
345
+ const { fps } = useVideoConfig();
346
+ const lines = [
347
+ { who: "A", text: "So the big idea here is that price is a signal." },
348
+ { who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
349
+ { who: "A", text: "Which she wrote about back in March, in On Pricing." },
350
+ ];
351
+ const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 20) / 34));
352
+ return (
353
+ <AbsoluteFill>
354
+ <WarpField speed={0.7} count={260} />
355
+ <Caption eyebrow="Audio overview" title="Two hosts. Your ideas. Grounded." />
356
+ <div style={{ position: "absolute", left: 140, right: 140, top: 380, display: "grid", gap: 40 }}>
357
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, height: 220 }}>
358
+ {Array.from({ length: 64 }).map((_, i) => {
359
+ const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
360
+ const env = spring({ frame: frame - 6, fps, config: { damping: 20 } });
361
+ return <div key={i} style={{ width: 14, height: 12 + v * 200 * env, borderRadius: 7, background: i % 2 ? theme.white : BLUE, boxShadow: `0 0 18px ${BLUE}` }} />;
362
+ })}
363
+ </div>
364
+ <div style={{ display: "grid", gap: 14 }}>
365
+ {lines.map((l, i) => {
366
+ const on = i === active;
367
+ const seen = i <= active;
368
+ return (
369
+ <div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity: seen ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 34, color: theme.white }}>
370
+ <span style={{ width: 52, height: 52, borderRadius: "50%", display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 22, background: l.who === "A" ? BLUE : theme.white, color: l.who === "A" ? theme.white : theme.black, boxShadow: on ? `0 0 26px ${BLUE}` : "none" }}>
371
+ {l.who}
372
+ </span>
373
+ {l.text}
374
+ </div>
375
+ );
376
+ })}
377
+ </div>
378
+ </div>
379
+ </AbsoluteFill>
380
+ );
381
+ }
382
+
383
+ function LaunchKit() {
384
+ const frame = useCurrentFrame();
385
+ const { fps } = useVideoConfig();
386
+ const cards = [
387
+ { label: "X THREAD", body: "I doubled my price. My paid tier doubled too. Here is what I learned." },
388
+ { label: "LINKEDIN", body: "Most writers underprice. I did for two years. Then I ran an experiment." },
389
+ { label: "SUBSTACK NOTES", body: "Price is a signal. Readers told me so, with their wallets." },
390
+ { label: "BLUESKY", body: "Raised my price to $10. Paid readers went up, not down." },
391
+ ];
392
+ return (
393
+ <AbsoluteFill>
394
+ <WarpField speed={0.9} count={260} />
395
+ <Caption eyebrow="Launch Kit" title="One post. Every platform. Your voice." />
396
+ <div style={{ position: "absolute", left: 140, right: 140, top: 380, display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 28 }}>
397
+ {cards.map((c, i) => {
398
+ const s = spring({ frame: frame - 10 - i * 7, fps, config: { damping: 14 } });
399
+ const rot = interpolate(s, [0, 1], [(i - 1.5) * 14, 0]);
400
+ return (
401
+ <div key={c.label} style={{ ...panel, padding: 28, minHeight: 300, opacity: s, transform: `translateY(${(1 - s) * 180}px) rotate(${rot}deg)`, display: "grid", alignContent: "start", gap: 18 }}>
402
+ <span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
403
+ <span style={{ fontFamily: theme.body, fontSize: 28, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
404
+ </div>
405
+ );
406
+ })}
407
+ </div>
408
+ <div style={{ position: "absolute", left: 140, bottom: 90, display: "flex", gap: 16, fontFamily: theme.mono, fontSize: 22, color: "rgba(255,255,255,0.7)", letterSpacing: 2 }}>
409
+ {["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
410
+ <span key={t} style={{ padding: "10px 18px", border: `1px solid ${BLUE}88`, borderRadius: 999, opacity: interpolate(frame, [50 + i * 6, 60 + i * 6], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
411
+ {t}
412
+ </span>
413
+ ))}
414
+ </div>
415
+ </AbsoluteFill>
416
+ );
417
+ }
418
+
419
+ function Launchpad() {
420
+ const frame = useCurrentFrame();
421
+ const { width, height } = useVideoConfig();
422
+ const lit = [2, 5, 9, 12, 16, 19, 23, 26];
423
+ const rocket = interpolate(frame, [70, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.in(Easing.cubic) });
424
+ return (
425
+ <AbsoluteFill>
426
+ <WarpField speed={0.8} count={260} />
427
+ <Caption eyebrow="Launchpad" title="Schedule it. Watch it lift off." />
428
+ <div style={{ position: "absolute", left: 140, top: 360, display: "grid", gridTemplateColumns: "repeat(7, 120px)", gap: 12 }}>
429
+ {Array.from({ length: 28 }).map((_, i) => {
430
+ const on = lit.includes(i) && frame > 8 + lit.indexOf(i) * 6;
431
+ return (
432
+ <div key={i} style={{ height: 88, borderRadius: 12, border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.14)"}`, background: on ? `${BLUE}33` : "rgba(0,0,0,0.5)", boxShadow: on ? `0 0 22px ${BLUE}88` : "none", padding: 10, fontFamily: theme.mono, fontSize: 16, color: "rgba(255,255,255,0.6)" }}>
433
+ {i + 1}
434
+ {on && <div style={{ marginTop: 18, height: 10, borderRadius: 5, background: theme.white, boxShadow: `0 0 12px ${BLUE}` }} />}
435
+ </div>
436
+ );
437
+ })}
438
+ </div>
439
+ <svg width={width} height={height} style={{ position: "absolute" }}>
440
+ <defs>
441
+ <linearGradient id="trail" x1="0" y1="1" x2="0" y2="0">
442
+ <stop offset="0%" stopColor={BLUE} stopOpacity="0" />
443
+ <stop offset="70%" stopColor={BLUE} stopOpacity="0.8" />
444
+ <stop offset="100%" stopColor="#ffffff" stopOpacity="1" />
445
+ </linearGradient>
446
+ </defs>
447
+ {(() => {
448
+ // A launch arc from the lower right: curved trail, hot head, exhaust sparks.
449
+ const pt = (u: number) => ({ x: 1360 + 300 * u + 120 * u * u, y: 1000 - 1050 * u + 120 * u * (1 - u) });
450
+ const head = pt(rocket);
451
+ const tail = pt(Math.max(0, rocket - 0.35));
452
+ const path = Array.from({ length: 24 }, (_, i) => pt(Math.max(0, rocket - 0.35) + ((rocket - Math.max(0, rocket - 0.35)) * i) / 23));
453
+ return (
454
+ <>
455
+ <circle cx={1360} cy={1000} r={140 * Math.min(1, rocket * 4)} fill={BLUE} opacity={0.25 * (1 - rocket)} style={{ filter: "blur(30px)" }} />
456
+ {rocket > 0 && (
457
+ <polyline points={path.map((q) => `${q.x},${q.y}`).join(" ")} fill="none" stroke="url(#trail)" strokeWidth={10} strokeLinecap="round" style={{ filter: `drop-shadow(0 0 18px ${BLUE})` }} />
458
+ )}
459
+ {rocket > 0 &&
460
+ Array.from({ length: 16 }).map((_, i) => {
461
+ const u = Math.max(0, rocket - random(`s${i}`) * 0.3);
462
+ const q = pt(u);
463
+ const jitter = (random(`j${i}`) - 0.5) * 60;
464
+ return <circle key={i} cx={q.x + jitter} cy={q.y + 20} r={2 + random(`k${i}`) * 3} fill="#ffffff" opacity={0.3 + 0.5 * random(`o${i}`)} />;
465
+ })}
466
+ {rocket > 0 && <circle cx={head.x} cy={head.y} r={16} fill="#ffffff" style={{ filter: `drop-shadow(0 0 28px ${BLUE}) drop-shadow(0 0 8px #ffffff)` }} />}
467
+ {rocket === 0 && <circle cx={tail.x} cy={tail.y} r={10} fill="#ffffff" opacity={interpolate(frame, [30, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })} style={{ filter: `drop-shadow(0 0 16px ${BLUE})` }} />}
468
+ </>
469
+ );
470
+ })()}
471
+ </svg>
472
+ <div style={{ position: "absolute", left: 1180, top: 400, fontFamily: theme.mono, color: theme.white, opacity: interpolate(frame, [20, 40], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
473
+ <p style={{ margin: 0, fontSize: 22, letterSpacing: 4, color: BLUE }}>NEXT LAUNCH</p>
474
+ <p style={{ margin: "10px 0 0", fontFamily: theme.display, fontSize: 88, textShadow: `0 0 30px ${BLUE}` }}>
475
+ T-{String(Math.max(0, 10 - Math.floor(frame / 7))).padStart(2, "0")}
476
+ </p>
477
+ <p style={{ margin: "6px 0 0", fontSize: 22, color: "rgba(255,255,255,0.7)", letterSpacing: 2 }}>X · LINKEDIN · BLUESKY</p>
478
+ </div>
479
+ </AbsoluteFill>
480
+ );
481
+ }
482
+
483
+ function Outro() {
484
+ const frame = useCurrentFrame();
485
+ const { fps } = useVideoConfig();
486
+ const s = spring({ frame: frame - 8, fps, config: { damping: 14 } });
487
+ const cta = spring({ frame: frame - 30, fps, config: { damping: 18 } });
488
+ return (
489
+ <AbsoluteFill>
490
+ <WarpField speed={interpolate(frame, [0, 60, 114], [0.5, 2, 12])} streak={interpolate(frame, [70, 114], [0, 1], { extrapolateLeft: "clamp" })} />
491
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 30 }}>
492
+ <div style={{ width: 120, height: 120, borderRadius: 30, background: theme.white, display: "grid", placeItems: "center", opacity: s, transform: `scale(${s})`, boxShadow: `0 0 80px ${BLUE}` }}>
493
+ <div style={{ width: 44, height: 44, borderRadius: "50%", background: theme.black, boxShadow: `18px -18px 0 -10px ${theme.black}` }} />
494
+ </div>
495
+ <h1 style={{ margin: 0, fontFamily: theme.display, fontSize: 120, color: theme.white, opacity: s, textShadow: `0 0 40px ${BLUE}` }}>Notestack</h1>
496
+ <p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.8)", opacity: cta }}>Your archive, in orbit.</p>
497
+ <p style={{ margin: 0, padding: "18px 40px", borderRadius: 999, background: BLUE, color: theme.white, fontFamily: theme.body, fontWeight: 600, fontSize: 32, opacity: cta, boxShadow: `0 0 40px ${BLUE}` }}>
498
+ Free during early access
499
+ </p>
500
+ </AbsoluteFill>
501
+ <Flare at={20} duration={34} y={42} />
502
+ </AbsoluteFill>
503
+ );
504
+ }
505
+
506
+ const RENDER: Record<(typeof SCENES)[number]["id"], () => JSX.Element> = {
507
+ warp: Warp,
508
+ paste: Paste,
509
+ orbit: Orbit,
510
+ research: Research,
511
+ audio: Audio,
512
+ launchkit: LaunchKit,
513
+ launchpad: Launchpad,
514
+ outro: Outro,
515
+ };
516
+
517
+ export function LandingDemo() {
518
+ let cursor = 0;
519
+ return (
520
+ <AbsoluteFill style={{ backgroundColor: theme.black }}>
521
+ <SoundTrack src={staticFile("demo-vo/ambience.wav")} volume={0.32} />
522
+ {SCENES.map((scene, i) => {
523
+ const from = cursor;
524
+ cursor += S(scene.len - XFADE);
525
+ const Scene = RENDER[scene.id];
526
+ return (
527
+ <Sequence key={scene.id} from={from} durationInFrames={S(scene.len)} name={scene.id}>
528
+ <FadeScene len={scene.len}>
529
+ <Scene />
530
+ </FadeScene>
531
+ <Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
532
+ <SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
533
+ </Sequence>
534
+ {i === 0 && null}
535
+ </Sequence>
536
+ );
537
+ })}
538
+ </AbsoluteFill>
539
+ );
540
+ }