Harsh200415 Claude Sonnet 4.6 commited on
Commit
d1cde70
Β·
1 Parent(s): d4c6e81

Rename to mirror., scroll reveal animations, waveform logo, deployment config

Browse files

- Renamed app from Behavioral Mirror to mirror. throughout
- Added waveform SVG logo (option 2A) to all pages replacing emoji placeholders
- Added scroll reveal animations (Reveal/RevealItem) to all pages
- Cleaned requirements.txt: removed unused anthropic, openai-whisper, whisperx, sqlalchemy
- Added Dockerfile with CPU-only torch and ffmpeg for Railway deployment
- Updated CORS to allow chrome-extension:// origins via regex + FRONTEND_URL env var
- Added .env.example template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ GROQ_API_KEY=
2
+ HF_TOKEN=
3
+ SUPABASE_URL=
4
+ SUPABASE_ANON_KEY=
5
+ SUPABASE_SERVICE_KEY=
6
+ FRONTEND_URL=
backend/Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ ffmpeg \
5
+ libsndfile1 \
6
+ git \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ # Install CPU-only torch first so pip doesn't pull the 2 GB CUDA build
12
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
13
+
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ COPY . .
18
+
19
+ EXPOSE 8000
20
+
21
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]
backend/main.py CHANGED
@@ -19,18 +19,27 @@ from pipeline.dimension_scorer import DimensionScorer
19
  from pipeline.voiceprint import VoiceprintMatcher
20
  from pipeline.context_detector import ContextDetector
21
  from pipeline.personality_synthesizer import PersonalitySynthesizer
 
22
  from db.database import supabase_admin
23
 
24
  app = FastAPI()
25
 
 
 
 
 
 
 
26
  app.add_middleware(
27
  CORSMiddleware,
28
- allow_origins=["http://localhost:5173"],
 
 
29
  allow_methods=["*"],
30
- allow_headers=["*"]
31
  )
32
 
33
- UPLOAD_DIR = "/tmp/behavioral_mirror"
34
  os.makedirs(UPLOAD_DIR, exist_ok=True)
35
 
36
  # In-memory store for prepare β†’ finalize handoff.
@@ -56,18 +65,20 @@ def _cleanup_stale_cache():
56
  del _prepare_cache[sid]
57
 
58
 
59
- print("Loading models... this may take a minute on first run.")
60
- transcriber = Transcriber(model_size="small")
61
  diarizer = Diarizer(hf_token=os.getenv("HF_TOKEN"))
62
  insight_gen = InsightGenerator(api_key=os.getenv("GROQ_API_KEY"))
63
  context_detector = ContextDetector(api_key=os.getenv("GROQ_API_KEY"))
64
  dimension_scorer = DimensionScorer()
65
  voiceprint_matcher = VoiceprintMatcher(hf_token=os.getenv("HF_TOKEN"))
66
  personality_synth = PersonalitySynthesizer(api_key=os.getenv("GROQ_API_KEY"))
 
67
  print("All models loaded. Server ready.")
68
 
69
- # In-memory personality cache: key = "{user_id}:{session_count}"
70
- _personality_cache: dict = {}
 
71
 
72
 
73
  CONTEXT_POPULATION_NORMS: dict = {
@@ -155,33 +166,33 @@ def _compute_dim_averages(parsed: list) -> dict:
155
 
156
 
157
  _CONTEXT_BLIND_SPOTS: dict = {
158
- "evaluative": (1, "High-stakes settings",
159
- "You've never uploaded an interview, presentation, or review. Your confidence and "
160
- "composure scores are based on lower-stakes conversations β€” they may look very "
161
- "different when you're being assessed."),
162
- "adversarial": (2, "Conflict & pushback",
163
  "No conflict or disagreement recordings yet. We can't tell how you respond when "
164
  "challenged or when there's real friction in the room."),
165
- "collaborative": (3, "Group & team dynamics",
166
  "No team meetings or brainstorming sessions uploaded. Your listening and "
167
  "assertiveness scores come from 1-on-1 conversations only."),
168
- "influential": (4, "Persuasion & pitching",
169
- "No pitch or sales conversations yet. We can't see how you hold an argument or "
170
- "move someone toward a decision."),
171
  "negotiation": (5, "Negotiation",
172
- "No negotiation recordings. How your composure and assertiveness hold under "
173
  "competing interests is still unmeasured."),
174
- "developmental": (6, "Giving feedback",
175
  "No coaching or feedback sessions recorded. We can't see how you structure or "
176
- "deliver criticism."),
177
- "support": (7, "Empathy-led listening",
178
  "No support conversations yet. Your empathy dimension has no direct evidence."),
179
- "intimate": (8, "Deep personal conversations",
180
- "No emotionally intimate conversations uploaded. Your expressiveness score may "
181
- "be incomplete."),
182
- "social": (9, "Casual & social",
183
- "No casual social conversations recorded. We can't see your natural, low-stakes "
184
- "communication style yet."),
185
  }
186
 
187
 
@@ -219,28 +230,6 @@ def _compute_session_delta(old: dict, new: dict) -> dict | None:
219
  return {"changes": changes[:3]}
220
 
221
 
222
- def _extract_mirror_highlights(insights: dict, n: int = 3) -> list:
223
- """Pull the N most significant observations from a session's insights."""
224
- highlights = []
225
-
226
- if insights.get("notable_pattern"):
227
- highlights.append(insights["notable_pattern"])
228
-
229
- for s in insights.get("coaching_suggestions", []):
230
- if len(highlights) >= n:
231
- break
232
- issue = (s.get("issue") or "").strip()
233
- if issue and issue not in highlights:
234
- highlights.append(issue)
235
-
236
- for obs in insights.get("observations", []):
237
- if len(highlights) >= n:
238
- break
239
- text = (obs.get("observation") or "").strip()
240
- if text and text not in highlights:
241
- highlights.append(text)
242
-
243
- return highlights[:n]
244
 
245
 
246
  def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
@@ -259,23 +248,6 @@ def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
259
  except (KeyError, TypeError):
260
  pass
261
 
262
- # Extract 2-3 sample user quotes (5-20 words, spread across the conversation)
263
- sample_quotes = []
264
- merged = p.get("merged", [])
265
- detected_speaker = p.get("detected_speaker", "SPEAKER_00")
266
- if merged:
267
- user_turns = [
268
- seg.get("text", "").strip()
269
- for seg in merged
270
- if seg.get("speaker") == detected_speaker
271
- and 5 <= len(seg.get("text", "").split()) <= 20
272
- ]
273
- if len(user_turns) >= 3:
274
- mid = len(user_turns) // 2
275
- sample_quotes = [user_turns[0], user_turns[mid], user_turns[-1]]
276
- elif user_turns:
277
- sample_quotes = user_turns[:3]
278
-
279
  sessions.append({
280
  "date": date_str,
281
  "context": p["context"],
@@ -283,7 +255,7 @@ def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
283
  "filler_rate": p["sig"]["filler_words"]["rate_per_100_words"],
284
  "talk_ratio_pct": round(p["sig"]["talk_ratio"]["user_ratio"] * 100),
285
  "notable_pattern": p["ins"].get("notable_pattern", ""),
286
- "sample_quotes": sample_quotes,
287
  })
288
  return sessions
289
 
@@ -291,7 +263,7 @@ def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
291
  def _get_or_synthesize_personality(user_id: str, session_count: int,
292
  profile_data: dict, dim_averages: dict,
293
  sessions_data: list = None) -> dict:
294
- _SYNTHESIS_VERSION = 4 # bump when prompt changes to invalidate old cache
295
  cache_key = f"{user_id}:{session_count}:v{_SYNTHESIS_VERSION}"
296
 
297
  if cache_key in _personality_cache:
@@ -306,13 +278,17 @@ def _get_or_synthesize_personality(user_id: str, session_count: int,
306
  .eq("user_id", user_id).execute()
307
  if result.data:
308
  row = result.data[0]
309
- if row["session_count_at_synthesis"] == session_count * _SYNTHESIS_VERSION:
310
- personality = json.loads(row["personality_json"])
 
311
  _personality_cache[cache_key] = personality
312
  return personality
313
- else:
314
- # Session count changed β€” preserve old synthesis for delta
315
- old_personality = json.loads(row["personality_json"])
 
 
 
316
  except Exception:
317
  pass
318
 
@@ -356,7 +332,7 @@ async def get_current_user(authorization: Optional[str] = Header(None)) -> str:
356
 
357
  @app.get("/")
358
  def root():
359
- return {"status": "Behavioral Mirror API is running"}
360
 
361
 
362
  # ── SSE Step 1: Start prepare job ────────────────────────────────
@@ -383,6 +359,16 @@ async def start_prepare_session(
383
  )
384
  os.remove(temp_path)
385
 
 
 
 
 
 
 
 
 
 
 
386
  job_q: stdlib_queue.Queue = stdlib_queue.Queue()
387
  _jobs[session_id] = job_q
388
 
@@ -627,31 +613,34 @@ def _run_finalize_job(session_id, confirmed_speaker):
627
  if signals is None:
628
  signals = SignalExtractor(audio_path, merged, confirmed_speaker).extract_all()
629
 
630
- baseline = _get_context_baseline(user_id, primary_context)
631
- session_history = _get_user_session_history(user_id)
632
- resonance_calibration = _get_resonance_calibration(user_id)
633
-
634
  print(f"[{session_id}] Scoring dimensions...")
635
  emit("scoring", "Scoring behavioral dimensions…")
636
  dimensions = dimension_scorer.score_all(signals)
637
 
638
  print(f"[{session_id}] Detecting conversation type and generating insights...")
639
  emit("generating", "Generating insights with AI…")
640
- transcript_text = _sample_transcript(merged)
641
- conversation_types = context_detector.detect(transcript_text)
 
642
  primary_context = conversation_types[0]
643
  print(f"[{session_id}] Detected conversation types: {conversation_types}")
644
 
 
 
 
 
645
  insights = insight_gen.generate(
646
- signals, primary_context, baseline, transcript_text, dimensions,
647
  session_history=session_history,
648
  resonance_calibration=resonance_calibration,
649
  conversation_types=conversation_types,
650
  )
651
 
 
 
652
  emit("reflecting", "Generating reflection questions…")
653
  reflection_questions = insight_gen.generate_reflection_questions(
654
- signals, insights, transcript_text, primary_context
655
  )
656
  if reflection_questions:
657
  insights["reflection_questions"] = reflection_questions
@@ -661,10 +650,25 @@ def _run_finalize_job(session_id, confirmed_speaker):
661
  session_id, user_id, signals, insights, dimensions,
662
  primary_context, filename, confirmed_speaker,
663
  speaker_confirmed=True,
664
- merged=merged,
665
  all_speakers_signals=all_speakers_signals
666
  )
667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  if os.path.exists(audio_path):
669
  os.remove(audio_path)
670
  del _prepare_cache[session_id]
@@ -680,7 +684,6 @@ def _run_finalize_job(session_id, confirmed_speaker):
680
  "speaker_confirmed": True,
681
  "available_speakers": all_speaker_ids,
682
  "voiceprint_confidence": voiceprint_confidence,
683
- "transcript": merged,
684
  }
685
 
686
  if job_key in _jobs:
@@ -764,11 +767,10 @@ async def reanalyze_session(
764
  resonance_calibration = _get_resonance_calibration(user_id)
765
  dimensions = dimension_scorer.score_all(signals)
766
 
767
- merged = json.loads(session["merged_json"]) if session.get("merged_json") else []
768
- transcript_text = " ".join(
769
- f"{s.get('speaker', 'UNKNOWN')}: {s.get('text', '')}"
770
- for s in merged[:60]
771
- )
772
  insights = insight_gen.generate(
773
  signals, primary_context, baseline, transcript_text, dimensions,
774
  session_history=session_history,
@@ -776,6 +778,8 @@ async def reanalyze_session(
776
  conversation_types=conversation_types,
777
  )
778
 
 
 
779
  reflection_questions = insight_gen.generate_reflection_questions(
780
  signals, insights, transcript_text, primary_context
781
  )
@@ -788,6 +792,7 @@ async def reanalyze_session(
788
  "signals_json": json.dumps(signals),
789
  "insights_json": json.dumps(insights),
790
  "dimensions_json": json.dumps(dimensions),
 
791
  }).eq("id", session_id).execute()
792
 
793
  print(f"[{session_id}] Re-analysis done.")
@@ -800,7 +805,6 @@ async def reanalyze_session(
800
  "detected_speaker": confirmed_speaker,
801
  "speaker_confirmed": True,
802
  "available_speakers": list(all_speakers_signals.keys()),
803
- "transcript": merged,
804
  }
805
 
806
 
@@ -825,7 +829,7 @@ def get_sessions(user_id: str = Depends(get_current_user)):
825
  "dimensions": json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {},
826
  "available_speakers": list(json.loads(s["all_speakers_signals_json"]).keys())
827
  if s.get("all_speakers_signals_json") else [],
828
- "transcript": json.loads(s["merged_json"]) if s.get("merged_json") else [],
829
  }
830
  for s in res.data
831
  ]
@@ -873,14 +877,13 @@ def get_trends(user_id: str = Depends(get_current_user)):
873
  @app.get("/api/profile")
874
  def get_profile(user_id: str = Depends(get_current_user)):
875
  res = supabase_admin.table("sessions").select(
876
- "id, context, created_at, signals_json, dimensions_json, insights_json, merged_json, detected_speaker"
877
  ).eq("user_id", user_id).order("created_at", desc=False).execute()
878
 
879
- if len(res.data) < 3:
880
  return {
881
  "insufficient_data": True,
882
- "session_count": len(res.data),
883
- "sessions_needed": 3,
884
  }
885
 
886
  parsed = []
@@ -889,11 +892,11 @@ def get_profile(user_id: str = Depends(get_current_user)):
889
  sig = json.loads(s["signals_json"])
890
  dims = json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {}
891
  ins = json.loads(s["insights_json"])
892
- merged = json.loads(s["merged_json"]) if s.get("merged_json") else []
893
  parsed.append({
894
  "context": s["context"], "date": s["created_at"],
895
  "sig": sig, "dims": dims, "ins": ins,
896
- "merged": merged,
897
  "detected_speaker": s.get("detected_speaker") or "SPEAKER_00",
898
  })
899
  except Exception:
@@ -931,29 +934,30 @@ def get_profile(user_id: str = Depends(get_current_user)):
931
  "filler_rate": round(sum(p["sig"]["filler_words"]["rate_per_100_words"] for p in items) / len(items), 2),
932
  }
933
 
934
- # Pattern detection
935
  patterns = []
936
- talk_ratios = [p["sig"]["talk_ratio"]["user_ratio"] * 100 for p in parsed]
937
- high_talk = sum(1 for r in talk_ratios if r > 60)
938
- low_talk = sum(1 for r in talk_ratios if r < 40)
939
- if high_talk >= n * 0.7:
940
- patterns.append({"signal": "talk_ratio", "type": "consistently_high",
941
- "detail": f"You speak over 60% of the time in {high_talk}/{n} sessions."})
942
- elif low_talk >= n * 0.7:
943
- patterns.append({"signal": "talk_ratio", "type": "consistently_low",
944
- "detail": f"You speak under 40% of the time in {low_talk}/{n} sessions."})
945
-
946
- filler_rates = [p["sig"]["filler_words"]["rate_per_100_words"] for p in parsed]
947
- avg_filler = sum(filler_rates) / len(filler_rates)
948
- if avg_filler > 5:
949
- patterns.append({"signal": "filler_words", "type": "consistently_high",
950
- "detail": f"Average filler rate {round(avg_filler, 1)}/100 words across all sessions."})
951
-
952
- interruption_counts = [p["sig"]["interruptions"]["user_interrupted_other"] for p in parsed]
953
- avg_interrupts = sum(interruption_counts) / len(interruption_counts)
954
- if avg_interrupts >= 3:
955
- patterns.append({"signal": "interruptions", "type": "consistently_high",
956
- "detail": f"You average {round(avg_interrupts, 1)} interruptions per session."})
 
957
 
958
  # Trend detection across first vs last third (needs 6+ sessions)
959
  trends = []
@@ -1027,21 +1031,46 @@ def get_profile(user_id: str = Depends(get_current_user)):
1027
  elif completeness < 90: completeness_label = "Established"
1028
  else: completeness_label = "Deep mirror"
1029
 
1030
- # Mirror Feed: 3 highlights per session, last 7 sessions newest-first
1031
- session_highlights = []
1032
- for p in reversed(parsed[-7:]):
 
 
 
1033
  try:
1034
- from datetime import datetime as _dt2
1035
- date_str = _dt2.fromisoformat(p["date"].replace("Z", "+00:00")).strftime("%b %d")
 
 
 
 
 
1036
  except Exception:
1037
- date_str = "recent"
1038
- hl = _extract_mirror_highlights(p["ins"])
1039
- if hl:
1040
- session_highlights.append({
1041
- "date": date_str,
1042
  "context": p["context"],
1043
- "highlights": hl,
1044
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1045
 
1046
  return {
1047
  "insufficient_data": False,
@@ -1055,7 +1084,7 @@ def get_profile(user_id: str = Depends(get_current_user)):
1055
  "blind_spots": blind_spots,
1056
  "completeness": completeness,
1057
  "completeness_label": completeness_label,
1058
- "session_highlights": session_highlights,
1059
  }
1060
 
1061
 
@@ -1069,6 +1098,15 @@ async def delete_session(
1069
  ).execute()
1070
  if not res.data:
1071
  raise HTTPException(status_code=404, detail="Session not found.")
 
 
 
 
 
 
 
 
 
1072
  return {"status": "deleted"}
1073
 
1074
 
@@ -1296,37 +1334,40 @@ def _get_context_baseline(user_id: str, context: str) -> dict | None:
1296
  return None
1297
 
1298
 
1299
- def _get_user_session_history(user_id: str, limit: int = 10) -> list:
1300
- """Return last N sessions in chronological order for cross-session prompting."""
 
 
 
 
 
 
1301
  res = supabase_admin.table("sessions").select(
1302
- "id, context, created_at, signals_json, insights_json, dimensions_json"
1303
- ).eq("user_id", user_id).order("created_at", desc=True).limit(limit).execute()
1304
 
1305
- history = []
1306
  for s in reversed(res.data): # oldest first
1307
  try:
1308
- signals = json.loads(s["signals_json"])
1309
- insights = json.loads(s["insights_json"])
1310
- dimensions = json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {}
1311
- history.append({
1312
- "context": s["context"],
1313
- "date": s["created_at"][:10],
1314
- "summary": insights.get("summary_sentence", ""),
1315
- "signals": {
1316
- "talk_ratio_pct": round(signals["talk_ratio"]["user_ratio"] * 100, 1),
1317
- "wpm": signals["speech_rate"]["overall_wpm"],
1318
- "filler_rate": signals["filler_words"]["rate_per_100_words"],
1319
- "interruptions_given": signals["interruptions"]["user_interrupted_other"],
1320
- "silence_ratio_pct": round(signals["silence_ratio"]["silence_ratio"] * 100, 1),
1321
- },
1322
- "top_coaching_areas": [
1323
- c.get("area") for c in insights.get("coaching_suggestions", [])[:2]
1324
- if c.get("area")
1325
- ],
1326
- })
1327
  except Exception:
1328
  continue
1329
- return history
 
 
 
 
1330
 
1331
 
1332
  def _get_resonance_calibration(user_id: str) -> dict:
@@ -1352,9 +1393,131 @@ def _get_resonance_calibration(user_id: str) -> dict:
1352
  return {"avoid": avoid, "emphasize": emphasize}
1353
 
1354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1355
  def _sample_transcript(merged: list, max_segments: int = 60) -> str:
1356
- """Return a transcript string covering the full conversation.
1357
- If <= max_segments, use all. Otherwise take evenly-spaced thirds with no overlap."""
1358
  n = len(merged)
1359
  if n <= max_segments:
1360
  selected = merged
@@ -1379,7 +1542,7 @@ def _sample_transcript(merged: list, max_segments: int = 60) -> str:
1379
  def _save_session(
1380
  session_id, user_id, signals, insights, dimensions,
1381
  context, filename="recording", detected_speaker="SPEAKER_00",
1382
- speaker_confirmed=True, merged=None, all_speakers_signals=None
1383
  ):
1384
  supabase_admin.table("sessions").insert({
1385
  "id": session_id,
@@ -1391,6 +1554,6 @@ def _save_session(
1391
  "signals_json": json.dumps(signals),
1392
  "insights_json": json.dumps(insights),
1393
  "dimensions_json": json.dumps(dimensions),
1394
- "merged_json": json.dumps(merged) if merged else None,
1395
  "all_speakers_signals_json": json.dumps(all_speakers_signals) if all_speakers_signals else None,
1396
  }).execute()
 
19
  from pipeline.voiceprint import VoiceprintMatcher
20
  from pipeline.context_detector import ContextDetector
21
  from pipeline.personality_synthesizer import PersonalitySynthesizer
22
+ from pipeline.mirror_feed_synthesizer import MirrorFeedSynthesizer
23
  from db.database import supabase_admin
24
 
25
  app = FastAPI()
26
 
27
+ _ALLOWED_ORIGINS = [o for o in [
28
+ "http://localhost:5173",
29
+ "http://localhost:5174",
30
+ os.getenv("FRONTEND_URL", ""),
31
+ ] if o]
32
+
33
  app.add_middleware(
34
  CORSMiddleware,
35
+ allow_origins=_ALLOWED_ORIGINS,
36
+ allow_origin_regex=r"chrome-extension://.*",
37
+ allow_credentials=True,
38
  allow_methods=["*"],
39
+ allow_headers=["*"],
40
  )
41
 
42
+ UPLOAD_DIR = "/tmp/mirror"
43
  os.makedirs(UPLOAD_DIR, exist_ok=True)
44
 
45
  # In-memory store for prepare β†’ finalize handoff.
 
65
  del _prepare_cache[sid]
66
 
67
 
68
+ print("Loading models...")
69
+ transcriber = Transcriber(api_key=os.getenv("GROQ_API_KEY"))
70
  diarizer = Diarizer(hf_token=os.getenv("HF_TOKEN"))
71
  insight_gen = InsightGenerator(api_key=os.getenv("GROQ_API_KEY"))
72
  context_detector = ContextDetector(api_key=os.getenv("GROQ_API_KEY"))
73
  dimension_scorer = DimensionScorer()
74
  voiceprint_matcher = VoiceprintMatcher(hf_token=os.getenv("HF_TOKEN"))
75
  personality_synth = PersonalitySynthesizer(api_key=os.getenv("GROQ_API_KEY"))
76
+ mirror_feed_synth = MirrorFeedSynthesizer(api_key=os.getenv("GROQ_API_KEY"))
77
  print("All models loaded. Server ready.")
78
 
79
+ # In-memory caches β€” cleared on server restart, Supabase is the durable layer
80
+ _personality_cache: dict = {} # key = "{user_id}:{session_count}:v{version}"
81
+ _mirror_feed_cache: dict = {} # key = "{user_id}:{session_count}"
82
 
83
 
84
  CONTEXT_POPULATION_NORMS: dict = {
 
166
 
167
 
168
  _CONTEXT_BLIND_SPOTS: dict = {
169
+ "evaluative": (1, "Interview & Review Β· High Stakes",
170
+ "No interview, presentation, or review recordings yet. Your confidence and "
171
+ "composure scores are built from lower-stakes conversations β€” they may shift "
172
+ "significantly when you're being assessed."),
173
+ "adversarial": (2, "Conflict & Friction",
174
  "No conflict or disagreement recordings yet. We can't tell how you respond when "
175
  "challenged or when there's real friction in the room."),
176
+ "collaborative": (3, "Collaborative",
177
  "No team meetings or brainstorming sessions uploaded. Your listening and "
178
  "assertiveness scores come from 1-on-1 conversations only."),
179
+ "influential": (4, "Persuading & Pitching",
180
+ "No pitch or persuasion conversations yet. We can't see how you hold an argument "
181
+ "or move someone toward a decision."),
182
  "negotiation": (5, "Negotiation",
183
+ "No negotiation recordings yet. How your composure and assertiveness hold under "
184
  "competing interests is still unmeasured."),
185
+ "developmental": (6, "Coaching & Feedback",
186
  "No coaching or feedback sessions recorded. We can't see how you structure or "
187
+ "deliver guidance to others."),
188
+ "support": (7, "Supportive Listening",
189
  "No support conversations yet. Your empathy dimension has no direct evidence."),
190
+ "intimate": (8, "Deep Personal",
191
+ "No emotionally open conversations uploaded. Your expressiveness and warmth "
192
+ "scores may be incomplete."),
193
+ "social": (9, "Casual & Low-Stakes",
194
+ "No casual social conversations recorded. We can't see your natural, "
195
+ "low-pressure communication style yet."),
196
  }
197
 
198
 
 
230
  return {"changes": changes[:3]}
231
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
 
235
  def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
 
248
  except (KeyError, TypeError):
249
  pass
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  sessions.append({
252
  "date": date_str,
253
  "context": p["context"],
 
255
  "filler_rate": p["sig"]["filler_words"]["rate_per_100_words"],
256
  "talk_ratio_pct": round(p["sig"]["talk_ratio"]["user_ratio"] * 100),
257
  "notable_pattern": p["ins"].get("notable_pattern", ""),
258
+ "fingerprint": p.get("fingerprint") or "",
259
  })
260
  return sessions
261
 
 
263
  def _get_or_synthesize_personality(user_id: str, session_count: int,
264
  profile_data: dict, dim_averages: dict,
265
  sessions_data: list = None) -> dict:
266
+ _SYNTHESIS_VERSION = 5 # bump when prompt changes to invalidate old cache
267
  cache_key = f"{user_id}:{session_count}:v{_SYNTHESIS_VERSION}"
268
 
269
  if cache_key in _personality_cache:
 
278
  .eq("user_id", user_id).execute()
279
  if result.data:
280
  row = result.data[0]
281
+ raw_pj = row.get("personality_json")
282
+ if raw_pj and row.get("session_count_at_synthesis") == session_count * _SYNTHESIS_VERSION:
283
+ personality = json.loads(raw_pj)
284
  _personality_cache[cache_key] = personality
285
  return personality
286
+ elif raw_pj:
287
+ # Session count changed β€” preserve old synthesis for delta calculation
288
+ try:
289
+ old_personality = json.loads(raw_pj)
290
+ except Exception:
291
+ pass
292
  except Exception:
293
  pass
294
 
 
332
 
333
  @app.get("/")
334
  def root():
335
+ return {"status": "mirror. API is running"}
336
 
337
 
338
  # ── SSE Step 1: Start prepare job ────────────────────────────────
 
359
  )
360
  os.remove(temp_path)
361
 
362
+ import wave
363
+ with wave.open(audio_path, "r") as _wf:
364
+ _duration_s = _wf.getnframes() / _wf.getframerate()
365
+ if _duration_s > 1200:
366
+ os.remove(audio_path)
367
+ raise HTTPException(
368
+ status_code=400,
369
+ detail="Recording exceeds the 20-minute limit. Please trim and re-upload."
370
+ )
371
+
372
  job_q: stdlib_queue.Queue = stdlib_queue.Queue()
373
  _jobs[session_id] = job_q
374
 
 
613
  if signals is None:
614
  signals = SignalExtractor(audio_path, merged, confirmed_speaker).extract_all()
615
 
 
 
 
 
616
  print(f"[{session_id}] Scoring dimensions...")
617
  emit("scoring", "Scoring behavioral dimensions…")
618
  dimensions = dimension_scorer.score_all(signals)
619
 
620
  print(f"[{session_id}] Detecting conversation type and generating insights...")
621
  emit("generating", "Generating insights with AI…")
622
+ sample_text = _sample_transcript(merged)
623
+ full_text = _full_transcript(merged)
624
+ conversation_types = context_detector.detect(sample_text)
625
  primary_context = conversation_types[0]
626
  print(f"[{session_id}] Detected conversation types: {conversation_types}")
627
 
628
+ baseline = _get_context_baseline(user_id, primary_context)
629
+ session_history = _get_user_session_history(user_id)
630
+ resonance_calibration = _get_resonance_calibration(user_id)
631
+
632
  insights = insight_gen.generate(
633
+ signals, primary_context, baseline, full_text, dimensions,
634
  session_history=session_history,
635
  resonance_calibration=resonance_calibration,
636
  conversation_types=conversation_types,
637
  )
638
 
639
+ fingerprint = insights.pop("fingerprint", None)
640
+
641
  emit("reflecting", "Generating reflection questions…")
642
  reflection_questions = insight_gen.generate_reflection_questions(
643
+ signals, insights, full_text, primary_context
644
  )
645
  if reflection_questions:
646
  insights["reflection_questions"] = reflection_questions
 
650
  session_id, user_id, signals, insights, dimensions,
651
  primary_context, filename, confirmed_speaker,
652
  speaker_confirmed=True,
653
+ fingerprint=fingerprint,
654
  all_speakers_signals=all_speakers_signals
655
  )
656
 
657
+ # Trigger background consolidation if user has enough sessions
658
+ try:
659
+ count_res = supabase_admin.table("sessions").select(
660
+ "id", count="exact"
661
+ ).eq("user_id", user_id).execute()
662
+ session_count = count_res.count or 0
663
+ if session_count >= 12:
664
+ threading.Thread(
665
+ target=_maybe_consolidate,
666
+ args=(user_id, session_count),
667
+ daemon=True
668
+ ).start()
669
+ except Exception:
670
+ pass
671
+
672
  if os.path.exists(audio_path):
673
  os.remove(audio_path)
674
  del _prepare_cache[session_id]
 
684
  "speaker_confirmed": True,
685
  "available_speakers": all_speaker_ids,
686
  "voiceprint_confidence": voiceprint_confidence,
 
687
  }
688
 
689
  if job_key in _jobs:
 
767
  resonance_calibration = _get_resonance_calibration(user_id)
768
  dimensions = dimension_scorer.score_all(signals)
769
 
770
+ # Re-analysis uses existing fingerprint as transcript context if no merged_json
771
+ existing_fingerprint = json.loads(session["fingerprint_json"]) if session.get("fingerprint_json") else None
772
+ transcript_text = existing_fingerprint or ""
773
+
 
774
  insights = insight_gen.generate(
775
  signals, primary_context, baseline, transcript_text, dimensions,
776
  session_history=session_history,
 
778
  conversation_types=conversation_types,
779
  )
780
 
781
+ fingerprint = insights.pop("fingerprint", existing_fingerprint)
782
+
783
  reflection_questions = insight_gen.generate_reflection_questions(
784
  signals, insights, transcript_text, primary_context
785
  )
 
792
  "signals_json": json.dumps(signals),
793
  "insights_json": json.dumps(insights),
794
  "dimensions_json": json.dumps(dimensions),
795
+ "fingerprint_json": json.dumps(fingerprint) if fingerprint else None,
796
  }).eq("id", session_id).execute()
797
 
798
  print(f"[{session_id}] Re-analysis done.")
 
805
  "detected_speaker": confirmed_speaker,
806
  "speaker_confirmed": True,
807
  "available_speakers": list(all_speakers_signals.keys()),
 
808
  }
809
 
810
 
 
829
  "dimensions": json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {},
830
  "available_speakers": list(json.loads(s["all_speakers_signals_json"]).keys())
831
  if s.get("all_speakers_signals_json") else [],
832
+ "fingerprint": json.loads(s["fingerprint_json"]) if s.get("fingerprint_json") else None,
833
  }
834
  for s in res.data
835
  ]
 
877
  @app.get("/api/profile")
878
  def get_profile(user_id: str = Depends(get_current_user)):
879
  res = supabase_admin.table("sessions").select(
880
+ "id, context, created_at, signals_json, dimensions_json, insights_json, fingerprint_json, detected_speaker"
881
  ).eq("user_id", user_id).order("created_at", desc=False).execute()
882
 
883
+ if len(res.data) < 1:
884
  return {
885
  "insufficient_data": True,
886
+ "session_count": 0,
 
887
  }
888
 
889
  parsed = []
 
892
  sig = json.loads(s["signals_json"])
893
  dims = json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {}
894
  ins = json.loads(s["insights_json"])
895
+ fingerprint = json.loads(s["fingerprint_json"]) if s.get("fingerprint_json") else None
896
  parsed.append({
897
  "context": s["context"], "date": s["created_at"],
898
  "sig": sig, "dims": dims, "ins": ins,
899
+ "fingerprint": fingerprint,
900
  "detected_speaker": s.get("detected_speaker") or "SPEAKER_00",
901
  })
902
  except Exception:
 
934
  "filler_rate": round(sum(p["sig"]["filler_words"]["rate_per_100_words"] for p in items) / len(items), 2),
935
  }
936
 
937
+ # Pattern detection (needs 2+ sessions to establish consistency)
938
  patterns = []
939
+ if n >= 2:
940
+ talk_ratios = [p["sig"]["talk_ratio"]["user_ratio"] * 100 for p in parsed]
941
+ high_talk = sum(1 for r in talk_ratios if r > 60)
942
+ low_talk = sum(1 for r in talk_ratios if r < 40)
943
+ if high_talk >= n * 0.7:
944
+ patterns.append({"signal": "talk_ratio", "type": "consistently_high",
945
+ "detail": f"You speak over 60% of the time in {high_talk}/{n} sessions."})
946
+ elif low_talk >= n * 0.7:
947
+ patterns.append({"signal": "talk_ratio", "type": "consistently_low",
948
+ "detail": f"You speak under 40% of the time in {low_talk}/{n} sessions."})
949
+
950
+ filler_rates = [p["sig"]["filler_words"]["rate_per_100_words"] for p in parsed]
951
+ avg_filler = sum(filler_rates) / len(filler_rates)
952
+ if avg_filler > 5:
953
+ patterns.append({"signal": "filler_words", "type": "consistently_high",
954
+ "detail": f"Average filler rate {round(avg_filler, 1)}/100 words across all sessions."})
955
+
956
+ interruption_counts = [p["sig"]["interruptions"]["user_interrupted_other"] for p in parsed]
957
+ avg_interrupts = sum(interruption_counts) / len(interruption_counts)
958
+ if avg_interrupts >= 3:
959
+ patterns.append({"signal": "interruptions", "type": "consistently_high",
960
+ "detail": f"You average {round(avg_interrupts, 1)} interruptions per session."})
961
 
962
  # Trend detection across first vs last third (needs 6+ sessions)
963
  trends = []
 
1031
  elif completeness < 90: completeness_label = "Established"
1032
  else: completeness_label = "Deep mirror"
1033
 
1034
+ _MF_VERSION = 3 # bump when tip field or prompt changes to invalidate old cache
1035
+ mf_cache_key = f"{user_id}:{n}:v{_MF_VERSION}"
1036
+ mirror_feed = _mirror_feed_cache.get(mf_cache_key)
1037
+
1038
+ if mirror_feed is None:
1039
+ # Try Supabase cache
1040
  try:
1041
+ mf_res = supabase_admin.table("user_profiles").select(
1042
+ "mirror_feed_json, mirror_feed_session_count"
1043
+ ).eq("user_id", user_id).execute()
1044
+ if mf_res.data:
1045
+ row = mf_res.data[0]
1046
+ if row.get("mirror_feed_session_count") == n * _MF_VERSION and row.get("mirror_feed_json"):
1047
+ mirror_feed = json.loads(row["mirror_feed_json"])
1048
  except Exception:
1049
+ pass
1050
+
1051
+ if mirror_feed is None:
1052
+ feed_sessions = [
1053
+ {
1054
  "context": p["context"],
1055
+ "date": p["date"][:10],
1056
+ "fingerprint": p.get("fingerprint") or "",
1057
+ "notable_pattern": p["ins"].get("notable_pattern", ""),
1058
+ }
1059
+ for p in parsed
1060
+ ]
1061
+ user_summary = _get_user_summary(user_id)
1062
+ mirror_feed = mirror_feed_synth.synthesize(feed_sessions, user_summary=user_summary)
1063
+ try:
1064
+ supabase_admin.table("user_profiles").upsert({
1065
+ "user_id": user_id,
1066
+ "mirror_feed_json": json.dumps(mirror_feed),
1067
+ "mirror_feed_session_count": n * _MF_VERSION,
1068
+ "updated_at": _utcnow(),
1069
+ }).execute()
1070
+ except Exception:
1071
+ pass
1072
+
1073
+ _mirror_feed_cache[mf_cache_key] = mirror_feed
1074
 
1075
  return {
1076
  "insufficient_data": False,
 
1084
  "blind_spots": blind_spots,
1085
  "completeness": completeness,
1086
  "completeness_label": completeness_label,
1087
+ "mirror_feed": mirror_feed,
1088
  }
1089
 
1090
 
 
1098
  ).execute()
1099
  if not res.data:
1100
  raise HTTPException(status_code=404, detail="Session not found.")
1101
+
1102
+ # Evict all cached profile data for this user β€” session count has changed
1103
+ for key in list(_personality_cache.keys()):
1104
+ if key.startswith(f"{user_id}:"):
1105
+ del _personality_cache[key]
1106
+ for key in list(_mirror_feed_cache.keys()):
1107
+ if key.startswith(f"{user_id}:"):
1108
+ del _mirror_feed_cache[key]
1109
+
1110
  return {"status": "deleted"}
1111
 
1112
 
 
1334
  return None
1335
 
1336
 
1337
+ def _get_user_session_history(user_id: str, limit: int = 8) -> list:
1338
+ """Return session history for cross-session context.
1339
+ If a consolidated summary exists (12+ sessions), returns it + last 3 individual fingerprints.
1340
+ Otherwise returns last N individual fingerprints.
1341
+ """
1342
+ user_summary = _get_user_summary(user_id)
1343
+
1344
+ fetch_limit = 3 if user_summary else limit
1345
  res = supabase_admin.table("sessions").select(
1346
+ "context, created_at, fingerprint_json, insights_json"
1347
+ ).eq("user_id", user_id).order("created_at", desc=True).limit(fetch_limit).execute()
1348
 
1349
+ recent = []
1350
  for s in reversed(res.data): # oldest first
1351
  try:
1352
+ fingerprint = None
1353
+ if s.get("fingerprint_json"):
1354
+ fingerprint = json.loads(s["fingerprint_json"])
1355
+ if not fingerprint and s.get("insights_json"):
1356
+ ins = json.loads(s["insights_json"])
1357
+ fingerprint = ins.get("summary_sentence") or ins.get("conversation_summary")
1358
+ if fingerprint:
1359
+ recent.append({
1360
+ "context": s["context"],
1361
+ "date": s["created_at"][:10],
1362
+ "fingerprint": fingerprint,
1363
+ })
 
 
 
 
 
 
 
1364
  except Exception:
1365
  continue
1366
+
1367
+ if user_summary:
1368
+ return [{"context": "profile", "date": "consolidated",
1369
+ "fingerprint": f"CONSOLIDATED BEHAVIORAL PROFILE:\n{user_summary}"}] + recent
1370
+ return recent
1371
 
1372
 
1373
  def _get_resonance_calibration(user_id: str) -> dict:
 
1393
  return {"avoid": avoid, "emphasize": emphasize}
1394
 
1395
 
1396
+ # ── User summary consolidation ────────────────────────────────────────
1397
+
1398
+ def _generate_user_summary(fingerprints: list) -> str:
1399
+ """LLM call: consolidate all session fingerprints into one behavioral summary."""
1400
+ ctx_map: dict = {}
1401
+ for fp in fingerprints:
1402
+ ctx_map.setdefault(fp["context"], []).append(fp)
1403
+
1404
+ blocks = []
1405
+ for ctx, items in ctx_map.items():
1406
+ label = ctx.replace("_", " ").title()
1407
+ block = f"{label} ({len(items)} sessions):\n"
1408
+ for item in items:
1409
+ block += f" {item['date']}: {item['fingerprint']}\n"
1410
+ blocks.append(block)
1411
+
1412
+ prompt = f"""You are building a consolidated behavioral profile from {len(fingerprints)} recorded conversations.
1413
+
1414
+ SESSIONS BY CONTEXT:
1415
+ {''.join(blocks)}
1416
+
1417
+ TASK: Write a single comprehensive behavioral summary of this person β€” 400–600 words.
1418
+
1419
+ Cover:
1420
+ - Their core behavioral tendencies that appear regardless of context
1421
+ - How they show up differently across different types of conversations (if multiple contexts exist)
1422
+ - Recurring strengths β€” what they do consistently well
1423
+ - Recurring blind spots or development areas that appear across multiple sessions
1424
+ - How their behavior has evolved from their earliest sessions to their most recent ones
1425
+
1426
+ RULES:
1427
+ 1. Be specific β€” reference what was actually observed, not generic descriptions
1428
+ 2. Write in third person ("this person", "they", "their") β€” this is a reference document, not a direct address
1429
+ 3. Do not use raw numbers β€” translate signals into behavioral descriptions
1430
+ 4. Be honest about both strengths and gaps
1431
+ 5. Output plain text only β€” no JSON, no markdown headers, no bullet points"""
1432
+
1433
+ try:
1434
+ response = personality_synth.client.chat.completions.create(
1435
+ model="llama-3.3-70b-versatile",
1436
+ messages=[{"role": "user", "content": prompt}],
1437
+ temperature=0.3,
1438
+ max_tokens=900,
1439
+ )
1440
+ return response.choices[0].message.content.strip()
1441
+ except Exception:
1442
+ return ""
1443
+
1444
+
1445
+ def _maybe_consolidate(user_id: str, session_count: int) -> None:
1446
+ """Run in background thread. Consolidates fingerprints into user_summary_json
1447
+ at 12+ sessions, then refreshes every 5 new sessions."""
1448
+ if session_count < 12:
1449
+ return
1450
+ try:
1451
+ profile_res = supabase_admin.table("user_profiles").select(
1452
+ "user_summary_json, summary_session_count"
1453
+ ).eq("user_id", user_id).execute()
1454
+
1455
+ existing = profile_res.data[0] if profile_res.data else {}
1456
+ last_count = existing.get("summary_session_count") or 0
1457
+
1458
+ # Skip if summary is recent enough (within 5 sessions)
1459
+ if existing.get("user_summary_json") and (session_count - last_count) < 5:
1460
+ return
1461
+
1462
+ fp_res = supabase_admin.table("sessions").select(
1463
+ "context, created_at, fingerprint_json"
1464
+ ).eq("user_id", user_id).order("created_at", desc=False).execute()
1465
+
1466
+ fingerprints = []
1467
+ for s in fp_res.data:
1468
+ if s.get("fingerprint_json"):
1469
+ try:
1470
+ fp = json.loads(s["fingerprint_json"])
1471
+ if fp:
1472
+ fingerprints.append({
1473
+ "context": s["context"],
1474
+ "date": s["created_at"][:10],
1475
+ "fingerprint": fp,
1476
+ })
1477
+ except Exception:
1478
+ continue
1479
+
1480
+ if len(fingerprints) < 10:
1481
+ return
1482
+
1483
+ summary = _generate_user_summary(fingerprints)
1484
+ if not summary:
1485
+ return
1486
+
1487
+ supabase_admin.table("user_profiles").upsert({
1488
+ "user_id": user_id,
1489
+ "user_summary_json": summary,
1490
+ "summary_session_count": session_count,
1491
+ }).execute()
1492
+ print(f"[consolidation] Updated user summary for {user_id} at {session_count} sessions.")
1493
+ except Exception as e:
1494
+ print(f"[consolidation] Failed for {user_id}: {e}")
1495
+
1496
+
1497
+ def _get_user_summary(user_id: str) -> str | None:
1498
+ """Return consolidated behavioral summary if it exists."""
1499
+ try:
1500
+ res = supabase_admin.table("user_profiles").select(
1501
+ "user_summary_json"
1502
+ ).eq("user_id", user_id).execute()
1503
+ if res.data and res.data[0].get("user_summary_json"):
1504
+ return res.data[0]["user_summary_json"]
1505
+ except Exception:
1506
+ pass
1507
+ return None
1508
+
1509
+
1510
+ def _full_transcript(merged: list) -> str:
1511
+ """Format complete diarized transcript as speaker-labeled lines."""
1512
+ return "\n".join(
1513
+ f"{s.get('speaker', 'UNKNOWN')}: {s.get('text', '').strip()}"
1514
+ for s in merged
1515
+ if s.get("text", "").strip()
1516
+ )
1517
+
1518
+
1519
  def _sample_transcript(merged: list, max_segments: int = 60) -> str:
1520
+ """Short transcript sample β€” used only for context detection."""
 
1521
  n = len(merged)
1522
  if n <= max_segments:
1523
  selected = merged
 
1542
  def _save_session(
1543
  session_id, user_id, signals, insights, dimensions,
1544
  context, filename="recording", detected_speaker="SPEAKER_00",
1545
+ speaker_confirmed=True, fingerprint=None, all_speakers_signals=None
1546
  ):
1547
  supabase_admin.table("sessions").insert({
1548
  "id": session_id,
 
1554
  "signals_json": json.dumps(signals),
1555
  "insights_json": json.dumps(insights),
1556
  "dimensions_json": json.dumps(dimensions),
1557
+ "fingerprint_json": json.dumps(fingerprint) if fingerprint else None,
1558
  "all_speakers_signals_json": json.dumps(all_speakers_signals) if all_speakers_signals else None,
1559
  }).execute()
backend/pipeline/insight_generator.py CHANGED
@@ -1,60 +1,44 @@
1
  import json
2
  from groq import Groq
3
 
 
4
  CONTEXT_COACHING_GUIDE = {
5
  "social": (
6
- "This is a casual, low-stakes social conversation. There are no strict norms to measure "
7
- "against. Focus on what was genuinely notable β€” rapport, warmth, and reciprocal engagement "
8
- "matter most. Avoid applying professional communication standards."
9
  ),
10
  "collaborative": (
11
- "This is a collaborative working conversation (meeting, brainstorming, planning). "
12
- "Prioritize: balanced airtime, turn-taking, question quality, and whether one person "
13
- "dominated. Mutual engagement and shared goal orientation matter. Long silences may "
14
- "indicate disengagement."
15
  ),
16
  "evaluative": (
17
- "This is an evaluative conversation β€” interview, presentation, performance review, or "
18
- "assessment. Prioritize: confidence signals, response latency, conciseness. Flag long "
19
- "monologues β€” evaluators expect dialogue, not lectures. Low filler rate matters here "
20
- "more than in casual contexts. Concise, direct answers signal competence."
21
  ),
22
  "influential": (
23
- "This is a persuasion or influence conversation (sales, pitch, convincing someone). "
24
- "Prioritize: whether the other person was engaged or passive, question quality (do "
25
- "questions draw out needs?), and clarity of the core argument. Monologuing is a red "
26
- "flag β€” real influence requires dialogue, not broadcasting."
27
  ),
28
  "negotiation": (
29
- "This is a negotiation β€” competing interests seeking compromise. Prioritize: balance of "
30
- "airtime, interruption patterns, response latency (strategic pauses matter here), and "
31
- "whether both parties had space to present their position. High interruptions and rushed "
32
- "responses signal a power struggle rather than a deal-making mindset."
33
  ),
34
  "adversarial": (
35
- "This is a conflict or disagreement conversation. Prioritize: listening quality, "
36
- "interruption patterns, talk ratio balance, response latency. A very high talk ratio "
37
- "by one person usually signals the other isn't being heard. Frequent interruptions "
38
- "escalate tension. Long response latency can indicate careful processing or withdrawal."
39
  ),
40
  "developmental": (
41
- "This is a coaching, mentoring, or feedback conversation. Prioritize: question quality "
42
- "(do questions provoke genuine reflection?), listening-to-talking balance, and whether "
43
- "the person being developed had space to think. The coach or mentor should talk less, "
44
- "not more β€” high talk ratio by the coach is often a red flag."
45
  ),
46
  "support": (
47
- "This is an emotional support or empathy-led conversation. A high talk ratio by the "
48
- "listener is a red flag β€” support requires presence, not performance. Prioritize: "
49
- "listening ratio, response latency (rushing to respond prevents real empathy), question "
50
- "quality (open vs. closed), and whether the supported person felt genuinely heard."
51
  ),
52
  "intimate": (
53
- "This is a psychologically intimate conversation β€” deep personal sharing, mutual "
54
- "vulnerability, or emotional connection. Prioritize: balance of vulnerability between "
55
- "both speakers, response latency (thoughtful pauses signal processing, not disengagement), "
56
- "interruption patterns (interruptions feel like ruptures here), and whether both people "
57
- "had equal space to go deep."
58
  ),
59
  }
60
 
@@ -71,22 +55,18 @@ class InsightGenerator:
71
  prompt = self._build_prompt(
72
  structured_input,
73
  transcript_text,
74
- signals.get("notable_signals", []),
75
- dimensions or {},
76
  context,
77
  user_baseline,
78
  session_history or [],
79
  resonance_calibration or {},
80
  conversation_types or [context],
81
  )
82
-
83
  response = self.client.chat.completions.create(
84
  model="llama-3.3-70b-versatile",
85
  messages=[{"role": "user", "content": prompt}],
86
  temperature=0.3,
87
- max_tokens=2500
88
  )
89
-
90
  return self._parse_output(response.choices[0].message.content)
91
 
92
  def _prepare_input(self, signals: dict, context: str, baseline: dict) -> dict:
@@ -95,24 +75,14 @@ class InsightGenerator:
95
  "session_duration_minutes": round(signals["session_duration_s"] / 60, 1),
96
  "talk_ratio_user": signals["talk_ratio"]["user_ratio"],
97
  "speech_rate_wpm": signals["speech_rate"]["overall_wpm"],
98
- "speech_rate_variability": signals["speech_rate"]["variability"],
99
- "avg_response_latency_s": signals["pauses"]["response_latency"]["mean_s"],
100
- "within_turn_pause_count": signals["pauses"]["within_turn_pauses"]["count"],
101
- "user_interrupted_other": signals["interruptions"]["user_interrupted_other"],
102
- "user_was_interrupted": signals["interruptions"]["user_was_interrupted"],
103
  "filler_rate_per_100_words": signals["filler_words"]["rate_per_100_words"],
104
  "top_filler_words": list(signals["filler_words"]["breakdown"].keys())[:3],
105
- "pitch_mean_hz": signals["pitch_features"].get("mean_hz"),
106
- "pitch_std_hz": signals["pitch_features"].get("std_hz"),
107
- "vocal_energy_trend": signals["vocal_energy"].get("trend"),
108
- "speech_acceleration_trend": signals["speech_acceleration"].get("trend"),
109
- "speech_delta_wpm": signals["speech_acceleration"].get("delta_wpm"),
110
  "user_questions_asked": signals["questions"]["user_questions_asked"],
111
  "other_questions_asked": signals["questions"]["other_questions_asked"],
112
- "longest_monologue_s": signals["monologue"]["longest_turn_s"],
113
- "vocabulary_richness_ttr": signals["vocabulary_richness"]["type_token_ratio"],
114
- "silence_ratio": signals["silence_ratio"]["silence_ratio"],
115
- "crosstalk_ratio": signals["crosstalk"]["crosstalk_ratio"],
116
  }
117
 
118
  if baseline:
@@ -138,18 +108,12 @@ class InsightGenerator:
138
  "context_avg": round(baseline["avg_filler_rate"], 2),
139
  "delta_pct": _delta_pct(prepared["filler_rate_per_100_words"], baseline["avg_filler_rate"]),
140
  },
141
- "response_latency": {
142
- "current": prepared["avg_response_latency_s"],
143
- "context_avg": round(baseline["avg_response_latency_s"], 2),
144
- "delta_pct": _delta_pct(prepared["avg_response_latency_s"], baseline["avg_response_latency_s"]),
145
- },
146
  "talk_ratio": {
147
  "current": prepared["talk_ratio_user"],
148
  "context_avg": round(baseline["avg_talk_ratio"], 3),
149
  "delta_pp": round(prepared["talk_ratio_user"] - baseline["avg_talk_ratio"], 3),
150
  },
151
  }
152
-
153
  elif source == "population_norm":
154
  norms = baseline["norms"]
155
 
@@ -159,192 +123,160 @@ class InsightGenerator:
159
  lo, hi, note = norms[key]
160
  return (lo <= val <= hi), (round(lo * scale), round(hi * scale)), note
161
 
162
- tr_in, tr_range, tr_note = _norm_check(prepared["talk_ratio_user"], "avg_talk_ratio", scale=100)
163
- fr_in, fr_range, fr_note = _norm_check(prepared["filler_rate_per_100_words"], "avg_filler_rate")
164
- rl_in, rl_range, rl_note = _norm_check(prepared["avg_response_latency_s"], "avg_response_latency_s")
165
- wpm_in, wpm_range, wpm_note = _norm_check(prepared["speech_rate_wpm"], "avg_speech_rate_wpm")
166
 
167
  prepared["baseline_comparison"] = {
168
  "type": "population_norm",
169
  "context": baseline["context"],
170
- "talk_ratio": {"current_pct": round(prepared["talk_ratio_user"] * 100), "expected_range": tr_range, "within_norm": tr_in, "note": tr_note},
171
- "filler_rate": {"current": prepared["filler_rate_per_100_words"], "expected_range": fr_range, "within_norm": fr_in, "note": fr_note},
172
- "response_latency": {"current": prepared["avg_response_latency_s"], "expected_range": rl_range, "within_norm": rl_in, "note": rl_note},
173
- "speech_rate": {"current": prepared["speech_rate_wpm"], "expected_range": wpm_range, "within_norm": wpm_in, "note": wpm_note},
174
  }
175
 
176
  return prepared
177
 
178
  def _build_prompt(self, data: dict, transcript_text: str,
179
- notable_signals: list, dimensions: dict,
180
  context: str, baseline: dict,
181
- session_history: list = None,
182
- resonance_calibration: dict = None,
183
- conversation_types: list = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
- # ── Baseline section ──────────────────────────────────────
186
  baseline_section = ""
187
  if "baseline_comparison" in data:
188
  b = data["baseline_comparison"]
189
  btype = b.get("type")
190
-
191
- def _fmt_delta(delta, unit=""):
192
- if delta is None:
193
- return "no change"
194
- sign = "+" if delta > 0 else ""
195
- return f"{sign}{delta}{unit}"
196
-
197
  if btype == "personal_context":
198
  n = b["session_count"]
199
  ctx = b["context"].replace("_", " ")
200
- sr, fr, rl, tr = b["speech_rate"], b["filler_rate"], b["response_latency"], b["talk_ratio"]
 
 
 
 
 
 
201
  baseline_section = f"""
202
- YOUR {ctx.upper()} BASELINE (from your {n} previous {ctx} sessions):
203
- REQUIRED: reference at least 2 of these comparisons in your observations or coaching.
204
- Speech rate: {sr['current']} wpm vs your {ctx} avg {sr['context_avg']} wpm ({_fmt_delta(sr['delta_pct'], '%')})
205
- Filler rate: {fr['current']}/100w vs your {ctx} avg {fr['context_avg']}/100w ({_fmt_delta(fr['delta_pct'], '%')})
206
- Response latency: {rl['current']}s vs your {ctx} avg {rl['context_avg']}s ({_fmt_delta(rl['delta_pct'], '%')})
207
- Talk ratio: {round(tr['current']*100)}% vs your {ctx} avg {round(tr['context_avg']*100)}% ({_fmt_delta(tr['delta_pp']*100, 'pp')})
208
  """
209
-
210
  elif btype == "population_norm":
211
  ctx = b["context"].replace("_", " ")
212
- tr, fr, rl, sr = b["talk_ratio"], b["filler_rate"], b["response_latency"], b["speech_rate"]
213
-
214
- def _norm_line(label, val_str, entry, range_unit=""):
215
- if entry["expected_range"] is None:
216
- return ""
217
- lo, hi = entry["expected_range"]
218
- mark = "βœ“" if entry["within_norm"] else "βœ—"
219
- note = f" β€” {entry['note']}" if entry["note"] else ""
220
- return f" {label}: {val_str} (expected {lo}–{hi}{range_unit}) {mark}{note}"
221
-
222
- lines = [
223
- _norm_line("Talk ratio", f"{tr['current_pct']}%", tr, "%"),
224
- _norm_line("Filler rate", f"{fr['current']}/100w", fr, "/100w"),
225
- _norm_line("Response latency", f"{rl['current']}s", rl, "s"),
226
- _norm_line("Speech rate", f"{sr['current']} wpm", sr, " wpm"),
227
- ]
228
- lines = [l for l in lines if l]
229
-
230
- baseline_section = f"""
231
- CONTEXT NORMS FOR {ctx.upper()} CONVERSATIONS:
232
- IMPORTANT: Do NOT flag βœ“ signals as issues β€” they are within the expected range for this context.
233
- Coach only on βœ— signals. Adjust all observations and suggestions accordingly.
234
  {chr(10).join(lines)}
235
  """
236
 
237
- # ── Session history ───────────────────────────────────────
238
  history_section = ""
239
  if session_history:
240
  n = len(session_history)
241
- lines = [
242
- f" Session {i+1} ({s['date']}, {s['context'].replace('_',' ')}): "
243
- f"talk={s['signals']['talk_ratio_pct']}%, "
244
- f"wpm={s['signals']['wpm']}, "
245
- f"fillers={s['signals']['filler_rate']}/100w, "
246
- f"interruptions={s['signals']['interruptions_given']} β€” "
247
- f"\"{s['summary']}\""
248
- + (f" | coached on: {', '.join(s['top_coaching_areas'])}" if s['top_coaching_areas'] else "")
249
- for i, s in enumerate(session_history)
250
- ]
251
- from collections import Counter
252
- all_areas = [a for s in session_history for a in s.get("top_coaching_areas", [])]
253
- repeated = [area for area, cnt in Counter(all_areas).most_common() if cnt >= 2]
254
- repeat_note = (
255
- f"\n RECURRING ISSUES (flagged in multiple sessions): {', '.join(repeated)}"
256
- if repeated else ""
257
- )
258
- history_section = f"""
259
- SESSION HISTORY β€” this is session {n + 1} for this user.
260
- REQUIRED: mention at least one thing that changed or stayed the same vs previous sessions.
261
- {chr(10).join(lines)}{repeat_note}
262
  """
263
 
264
- # ── Resonance calibration ─────────────────────────────────
265
  resonance_section = ""
266
  if resonance_calibration:
267
  avoid = resonance_calibration.get("avoid", [])
268
  emphasize = resonance_calibration.get("emphasize", [])
269
  parts = []
270
  if avoid:
271
- parts.append(f" AVOID making strong claims about: {', '.join(avoid)}")
272
  if emphasize:
273
  parts.append(f" PRIORITIZE observations about: {', '.join(emphasize)}")
274
  if parts:
275
  resonance_section = "\nUSER FEEDBACK CALIBRATION:\n" + "\n".join(parts) + "\n"
276
 
277
- # ── Context-specific coaching guide ───────────────────────
278
- coaching_guide = CONTEXT_COACHING_GUIDE.get(context, CONTEXT_COACHING_GUIDE["social"])
279
-
280
- # ── Notable signals ───────────────────────────────────────
281
- notable_section = ""
282
- if notable_signals:
283
- notable_section = f"""
284
- MOST NOTABLE SIGNALS (build your observations around these first):
285
- {json.dumps(notable_signals, indent=2)}
286
- """
287
-
288
- # ── Dimensions ────────────────────────────────────────────
289
- dimensions_section = ""
290
- if dimensions:
291
- dimensions_section = f"""
292
- BEHAVIORAL DIMENSION SCORES (1=lowest, 5=highest):
293
- {json.dumps(dimensions, indent=2)}
294
- """
295
-
296
- # ── Transcript ────────────────────────────────────────────
297
- transcript_section = ""
298
- if transcript_text:
299
- transcript_section = f"""
300
- CONVERSATION TRANSCRIPT (partial β€” quote from this where relevant):
301
- {transcript_text}
302
- """
303
-
304
- types_str = ", ".join(conversation_types or [context])
305
-
306
- return f"""You are a behavioral communication coach analyzing a real conversation.
307
- Generate a specific, honest coaching report. Write directly to the user ("you", "your").
308
-
309
- CONVERSATION TYPES: {types_str}
310
- DURATION: {data['session_duration_minutes']} minutes
311
- {baseline_section}{history_section}{resonance_section}{notable_section}{dimensions_section}
312
- ALL SESSION DATA:
313
- {json.dumps({k: v for k, v in data.items() if k != "baseline_comparison"}, indent=2)}
314
- {transcript_section}
315
- CONTEXT-SPECIFIC COACHING GUIDE (primary type: {context}):
316
- {coaching_guide}
317
-
318
- LANGUAGE RULES β€” follow these precisely:
319
- 1. FACTS (measurements) β†’ always direct, no hedging.
320
- 2. BEHAVIORAL INTERPRETATIONS β†’ hedge roughly half the time for genuinely uncertain inferences.
321
- 3. PSYCHOLOGICAL / EMOTIONAL CLAIMS β†’ always hedge.
322
- 4. COACHING SUGGESTIONS β†’ always direct. No hedging.
323
- 5. Do not default to talk_ratio, speech_rate, or pauses unless they appear in MOST NOTABLE SIGNALS.
324
- 6. Every observation must reference a specific value from the data.
325
- 7. Quote from the transcript when it illustrates your point.
326
- 8. Never use "SPEAKER_00" or "SPEAKER_01" β€” always "you" and "the other person".
327
- 9. Output valid JSON only β€” no markdown, no code fences, no extra text.
328
 
329
  Output this exact JSON:
330
  {{
331
- "conversation_types": {json.dumps(conversation_types or [context])},
332
- "conversation_summary": "3-4 sentences. What was this conversation actually about? What happened? Reference specific things said. What was the overall tone and dynamic?",
333
- "summary_sentence": "One direct sentence on the overall communication pattern.",
334
  "observations": [
335
  {{
336
  "signal": "signal_name",
337
- "observation": "Specific observation grounded in an actual value.",
338
- "resonance_prompt": "A genuine reflective question."
339
  }},
340
  {{
341
- "signal": "different_signal_name",
342
- "observation": "Different observation on a different signal.",
343
- "resonance_prompt": "Different reflective question."
344
  }},
345
  {{
346
- "signal": "another_signal_name",
347
- "observation": "Third observation on a third signal.",
348
  "resonance_prompt": "Third reflective question."
349
  }}
350
  ],
@@ -352,14 +284,14 @@ Output this exact JSON:
352
  {{
353
  "priority": 1,
354
  "area": "area name",
355
- "issue": "Specific issue with actual values.",
356
  "suggestion": "Exactly what to do differently next time.",
357
- "why_it_matters": "Why this change improves communication in this type of conversation."
358
  }},
359
  {{
360
  "priority": 2,
361
  "area": "different area",
362
- "issue": "Second issue.",
363
  "suggestion": "Second actionable suggestion.",
364
  "why_it_matters": "Why it matters."
365
  }},
@@ -371,19 +303,14 @@ Output this exact JSON:
371
  "why_it_matters": "Why it matters."
372
  }}
373
  ],
374
- "dimension_narrative": {{
375
- "emotional_state": "2 sentences on emotional state scores for this person in this conversation.",
376
- "relational_dynamics": "2 sentences on how the two speakers connected or didn't.",
377
- "communication_effectiveness": "2 sentences on how effectively they communicated.",
378
- "conversation_arc": "2 sentences on how the conversation evolved."
379
- }},
380
- "notable_pattern": "The single most interesting behavioral pattern from this session. One sentence.",
381
  "data_confidence": "high"
382
  }}"""
383
 
384
  def generate_reflection_questions(self, signals: dict, insights: dict,
385
  transcript_text: str, context: str) -> list:
386
- prompt = self._build_reflection_prompt(signals, insights, transcript_text, context)
387
  try:
388
  response = self.client.chat.completions.create(
389
  model="llama-3.3-70b-versatile",
@@ -395,48 +322,45 @@ Output this exact JSON:
395
  except Exception:
396
  return []
397
 
398
- def _build_reflection_prompt(self, signals: dict, insights: dict,
399
- transcript_text: str, context: str) -> str:
400
- coaching_areas = [c.get("area", "") for c in insights.get("coaching_suggestions", [])[:3]]
401
  observations_text = "\n".join(
402
  f"- {o['observation']}" for o in insights.get("observations", [])[:3]
403
  )
404
- talkpct = round(signals.get("talk_ratio", {}).get("user_ratio", 0.5) * 100)
405
- fillers = signals.get("filler_words", {}).get("rate_per_100_words", 0)
406
 
407
- return f"""You are a behavioral coach. A person just finished a {context.replace("_", " ")} conversation.
408
- Generate 3 reflection questions grounded in what actually happened in this session.
409
 
410
- Session data:
411
- - Talk ratio: {talkpct}% (you spoke)
412
- - Filler rate: {fillers}/100 words
413
- - Coaching areas flagged: {", ".join(coaching_areas)}
414
 
415
  Key observations:
416
  {observations_text}
417
 
 
 
418
  Transcript sample:
419
- {transcript_text[:600] if transcript_text else "Not available"}
420
 
421
  Rules:
422
- 1. Each question must be specific to THIS conversation β€” not generic self-improvement advice.
423
- 2. The answer explains what the data reveals and what it might mean. 2-3 sentences.
424
- 3. Reference specific moments or patterns from the data/transcript where possible.
425
- 4. Write directly to the person ("you", "your").
 
426
 
427
- Output valid JSON only β€” no markdown, no code fences:
428
  [
429
  {{
430
- "question": "A specific, non-generic reflection question grounded in this conversation.",
431
- "answer": "2-3 sentences explaining what the data shows and what it might mean. Reference something specific."
432
  }},
433
  {{
434
- "question": "Second reflection question addressing a different aspect.",
435
- "answer": "2-3 sentences on what the data reveals."
436
  }},
437
  {{
438
  "question": "Third question β€” surface something they might not have noticed.",
439
- "answer": "2-3 sentences on the insight."
440
  }}
441
  ]"""
442
 
@@ -464,12 +388,11 @@ Output valid JSON only β€” no markdown, no code fences:
464
  return json.loads(clean.strip())
465
  except Exception:
466
  return {
467
- "conversation_types": ["social"],
468
- "conversation_summary": "Session analyzed successfully.",
469
- "summary_sentence": "Session analyzed successfully.",
470
  "observations": [],
471
  "coaching_suggestions": [],
472
- "dimension_narrative": {},
473
  "notable_pattern": None,
474
- "data_confidence": "low"
 
475
  }
 
1
  import json
2
  from groq import Groq
3
 
4
+
5
  CONTEXT_COACHING_GUIDE = {
6
  "social": (
7
+ "Casual, low-stakes social conversation. Focus on rapport, warmth, and reciprocal "
8
+ "engagement. Avoid applying professional communication standards."
 
9
  ),
10
  "collaborative": (
11
+ "Collaborative working conversation (meeting, brainstorming, planning). "
12
+ "Balanced airtime, turn-taking, and mutual engagement matter most."
 
 
13
  ),
14
  "evaluative": (
15
+ "Evaluative conversation β€” interview, presentation, or assessment. "
16
+ "Confidence, directness, and concise responses signal competence. "
17
+ "Evaluators expect dialogue, not lectures."
 
18
  ),
19
  "influential": (
20
+ "Persuasion or influence conversation (sales, pitch). Real influence "
21
+ "requires dialogue β€” monologuing is a red flag. Questions should draw out needs."
 
 
22
  ),
23
  "negotiation": (
24
+ "Negotiation β€” competing interests seeking compromise. Balanced airtime, "
25
+ "strategic pauses, and space for both sides to present their position matter."
 
 
26
  ),
27
  "adversarial": (
28
+ "Conflict or disagreement. Listening quality and interruption patterns are "
29
+ "critical. High talk ratio by one person usually means the other isn't being heard."
 
 
30
  ),
31
  "developmental": (
32
+ "Coaching, mentoring, or feedback conversation. The coach should talk less, "
33
+ "not more. Question quality and space for reflection matter most."
 
 
34
  ),
35
  "support": (
36
+ "Emotional support conversation. Presence over performance β€” rushing to respond "
37
+ "prevents real empathy. Listening ratio and open questions are key."
 
 
38
  ),
39
  "intimate": (
40
+ "Psychologically intimate conversation. Balance of vulnerability between both "
41
+ "speakers, thoughtful pauses, and no interruptions matter deeply."
 
 
 
42
  ),
43
  }
44
 
 
55
  prompt = self._build_prompt(
56
  structured_input,
57
  transcript_text,
 
 
58
  context,
59
  user_baseline,
60
  session_history or [],
61
  resonance_calibration or {},
62
  conversation_types or [context],
63
  )
 
64
  response = self.client.chat.completions.create(
65
  model="llama-3.3-70b-versatile",
66
  messages=[{"role": "user", "content": prompt}],
67
  temperature=0.3,
68
+ max_tokens=3000,
69
  )
 
70
  return self._parse_output(response.choices[0].message.content)
71
 
72
  def _prepare_input(self, signals: dict, context: str, baseline: dict) -> dict:
 
75
  "session_duration_minutes": round(signals["session_duration_s"] / 60, 1),
76
  "talk_ratio_user": signals["talk_ratio"]["user_ratio"],
77
  "speech_rate_wpm": signals["speech_rate"]["overall_wpm"],
 
 
 
 
 
78
  "filler_rate_per_100_words": signals["filler_words"]["rate_per_100_words"],
79
  "top_filler_words": list(signals["filler_words"]["breakdown"].keys())[:3],
80
+ "user_interrupted_other": signals["interruptions"]["user_interrupted_other"],
81
+ "user_was_interrupted": signals["interruptions"]["user_was_interrupted"],
82
+ "longest_monologue_s": signals["monologue"]["longest_turn_s"],
 
 
83
  "user_questions_asked": signals["questions"]["user_questions_asked"],
84
  "other_questions_asked": signals["questions"]["other_questions_asked"],
85
+ "avg_response_latency_s": signals["pauses"]["response_latency"]["mean_s"],
 
 
 
86
  }
87
 
88
  if baseline:
 
108
  "context_avg": round(baseline["avg_filler_rate"], 2),
109
  "delta_pct": _delta_pct(prepared["filler_rate_per_100_words"], baseline["avg_filler_rate"]),
110
  },
 
 
 
 
 
111
  "talk_ratio": {
112
  "current": prepared["talk_ratio_user"],
113
  "context_avg": round(baseline["avg_talk_ratio"], 3),
114
  "delta_pp": round(prepared["talk_ratio_user"] - baseline["avg_talk_ratio"], 3),
115
  },
116
  }
 
117
  elif source == "population_norm":
118
  norms = baseline["norms"]
119
 
 
123
  lo, hi, note = norms[key]
124
  return (lo <= val <= hi), (round(lo * scale), round(hi * scale)), note
125
 
126
+ tr_in, tr_range, _ = _norm_check(prepared["talk_ratio_user"], "avg_talk_ratio", scale=100)
127
+ fr_in, fr_range, _ = _norm_check(prepared["filler_rate_per_100_words"], "avg_filler_rate")
 
 
128
 
129
  prepared["baseline_comparison"] = {
130
  "type": "population_norm",
131
  "context": baseline["context"],
132
+ "talk_ratio": {"current_pct": round(prepared["talk_ratio_user"] * 100),
133
+ "expected_range": tr_range, "within_norm": tr_in},
134
+ "filler_rate": {"current": prepared["filler_rate_per_100_words"],
135
+ "expected_range": fr_range, "within_norm": fr_in},
136
  }
137
 
138
  return prepared
139
 
140
  def _build_prompt(self, data: dict, transcript_text: str,
 
141
  context: str, baseline: dict,
142
+ session_history: list,
143
+ resonance_calibration: dict,
144
+ conversation_types: list) -> str:
145
+
146
+ coaching_guide = CONTEXT_COACHING_GUIDE.get(context, CONTEXT_COACHING_GUIDE["social"])
147
+
148
+ # ── Transcript (PRIMARY) ──────────────────────────────────────
149
+ transcript_section = ""
150
+ if transcript_text:
151
+ transcript_section = f"""
152
+ CONVERSATION TRANSCRIPT:
153
+ {transcript_text}
154
+
155
+ """
156
+
157
+ # ── Supporting signals ────────────────────────────────────────
158
+ fillers_str = ", ".join(data["top_filler_words"]) if data["top_filler_words"] else "none detected"
159
+ signals_section = f"""SUPPORTING DATA (use to ground observations β€” do not lead with these):
160
+ You spoke: {round(data['talk_ratio_user'] * 100)}% of the time
161
+ Speech rate: {data['speech_rate_wpm']} wpm
162
+ Filler words: {data['filler_rate_per_100_words']}/100 words (top: {fillers_str})
163
+ Interruptions you gave: {data['user_interrupted_other']} | received: {data['user_was_interrupted']}
164
+ Longest unbroken stretch: {data['longest_monologue_s']}s
165
+ Questions you asked: {data['user_questions_asked']} | they asked: {data['other_questions_asked']}
166
+ Response latency: {data['avg_response_latency_s']}s avg
167
+ Duration: {data['session_duration_minutes']} minutes
168
+ """
169
 
170
+ # ── Baseline ──────────────────────────────────────────────────
171
  baseline_section = ""
172
  if "baseline_comparison" in data:
173
  b = data["baseline_comparison"]
174
  btype = b.get("type")
 
 
 
 
 
 
 
175
  if btype == "personal_context":
176
  n = b["session_count"]
177
  ctx = b["context"].replace("_", " ")
178
+ sr, fr, tr = b["speech_rate"], b["filler_rate"], b["talk_ratio"]
179
+
180
+ def _fmt(delta, unit=""):
181
+ if delta is None:
182
+ return "no change"
183
+ return f"{'+'if delta > 0 else ''}{delta}{unit}"
184
+
185
  baseline_section = f"""
186
+ YOUR {ctx.upper()} BASELINE ({n} previous sessions):
187
+ Speech rate: {sr['current']} wpm vs your avg {sr['context_avg']} wpm ({_fmt(sr['delta_pct'], '%')})
188
+ Filler rate: {fr['current']}/100w vs your avg {fr['context_avg']}/100w ({_fmt(fr['delta_pct'], '%')})
189
+ Talk ratio: {round(tr['current']*100)}% vs your avg {round(tr['context_avg']*100)}% ({_fmt(tr['delta_pp']*100, 'pp')})
 
 
190
  """
 
191
  elif btype == "population_norm":
192
  ctx = b["context"].replace("_", " ")
193
+ tr, fr = b["talk_ratio"], b["filler_rate"]
194
+ mark = lambda v: "βœ“" if v else "βœ—"
195
+ lines = []
196
+ if tr["expected_range"]:
197
+ lo, hi = tr["expected_range"]
198
+ lines.append(f" Talk ratio: {tr['current_pct']}% (expected {lo}–{hi}%) {mark(tr['within_norm'])}")
199
+ if fr["expected_range"]:
200
+ lo, hi = fr["expected_range"]
201
+ lines.append(f" Filler rate: {fr['current']}/100w (expected {lo}–{hi}/100w) {mark(fr['within_norm'])}")
202
+ if lines:
203
+ baseline_section = f"""
204
+ CONTEXT NORMS FOR {ctx.upper()}:
205
+ Do NOT flag βœ“ signals as issues. Coach only on βœ— signals.
 
 
 
 
 
 
 
 
 
206
  {chr(10).join(lines)}
207
  """
208
 
209
+ # ── Session history (fingerprints) ────────────────────────────
210
  history_section = ""
211
  if session_history:
212
  n = len(session_history)
213
+ lines = []
214
+ for i, s in enumerate(session_history):
215
+ ctx = s["context"].replace("_", " ")
216
+ fp = s.get("fingerprint") or s.get("summary", "")
217
+ if fp:
218
+ lines.append(f" Session {i+1} ({s['date']}, {ctx}):\n {fp}")
219
+ if lines:
220
+ history_section = f"""
221
+ PAST SESSION FINGERPRINTS β€” this is session {n + 1} for this user.
222
+ Use these to identify what's recurring vs what's new in this conversation.
223
+ {chr(10).join(lines)}
 
 
 
 
 
 
 
 
 
 
224
  """
225
 
226
+ # ── Resonance calibration ─────────────────────────────────────
227
  resonance_section = ""
228
  if resonance_calibration:
229
  avoid = resonance_calibration.get("avoid", [])
230
  emphasize = resonance_calibration.get("emphasize", [])
231
  parts = []
232
  if avoid:
233
+ parts.append(f" AVOID strong claims about: {', '.join(avoid)}")
234
  if emphasize:
235
  parts.append(f" PRIORITIZE observations about: {', '.join(emphasize)}")
236
  if parts:
237
  resonance_section = "\nUSER FEEDBACK CALIBRATION:\n" + "\n".join(parts) + "\n"
238
 
239
+ types_str = ", ".join(conversation_types)
240
+
241
+ return f"""You are a behavioral communication coach. Read the conversation transcript below and provide a deep, specific analysis.
242
+
243
+ CONTEXT: {types_str} β€” {coaching_guide}
244
+ {transcript_section}{signals_section}{baseline_section}{history_section}{resonance_section}
245
+ WHAT TO LOOK FOR (search the transcript specifically for these):
246
+ 1. Questions the other person asked that you didn't fully or directly answer
247
+ 2. Opinions, concerns, or points they raised that you talked past or didn't acknowledge
248
+ 3. Moments where you spoke for an extended stretch without giving them space to respond
249
+ 4. Interruptions β€” where you cut in before they finished their thought
250
+ 5. Moments where you seemed to misread what was being asked and answered something else
251
+
252
+ RULES:
253
+ 1. Ground every observation in something that actually happened in the transcript
254
+ 2. Quote directly (in "quotes") when it makes the point concrete β€” be specific about where in the conversation
255
+ 3. Never refer to speakers as SPEAKER_00 or SPEAKER_01 β€” use "you" and "the other person"
256
+ 4. Write directly to the user ("you", "your")
257
+ 5. Be specific and honest β€” no vague generalities
258
+ 6. Don't flag signals that are within normal range for this context type
259
+ 7. If session history exists, explicitly note what's recurring vs what's different this time
260
+ 8. Output valid JSON only β€” no markdown, no code fences
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
  Output this exact JSON:
263
  {{
264
+ "conversation_summary": "3-4 sentences. What was this conversation actually about? What happened? What was the tone and dynamic? Reference specific things said.",
265
+ "summary_sentence": "One direct sentence capturing the dominant behavioral pattern in this conversation.",
 
266
  "observations": [
267
  {{
268
  "signal": "signal_name",
269
+ "observation": "Specific observation grounded in what actually happened. Quote from transcript where it makes the point. Reference where in the conversation it occurred.",
270
+ "resonance_prompt": "A genuine reflective question specific to this moment β€” not generic."
271
  }},
272
  {{
273
+ "signal": "different_signal",
274
+ "observation": "Second observation on a different aspect of the conversation.",
275
+ "resonance_prompt": "Second reflective question."
276
  }},
277
  {{
278
+ "signal": "third_signal",
279
+ "observation": "Third observation.",
280
  "resonance_prompt": "Third reflective question."
281
  }}
282
  ],
 
284
  {{
285
  "priority": 1,
286
  "area": "area name",
287
+ "issue": "What specifically happened in this conversation β€” quote or reference the moment.",
288
  "suggestion": "Exactly what to do differently next time.",
289
+ "why_it_matters": "Why this matters in this type of conversation."
290
  }},
291
  {{
292
  "priority": 2,
293
  "area": "different area",
294
+ "issue": "Second specific issue from this conversation.",
295
  "suggestion": "Second actionable suggestion.",
296
  "why_it_matters": "Why it matters."
297
  }},
 
303
  "why_it_matters": "Why it matters."
304
  }}
305
  ],
306
+ "notable_pattern": "The single most interesting or surprising behavioral pattern from this session. One sentence.",
307
+ "fingerprint": "200-300 words. Describe what was observed in this {context.replace('_', ' ')} conversation: how the person engaged, how they handled questions directed at them, what they avoided, how they responded under pressure or when challenged, their listening patterns, whether they gave the other person space. Write in behavioral terms β€” no raw numbers. This will be used as context in future AI sessions to understand this person's patterns.",
 
 
 
 
 
308
  "data_confidence": "high"
309
  }}"""
310
 
311
  def generate_reflection_questions(self, signals: dict, insights: dict,
312
  transcript_text: str, context: str) -> list:
313
+ prompt = self._build_reflection_prompt(insights, transcript_text, context)
314
  try:
315
  response = self.client.chat.completions.create(
316
  model="llama-3.3-70b-versatile",
 
322
  except Exception:
323
  return []
324
 
325
+ def _build_reflection_prompt(self, insights: dict, transcript_text: str, context: str) -> str:
 
 
326
  observations_text = "\n".join(
327
  f"- {o['observation']}" for o in insights.get("observations", [])[:3]
328
  )
329
+ coaching_areas = [c.get("area") for c in insights.get("coaching_suggestions", [])[:2] if c.get("area")]
330
+ summary = insights.get("conversation_summary", "")
331
 
332
+ return f"""You are a behavioral coach. A person just finished a {context.replace('_', ' ')} conversation.
333
+ Generate 3 reflection questions grounded in what actually happened.
334
 
335
+ Conversation summary: {summary}
 
 
 
336
 
337
  Key observations:
338
  {observations_text}
339
 
340
+ Coaching areas flagged: {", ".join(coaching_areas) if coaching_areas else "none"}
341
+
342
  Transcript sample:
343
+ {transcript_text[:800] if transcript_text else "Not available"}
344
 
345
  Rules:
346
+ 1. Each question must be specific to THIS conversation β€” not generic self-improvement advice
347
+ 2. The answer explains what was observed and what it might mean. 2-3 sentences.
348
+ 3. Reference specific moments or patterns from the transcript where possible
349
+ 4. Write directly to the person ("you", "your")
350
+ 5. Output valid JSON only β€” no markdown, no code fences
351
 
 
352
  [
353
  {{
354
+ "question": "Specific reflective question grounded in this conversation.",
355
+ "answer": "2-3 sentences on what was observed and what it might mean."
356
  }},
357
  {{
358
+ "question": "Second question addressing a different aspect.",
359
+ "answer": "2-3 sentences."
360
  }},
361
  {{
362
  "question": "Third question β€” surface something they might not have noticed.",
363
+ "answer": "2-3 sentences."
364
  }}
365
  ]"""
366
 
 
388
  return json.loads(clean.strip())
389
  except Exception:
390
  return {
391
+ "conversation_summary": "Session analyzed.",
392
+ "summary_sentence": "Session analyzed.",
 
393
  "observations": [],
394
  "coaching_suggestions": [],
 
395
  "notable_pattern": None,
396
+ "fingerprint": None,
397
+ "data_confidence": "low",
398
  }
backend/pipeline/mirror_feed_synthesizer.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from groq import Groq
3
+
4
+ VALID_TYPES = {"pattern", "context_contrast", "trend_up", "trend_down"}
5
+
6
+
7
+ class MirrorFeedSynthesizer:
8
+ def __init__(self, api_key: str):
9
+ self.client = Groq(api_key=api_key)
10
+
11
+ def synthesize(self, sessions: list, user_summary: str = None) -> list:
12
+ """
13
+ sessions: list of dicts with keys:
14
+ context, date, fingerprint (str), notable_pattern (str)
15
+ user_summary: consolidated behavioral summary (present after 12+ sessions)
16
+ Returns list of mirror feed insight dicts, empty if < 2 usable sessions.
17
+ """
18
+ usable = [
19
+ s for s in sessions
20
+ if s.get("fingerprint") or s.get("notable_pattern")
21
+ ]
22
+ if not user_summary and len(usable) < 2:
23
+ return []
24
+
25
+ prompt = (
26
+ self._build_consolidated_prompt(user_summary, usable)
27
+ if user_summary
28
+ else self._build_prompt(usable)
29
+ )
30
+ try:
31
+ response = self.client.chat.completions.create(
32
+ model="llama-3.3-70b-versatile",
33
+ messages=[{"role": "user", "content": prompt}],
34
+ temperature=0.3,
35
+ max_tokens=1200,
36
+ )
37
+ return self._parse(response.choices[0].message.content)
38
+ except Exception:
39
+ return []
40
+
41
+ def _build_consolidated_prompt(self, user_summary: str, recent_sessions: list) -> str:
42
+ recent_block = ""
43
+ if recent_sessions:
44
+ recent_block = "\nMOST RECENT SESSIONS:\n"
45
+ for s in recent_sessions:
46
+ content = (s.get("fingerprint") or s.get("notable_pattern") or "").strip()
47
+ ctx = s["context"].replace("_", " ")
48
+ if content:
49
+ recent_block += f" {s['date']} ({ctx}): {content}\n"
50
+
51
+ return f"""You are identifying behavioral patterns for a person with an established behavioral history.
52
+
53
+ CONSOLIDATED BEHAVIORAL PROFILE (built from all past sessions):
54
+ {user_summary}
55
+ {recent_block}
56
+ TASK: Write 2–4 cross-session insights that reflect this person's established patterns and any recent shifts.
57
+
58
+ BEHAVIORAL DIMENSIONS TO DRAW FROM (pick different ones for each insight):
59
+ - Airtime & talk ratio β€” how much space they take vs. give
60
+ - Listening & acknowledgment β€” whether they receive and respond to what was said
61
+ - Emotional tone & composure β€” nervousness, reactivity, warmth
62
+ - Clarity & structure β€” how organized and clear their speech is
63
+ - Filler words & delivery confidence
64
+ - Adaptability β€” how they adjust when the context or conversation shifts
65
+ - Turn-taking & interruptions β€” how they manage conversation flow
66
+ - Trend over time β€” how early sessions compare to recent ones
67
+ - Context-specific vs universal tendencies
68
+
69
+ RULES:
70
+ 1. Ground every insight in what the behavioral profile actually shows β€” no generic observations
71
+ 2. CRITICAL β€” each insight must address a DIFFERENT behavioral dimension from the list above. If one insight covers communication dominance, the others must come from genuinely different dimensions (emotional tone, clarity, adaptation, trend over time, etc.). Never rephrase the same root behavior as multiple separate insights.
72
+ 3. If only one or two strong patterns exist, write 2 insights β€” a focused feed beats padded repetition
73
+ 4. If recent sessions differ from the established profile, name the shift explicitly
74
+ 5. Write directly to the person ("you", "your")
75
+ 6. Be specific and honest β€” the person has enough history for direct, confident observations
76
+ 7. Output valid JSON only β€” no markdown, no code fences
77
+
78
+ Output a JSON array of 2–4 objects:
79
+ [
80
+ {{
81
+ "type": "pattern",
82
+ "text": "2-3 sentences. Specific observation grounded in the behavioral history.",
83
+ "tip": "One sentence. A concrete, actionable thing to try β€” include a brief example scenario so the person can picture it.",
84
+ "signal": "short_unique_slug"
85
+ }}
86
+ ]
87
+
88
+ type must be exactly one of:
89
+ - "pattern" β€” behavior consistent across the history
90
+ - "context_contrast" β€” clear difference in behavior between contexts
91
+ - "trend_up" β€” something that has improved over time or in recent sessions
92
+ - "trend_down" β€” something that has declined or worsened"""
93
+
94
+ def _build_prompt(self, sessions: list) -> str:
95
+ n = len(sessions)
96
+
97
+ if n <= 3:
98
+ depth_note = (
99
+ "you have limited data β€” keep observations tentative. "
100
+ "Describe what you have noticed so far, not established patterns."
101
+ )
102
+ elif n <= 7:
103
+ depth_note = "patterns are beginning to emerge. Describe them with moderate confidence."
104
+ else:
105
+ depth_note = (
106
+ "you have enough data to describe established behavioral tendencies "
107
+ "with confidence."
108
+ )
109
+
110
+ ctx_map: dict = {}
111
+ for s in sessions:
112
+ ctx_map.setdefault(s["context"], []).append(s)
113
+
114
+ n_contexts = len(ctx_map)
115
+
116
+ blocks = []
117
+ for ctx, ctx_sessions in ctx_map.items():
118
+ label = ctx.replace("_", " ").title()
119
+ block = f"{label} ({len(ctx_sessions)} session{'s' if len(ctx_sessions) > 1 else ''}):\n"
120
+ for s in ctx_sessions:
121
+ content = (s.get("fingerprint") or s.get("notable_pattern") or "").strip()
122
+ if content:
123
+ block += f" {s['date']}: {content}\n"
124
+ blocks.append(block)
125
+
126
+ context_diff_line = (
127
+ f"- How their behavior differs between the {n_contexts} contexts they have recorded in\n"
128
+ if n_contexts >= 2 else ""
129
+ )
130
+
131
+ return f"""You are identifying behavioral patterns across {n} conversations recorded by the same person.
132
+
133
+ SESSIONS BY CONTEXT:
134
+ {''.join(blocks)}
135
+ TASK: Write 2–4 cross-session insights. With {n} sessions, {depth_note}
136
+
137
+ BEHAVIORAL DIMENSIONS TO DRAW FROM (pick different ones for each insight):
138
+ - Airtime & talk ratio β€” how much space they take vs. give
139
+ - Listening & acknowledgment β€” whether they receive and respond to what was said
140
+ - Emotional tone & composure β€” nervousness, reactivity, warmth
141
+ - Clarity & structure β€” how organized and clear their speech is
142
+ - Filler words & delivery confidence
143
+ - Adaptability β€” how they adjust when the context or conversation shifts
144
+ - Turn-taking & interruptions β€” how they manage conversation flow
145
+ - Trend over time β€” how early sessions compare to recent ones
146
+ {context_diff_line}
147
+ RULES:
148
+ 1. Every insight must require multiple sessions to observe β€” nothing you could say from one conversation alone
149
+ 2. CRITICAL β€” each insight must address a DIFFERENT behavioral dimension from the list above. If airtime/dominance is one insight, the others must come from different dimensions (e.g. emotional tone, clarity, trend over time). Never write two insights that are both about the same root behavior rephrased differently.
150
+ 3. If you can only find one strong pattern, write 2 insights total β€” a focused feed beats padded repetition
151
+ 4. Be specific β€” reference what was actually observed, not generic advice
152
+ 5. Write directly to the person ("you", "your")
153
+ 6. With few sessions, be honest about uncertainty β€” say "across your sessions so far" not "you always"
154
+ 7. Never refer to SPEAKER_00 or SPEAKER_01 β€” use "you" and "the other person"
155
+ 8. Output valid JSON only β€” no markdown, no code fences, no extra text
156
+
157
+ Output a JSON array of 2–4 objects:
158
+ [
159
+ {{
160
+ "type": "pattern",
161
+ "text": "2-3 sentences. Specific cross-session observation written directly to the person. Ground it in what was actually observed across multiple sessions.",
162
+ "tip": "One sentence. A concrete, actionable thing to try β€” include a brief example scenario so the person can picture it.",
163
+ "signal": "short_unique_slug"
164
+ }}
165
+ ]
166
+
167
+ type must be exactly one of:
168
+ - "pattern" β€” behavior that is consistent across multiple sessions
169
+ - "context_contrast" β€” behavior that noticeably differs between contexts (only use if 2+ contexts exist with a clear, observable difference)
170
+ - "trend_up" β€” something that has clearly improved from early sessions to recent ones
171
+ - "trend_down" β€” something that has declined or worsened from early to recent sessions"""
172
+
173
+ def _parse(self, raw: str) -> list:
174
+ try:
175
+ clean = raw.strip()
176
+ if "```" in clean:
177
+ clean = clean.split("```")[1]
178
+ if clean.startswith("json"):
179
+ clean = clean[4:]
180
+ data = json.loads(clean.strip())
181
+ if not isinstance(data, list):
182
+ return []
183
+ result, seen = [], set()
184
+ for item in data:
185
+ if not isinstance(item, dict):
186
+ continue
187
+ t = item.get("type", "pattern")
188
+ if t not in VALID_TYPES:
189
+ t = "pattern"
190
+ text = str(item.get("text", "")).strip()
191
+ tip = str(item.get("tip", "")).strip()
192
+ signal = str(item.get("signal", f"insight_{len(result)}")).strip()
193
+ if not text or signal in seen:
194
+ continue
195
+ seen.add(signal)
196
+ entry = {"type": t, "text": text, "signal": signal}
197
+ if tip:
198
+ entry["tip"] = tip
199
+ result.append(entry)
200
+ if len(result) >= 4:
201
+ break
202
+ return result
203
+ except Exception:
204
+ return []
backend/pipeline/personality_synthesizer.py CHANGED
@@ -24,7 +24,6 @@ class PersonalitySynthesizer:
24
  patterns = profile_data.get("patterns", [])
25
  trends = profile_data.get("trends", [])
26
 
27
- # Paragraph depth instruction scales with session count
28
  if n <= 5:
29
  depth_note = (
30
  "You have limited data (few sessions). Keep the portrait observational β€” "
@@ -63,23 +62,19 @@ class PersonalitySynthesizer:
63
  for k, v in dim_avgs.items():
64
  dim_lines += f" {k}: {v}\n"
65
 
66
- # Per-session log for grounding narratives in specific conversations
67
  session_log = ""
68
  if sessions_data:
69
- session_log = "\nSESSION LOG (use these to ground dimension narratives β€” reference by context type):\n"
70
  for i, s in enumerate(sessions_data[-10:], 1):
71
  scores = ", ".join(f"{k}={v}" for k, v in s.get("dim_scores", {}).items())
72
  notable = s.get("notable_pattern", "")
73
  notable_str = f' | pattern: "{notable}"' if notable else ""
74
- quotes = s.get("sample_quotes", [])
75
- quotes_str = ""
76
- if quotes:
77
- q_list = " / ".join(f'"{q}"' for q in quotes[:2])
78
- quotes_str = f'\n quotes: {q_list}'
79
  session_log += (
80
  f" {i}. {s['context']} | {scores} "
81
  f"| fillers={s['filler_rate']}/100w | talk={s['talk_ratio_pct']}%"
82
- f"{notable_str}{quotes_str}\n"
83
  )
84
 
85
  return f"""You are building a behavioral personality portrait from {n} recorded conversations.
@@ -87,7 +82,7 @@ class PersonalitySynthesizer:
87
  {depth_note}
88
  {context_lines}{pattern_lines}{trend_lines}{dim_lines}{session_log}
89
 
90
- TASK: Write a personality portrait paragraph and 5 dimension narratives.
91
 
92
  PARAGRAPH RULES:
93
  1. 3–4 sentences maximum. Write as if describing this person to someone who knows them.
@@ -98,17 +93,13 @@ PARAGRAPH RULES:
98
  5. Use "you" / "your" β€” address the person directly.
99
  6. Never write "seems to" or "appears to" β€” be direct about observed patterns.
100
 
101
- DIMENSION NARRATIVE RULES:
102
- 1. Each narrative must be 2–3 sentences.
103
- 2. Reference context types to ground the narrative β€” not specific dates.
104
- Format: "In your [context type] conversations, ..." or "When you're in [context type] settings, ..."
105
- If only one context type exists, describe the pattern within it.
106
- 3. Explain what behavioural pattern drives the score β€” what actually happens in those settings.
107
- 4. Do not repeat the score number in the narrative. The score is shown separately.
108
- 5. Keep it human β€” write like a perceptive coach, not an analyst.
109
- 6. If a session quote from the log illustrates the pattern, embed it inline using curly quotes β€” e.g.
110
- 'You tend to take charge β€” "let me explain how this works" is characteristic of how you steer conversations.'
111
- Use at most one quote per narrative. Only use it if it genuinely supports the point.
112
 
113
  SCORING β€” derive dimension scores (integers 0–100) from the averages:
114
  - confidence: map confidence avg (1–5) β†’ (avg-1)/4*100, reduce by 5pts per filler point above 3.0/100w
@@ -125,38 +116,34 @@ Output valid JSON only β€” no markdown, no code fences, no extra text:
125
  "key": "confidence",
126
  "name": "Confidence",
127
  "score": <integer 0-100>,
128
- "label": "1–3 word label matching the score",
129
- "narrative": "2–3 sentences. References a specific session from the log. Explains what happened."
130
  }},
131
  {{
132
  "key": "assertiveness",
133
  "name": "Assertiveness",
134
  "score": <integer 0-100>,
135
- "label": "short label",
136
- "narrative": "2–3 sentences referencing specific session(s)."
137
  }},
138
  {{
139
  "key": "listening",
140
  "name": "Listening Quality",
141
  "score": <integer 0-100>,
142
- "label": "short label",
143
- "narrative": "2–3 sentences referencing specific session(s)."
144
  }},
145
  {{
146
  "key": "composure",
147
  "name": "Composure",
148
  "score": <integer 0-100>,
149
- "label": "short label",
150
- "narrative": "2–3 sentences referencing specific session(s)."
151
  }},
152
  {{
153
  "key": "clarity",
154
  "name": "Communication Clarity",
155
  "score": <integer 0-100>,
156
- "label": "short label",
157
- "narrative": "2–3 sentences referencing specific session(s)."
158
  }}
159
- ]
 
160
  }}"""
161
 
162
  def _parse(self, raw: str, dim_avgs: dict) -> dict:
@@ -176,11 +163,12 @@ Output valid JSON only β€” no markdown, no code fences, no extra text:
176
 
177
  return {
178
  "paragraph": "Your behavioral profile is being built. Upload more sessions for deeper insights.",
 
179
  "dimensions": [
180
- {"key": "confidence", "name": "Confidence", "score": _scale("confidence"), "label": "Moderate", "narrative": ""},
181
- {"key": "assertiveness", "name": "Assertiveness", "score": _scale("assertiveness"), "label": "Moderate", "narrative": ""},
182
- {"key": "listening", "name": "Listening Quality", "score": _scale("listening_quality"), "label": "Moderate", "narrative": ""},
183
- {"key": "composure", "name": "Composure", "score": _scale("nervousness", invert=True), "label": "Moderate", "narrative": ""},
184
- {"key": "clarity", "name": "Communication Clarity","score": _scale("clarity"), "label": "Moderate", "narrative": ""},
185
  ],
186
  }
 
24
  patterns = profile_data.get("patterns", [])
25
  trends = profile_data.get("trends", [])
26
 
 
27
  if n <= 5:
28
  depth_note = (
29
  "You have limited data (few sessions). Keep the portrait observational β€” "
 
62
  for k, v in dim_avgs.items():
63
  dim_lines += f" {k}: {v}\n"
64
 
 
65
  session_log = ""
66
  if sessions_data:
67
+ session_log = "\nSESSION LOG (reference by context type when writing the shape narrative):\n"
68
  for i, s in enumerate(sessions_data[-10:], 1):
69
  scores = ", ".join(f"{k}={v}" for k, v in s.get("dim_scores", {}).items())
70
  notable = s.get("notable_pattern", "")
71
  notable_str = f' | pattern: "{notable}"' if notable else ""
72
+ fingerprint = s.get("fingerprint", "")
73
+ fp_str = f'\n behavioral summary: {fingerprint[:200]}…' if fingerprint else ""
 
 
 
74
  session_log += (
75
  f" {i}. {s['context']} | {scores} "
76
  f"| fillers={s['filler_rate']}/100w | talk={s['talk_ratio_pct']}%"
77
+ f"{notable_str}{fp_str}\n"
78
  )
79
 
80
  return f"""You are building a behavioral personality portrait from {n} recorded conversations.
 
82
  {depth_note}
83
  {context_lines}{pattern_lines}{trend_lines}{dim_lines}{session_log}
84
 
85
+ TASK: Write a personality portrait paragraph, 5 dimension scores with labels, and one shape narrative.
86
 
87
  PARAGRAPH RULES:
88
  1. 3–4 sentences maximum. Write as if describing this person to someone who knows them.
 
93
  5. Use "you" / "your" β€” address the person directly.
94
  6. Never write "seems to" or "appears to" β€” be direct about observed patterns.
95
 
96
+ SHAPE NARRATIVE RULES (one paragraph shown below all 5 scores):
97
+ 1. 3–4 sentences. Synthesize what the COMBINATION of all 5 scores reveals β€” not a summary of each one individually.
98
+ 2. Focus on what the pattern of scores means together. High assertiveness + high listening together is unusual and worth naming. High composure + low confidence is a tension worth surfacing.
99
+ 3. If one score is notably higher or lower than the others, explain what that asymmetry likely means behaviourally.
100
+ 4. End with one concrete thing worth paying attention to, grounded in the overall score pattern.
101
+ 5. Write directly to the person ("you", "your"). Sound like a perceptive coach, not an analyst.
102
+ 6. Never list the dimension names. Synthesize them into a flowing, human observation.
 
 
 
 
103
 
104
  SCORING β€” derive dimension scores (integers 0–100) from the averages:
105
  - confidence: map confidence avg (1–5) β†’ (avg-1)/4*100, reduce by 5pts per filler point above 3.0/100w
 
116
  "key": "confidence",
117
  "name": "Confidence",
118
  "score": <integer 0-100>,
119
+ "label": "1–3 word label matching the score"
 
120
  }},
121
  {{
122
  "key": "assertiveness",
123
  "name": "Assertiveness",
124
  "score": <integer 0-100>,
125
+ "label": "short label"
 
126
  }},
127
  {{
128
  "key": "listening",
129
  "name": "Listening Quality",
130
  "score": <integer 0-100>,
131
+ "label": "short label"
 
132
  }},
133
  {{
134
  "key": "composure",
135
  "name": "Composure",
136
  "score": <integer 0-100>,
137
+ "label": "short label"
 
138
  }},
139
  {{
140
  "key": "clarity",
141
  "name": "Communication Clarity",
142
  "score": <integer 0-100>,
143
+ "label": "short label"
 
144
  }}
145
+ ],
146
+ "shape_narrative": "3–4 sentences on what the combination of these 5 scores reveals. Name tensions, unusual pairings, and what the overall pattern means for how this person shows up."
147
  }}"""
148
 
149
  def _parse(self, raw: str, dim_avgs: dict) -> dict:
 
163
 
164
  return {
165
  "paragraph": "Your behavioral profile is being built. Upload more sessions for deeper insights.",
166
+ "shape_narrative": "",
167
  "dimensions": [
168
+ {"key": "confidence", "name": "Confidence", "score": _scale("confidence"), "label": "Moderate"},
169
+ {"key": "assertiveness", "name": "Assertiveness", "score": _scale("assertiveness"), "label": "Moderate"},
170
+ {"key": "listening", "name": "Listening Quality", "score": _scale("listening_quality"), "label": "Moderate"},
171
+ {"key": "composure", "name": "Composure", "score": _scale("nervousness", invert=True), "label": "Moderate"},
172
+ {"key": "clarity", "name": "Communication Clarity", "score": _scale("clarity"), "label": "Moderate"},
173
  ],
174
  }
backend/pipeline/transcriber.py CHANGED
@@ -1,109 +1,77 @@
1
- import whisper
2
- import subprocess
3
  import os
 
 
4
 
5
- class Transcriber:
6
- def __init__(self, model_size="base"):
7
- print(f"Loading Whisper model: {model_size}")
8
- self.model = whisper.load_model(model_size)
9
- print("Whisper model loaded.")
10
-
11
- def convert_to_wav(self, audio_path: str) -> str:
12
- """Convert any audio format to WAV using ffmpeg."""
13
- wav_path = audio_path.replace(".m4a", ".wav").replace(".mp3", ".wav").replace(".mp4", ".wav")
14
- if wav_path == audio_path:
15
- wav_path = audio_path + ".wav"
16
-
17
- subprocess.run([
18
- "ffmpeg", "-i", audio_path,
19
- "-ar", "16000",
20
- "-ac", "1",
21
- "-y", wav_path
22
- ], capture_output=True)
23
 
24
- return wav_path
 
 
 
25
 
26
  def transcribe(self, audio_path: str) -> dict:
27
- print(f"Transcribing: {audio_path}")
28
-
29
- # Convert to WAV if needed
30
- if not audio_path.endswith(".wav"):
31
- print("Converting to WAV...")
32
- wav_path = self.convert_to_wav(audio_path)
33
- else:
34
- wav_path = audio_path
35
-
36
- result = self.model.transcribe(
37
- wav_path,
38
- word_timestamps=True,
39
- verbose=False
40
  )
41
 
42
- # Clean up converted file
43
- if wav_path != audio_path and os.path.exists(wav_path):
44
- os.remove(wav_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
 
46
  segments = []
47
- for seg in result["segments"]:
48
- words = []
49
- for w in seg.get("words", []):
50
- words.append({
51
- "word": w["word"].strip(),
52
- "start": round(w["start"], 3),
53
- "end": round(w["end"], 3),
54
- "score": round(w.get("probability", 0), 3)
55
- })
56
-
57
  segments.append({
58
- "start": round(seg["start"], 3),
59
- "end": round(seg["end"], 3),
60
- "text": seg["text"].strip(),
61
- "words": words
62
  })
63
 
 
 
 
64
  return {
65
  "segments": segments,
66
- "language": result.get("language", "en")
67
  }
68
-
69
-
70
-
71
- # import whisper
72
- # import json
73
-
74
- # class Transcriber:
75
- # def __init__(self, model_size="base"):
76
- # print(f"Loading Whisper model: {model_size}")
77
- # self.model = whisper.load_model(model_size)
78
- # print("Whisper model loaded.")
79
-
80
- # def transcribe(self, audio_path: str) -> dict:
81
- # print(f"Transcribing: {audio_path}")
82
- # result = self.model.transcribe(
83
- # audio_path,
84
- # word_timestamps=True,
85
- # verbose=False
86
- # )
87
-
88
- # segments = []
89
- # for seg in result["segments"]:
90
- # words = []
91
- # for w in seg.get("words", []):
92
- # words.append({
93
- # "word": w["word"].strip(),
94
- # "start": round(w["start"], 3),
95
- # "end": round(w["end"], 3),
96
- # "score": round(w.get("probability", 0), 3)
97
- # })
98
-
99
- # segments.append({
100
- # "start": round(seg["start"], 3),
101
- # "end": round(seg["end"], 3),
102
- # "text": seg["text"].strip(),
103
- # "words": words
104
- # })
105
-
106
- # return {
107
- # "segments": segments,
108
- # "language": result.get("language", "en")
109
- # }
 
 
 
1
  import os
2
+ import subprocess
3
+ from groq import Groq
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ class Transcriber:
7
+ def __init__(self, api_key: str):
8
+ self.client = Groq(api_key=api_key)
9
+ print("Groq Whisper transcriber ready.")
10
 
11
  def transcribe(self, audio_path: str) -> dict:
12
+ print(f"Transcribing via Groq Whisper large-v3: {audio_path}")
13
+
14
+ # WAV at 16kHz mono can be ~37MB for a 20-min session, exceeding Groq's 25MB limit.
15
+ # Convert to MP3 at 64kbps (~9MB for 20 min) before uploading.
16
+ mp3_path = audio_path.rsplit(".", 1)[0] + "_upload.mp3"
17
+ subprocess.run(
18
+ ["ffmpeg", "-i", audio_path, "-ar", "16000", "-ac", "1",
19
+ "-b:a", "64k", "-y", mp3_path],
20
+ capture_output=True
 
 
 
 
21
  )
22
 
23
+ try:
24
+ with open(mp3_path, "rb") as f:
25
+ response = self.client.audio.transcriptions.create(
26
+ file=(os.path.basename(mp3_path), f),
27
+ model="whisper-large-v3",
28
+ response_format="verbose_json",
29
+ timestamp_granularities=["word", "segment"],
30
+ temperature=0.0,
31
+ )
32
+ finally:
33
+ if os.path.exists(mp3_path):
34
+ os.remove(mp3_path)
35
+
36
+ # Groq verbose_json returns words at top level (not nested in segments).
37
+ # Map each word into its parent segment by timestamp overlap.
38
+ raw_words = getattr(response, "words", None) or []
39
+ if raw_words and isinstance(raw_words[0], dict):
40
+ word_list = [
41
+ {"word": w["word"], "start": round(w["start"], 3),
42
+ "end": round(w["end"], 3), "score": 1.0}
43
+ for w in raw_words
44
+ ]
45
+ else:
46
+ word_list = [
47
+ {"word": w.word, "start": round(w.start, 3),
48
+ "end": round(w.end, 3), "score": 1.0}
49
+ for w in raw_words
50
+ ]
51
 
52
+ raw_segments = getattr(response, "segments", None) or []
53
  segments = []
54
+ for seg in raw_segments:
55
+ if isinstance(seg, dict):
56
+ s_start, s_end, s_text = seg["start"], seg["end"], seg["text"]
57
+ else:
58
+ s_start, s_end, s_text = seg.start, seg.end, seg.text
59
+
60
+ seg_words = [
61
+ w for w in word_list
62
+ if w["start"] >= s_start - 0.05 and w["start"] < s_end + 0.05
63
+ ]
64
  segments.append({
65
+ "start": round(s_start, 3),
66
+ "end": round(s_end, 3),
67
+ "text": s_text.strip(),
68
+ "words": seg_words,
69
  })
70
 
71
+ detected_language = getattr(response, "language", "unknown")
72
+ print(f"Detected language: {detected_language}")
73
+
74
  return {
75
  "segments": segments,
76
+ "language": detected_language,
77
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/requirements.txt CHANGED
@@ -1,14 +1,12 @@
1
  fastapi
2
- uvicorn
3
  python-multipart
4
- anthropic
5
- sqlalchemy
6
  pydantic
7
  python-dotenv
8
  pydub
9
  librosa
10
  numpy
11
  scipy
12
- openai-whisper
13
  pyannote.audio
14
- whisperx
 
1
  fastapi
2
+ uvicorn[standard]
3
  python-multipart
4
+ groq
5
+ supabase
6
  pydantic
7
  python-dotenv
8
  pydub
9
  librosa
10
  numpy
11
  scipy
 
12
  pyannote.audio
 
frontend/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8" />
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Behavioural Mirror</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
 
4
  <meta charset="UTF-8" />
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>mirror.</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
frontend/package.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "name": "frontend",
3
  "private": true,
4
  "version": "0.0.0",
5
  "type": "module",
 
1
  {
2
+ "name": "mirror",
3
  "private": true,
4
  "version": "0.0.0",
5
  "type": "module",
frontend/public/favicon.svg CHANGED
frontend/src/App.jsx CHANGED
@@ -8,8 +8,7 @@ import UploadView from "./components/UploadView"
8
  import ResultsView from "./components/ResultsView"
9
  import HistoryView from "./components/HistoryView"
10
  import ProfileView from "./components/ProfileView"
11
-
12
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
13
 
14
  export default function App() {
15
  const [session, setSession] = useState(null)
@@ -25,6 +24,16 @@ export default function App() {
25
  const [deleting, setDeleting] = useState(false)
26
  const [showReenroll, setShowReenroll] = useState(false)
27
 
 
 
 
 
 
 
 
 
 
 
28
  const handleDeleteAccount = async () => {
29
  setDeleting(true)
30
  try {
@@ -54,14 +63,12 @@ export default function App() {
54
  api.get("/api/voiceprint/status")
55
  .then(res => setEnrollState(res.data.enrolled ? "done" : "needed"))
56
  .catch((e) => {
57
- // Network error = backend offline β†’ let user through to main app
58
- // API error (4xx) = backend up but not enrolled β†’ show enrollment
59
  setEnrollState(e.response ? "needed" : "done")
60
  })
61
  }, [session])
62
 
63
  if (authLoading || (session && enrollState === "checking")) return (
64
- <div style={{ textAlign: "center", padding: 80, color: "#4a4865" }}>
65
  Loading…
66
  </div>
67
  )
@@ -74,35 +81,80 @@ export default function App() {
74
 
75
  if (enrollState === "needed") return (
76
  <div style={{ maxWidth: 780, margin: "0 auto", padding: 24 }}>
77
- <div style={{ marginBottom: 28 }}>
78
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: "#f0eeff",
79
- letterSpacing: "-0.3px" }}>
80
- πŸͺž Behavioural Mirror
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </h1>
82
- <p style={{ color: "#4a4865", fontSize: 13, margin: "4px 0 0" }}>
83
- Reflective insights from your conversations
84
- </p>
85
  </div>
86
  <EnrollView onEnrolled={() => setEnrollState("done")} onSkip={() => setEnrollState("done")} />
87
  </div>
88
  )
89
 
90
  return (
91
- <div style={{ maxWidth: 780, margin: "0 auto", padding: 24 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  {/* Header */}
94
  <div style={{ marginBottom: 28, display: "flex",
95
  justifyContent: "space-between", alignItems: "flex-start" }}>
96
- <div>
97
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, letterSpacing: "-0.3px" }}>
98
- <span style={{ background: G, WebkitBackgroundClip: "text",
99
- WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
100
- mirror
101
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  </h1>
103
- <p style={{ color: "#4a4865", fontSize: 13, margin: "4px 0 0" }}>
104
- Reflective insights from your conversations
105
- </p>
106
  </div>
107
 
108
  <div style={{ position: "relative" }}>
@@ -126,21 +178,24 @@ export default function App() {
126
  ) : (
127
  <>
128
  <button onClick={() => setShowAccountMenu(v => !v)}
129
- style={{ background: "#14141f", border: "1px solid #2a2a42", borderRadius: 7,
130
  padding: "6px 14px", cursor: "pointer", fontSize: 13, color: "#8b89aa" }}>
131
  Account β–Ύ
132
  </button>
133
  {showAccountMenu && (
134
  <div style={{ position: "absolute", right: 0, top: "110%",
135
- background: "#14141f", border: "1px solid #2a2a42", borderRadius: 10,
136
- boxShadow: "0 8px 40px rgba(0,0,0,0.6)", zIndex: 20,
137
  minWidth: 200, overflow: "hidden" }}>
138
  <div style={{ padding: "8px 14px 6px", fontSize: 11,
139
- color: "#4a4865", borderBottom: "1px solid #2a2a42",
140
  textTransform: "uppercase", letterSpacing: 0.5 }}>
141
  Voice β€” {enrollState === "done" ? "enrolled" : "not enrolled"}
142
  </div>
143
  {[
 
 
 
144
  { label: enrollState === "done" ? "Retrain your voice" : "Enroll your voice",
145
  onClick: () => { setShowReenroll(true); setShowAccountMenu(false) },
146
  color: "#f0eeff" },
@@ -154,7 +209,7 @@ export default function App() {
154
  <button key={label} onClick={onClick}
155
  style={{ display: "block", width: "100%", padding: "10px 14px",
156
  textAlign: "left", background: "none", border: "none",
157
- borderBottom: "1px solid #2a2a42",
158
  cursor: "pointer", fontSize: 13, color }}>
159
  {label}
160
  </button>
@@ -168,18 +223,18 @@ export default function App() {
168
 
169
  {/* Nav */}
170
  <nav style={{ display: "flex", gap: 0, marginBottom: 32,
171
- borderBottom: "1px solid #2a2a42" }}>
172
  {[
173
  { key: "profile", label: "Profile" },
174
  { key: "upload", label: "Upload" },
175
  { key: "history", label: "History" },
176
  ].map(({ key, label }) => (
177
  <button key={key} onClick={() => setView(key)}
 
178
  style={{ background: "none", border: "none", cursor: "pointer",
179
  fontWeight: view === key ? 600 : 400, fontSize: 14,
180
- color: view === key ? "#e879f9" : "#8b89aa",
181
- borderBottom: view === key ? "2px solid #e879f9" : "2px solid transparent",
182
- padding: "0 16px 12px", transition: "color 0.15s" }}>
183
  {label}
184
  </button>
185
  ))}
@@ -187,9 +242,9 @@ export default function App() {
187
 
188
  {/* Re-enroll modal */}
189
  {showReenroll && (
190
- <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.8)",
191
  zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center" }}>
192
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
193
  borderRadius: 16, maxWidth: 500, width: "90%", maxHeight: "90vh",
194
  overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.7)" }}>
195
  <div style={{ display: "flex", justifyContent: "space-between",
@@ -199,7 +254,7 @@ export default function App() {
199
  </span>
200
  <button onClick={() => setShowReenroll(false)}
201
  style={{ background: "none", border: "none", cursor: "pointer",
202
- fontSize: 20, color: "#4a4865", lineHeight: 1 }}>Γ—</button>
203
  </div>
204
  <EnrollView
205
  onEnrolled={() => { setShowReenroll(false); setEnrollState("done") }}
@@ -209,22 +264,37 @@ export default function App() {
209
  </div>
210
  )}
211
 
 
 
 
 
 
 
212
  <div style={{ display: view === "profile" ? "block" : "none" }}>
213
- <ProfileView active={view === "profile"} onUpload={() => setView("upload")} />
 
 
214
  </div>
215
  <div style={{ display: view === "upload" ? "block" : "none" }}>
216
- <UploadView
217
- onResults={(r) => { setResults(r); setView("results") }}
218
- onActivate={() => setView("upload")}
219
- />
 
 
220
  </div>
221
  {view === "results" && results && (
222
- <ResultsView results={results} onBack={() => setView("history")} />
 
 
223
  )}
224
  <div style={{ display: view === "history" ? "block" : "none" }}>
225
- <HistoryView active={view === "history"}
226
- onSelect={(r) => { setResults(r); setView("results") }} />
 
 
227
  </div>
228
  </div>
 
229
  )
230
  }
 
8
  import ResultsView from "./components/ResultsView"
9
  import HistoryView from "./components/HistoryView"
10
  import ProfileView from "./components/ProfileView"
11
+ import HowItWorksView from "./components/HowItWorksView"
 
12
 
13
  export default function App() {
14
  const [session, setSession] = useState(null)
 
24
  const [deleting, setDeleting] = useState(false)
25
  const [showReenroll, setShowReenroll] = useState(false)
26
 
27
+ // Each key tracks how many times we've navigated TO that view β€” re-mounting
28
+ // the wrapper div causes the .view-enter animation to fire every tab switch.
29
+ const [viewKeys, setViewKeys] = useState({ profile: 0, upload: 0, history: 0 })
30
+
31
+ useEffect(() => {
32
+ if (view === "profile" || view === "upload" || view === "history") {
33
+ setViewKeys(k => ({ ...k, [view]: k[view] + 1 }))
34
+ }
35
+ }, [view])
36
+
37
  const handleDeleteAccount = async () => {
38
  setDeleting(true)
39
  try {
 
63
  api.get("/api/voiceprint/status")
64
  .then(res => setEnrollState(res.data.enrolled ? "done" : "needed"))
65
  .catch((e) => {
 
 
66
  setEnrollState(e.response ? "needed" : "done")
67
  })
68
  }, [session])
69
 
70
  if (authLoading || (session && enrollState === "checking")) return (
71
+ <div style={{ textAlign: "center", padding: 80, color: "#2e3464" }}>
72
  Loading…
73
  </div>
74
  )
 
81
 
82
  if (enrollState === "needed") return (
83
  <div style={{ maxWidth: 780, margin: "0 auto", padding: 24 }}>
84
+ <div style={{ marginBottom: 28, display: "flex", alignItems: "center", gap: 10 }}>
85
+ <svg width="26" height="26" viewBox="0 0 32 32" fill="none">
86
+ <defs>
87
+ <linearGradient id="enr-g" x1="0" y1="0" x2="32" y2="0" gradientUnits="userSpaceOnUse">
88
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
89
+ </linearGradient>
90
+ </defs>
91
+ <rect x="2" y="12" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".4"/>
92
+ <rect x="8" y="8" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".65"/>
93
+ <rect x="14" y="4" width="4" height="12" rx="2" fill="url(#enr-g)"/>
94
+ <rect x="20" y="8" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".65"/>
95
+ <rect x="26" y="12" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".4"/>
96
+ <line x1="0" y1="17.5" x2="32" y2="17.5" stroke="#1e2438" strokeWidth="1"/>
97
+ <rect x="2" y="18" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".18"/>
98
+ <rect x="8" y="18" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".3"/>
99
+ <rect x="14" y="18" width="4" height="12" rx="2" fill="url(#enr-g)" opacity=".38"/>
100
+ <rect x="20" y="18" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".3"/>
101
+ <rect x="26" y="18" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".18"/>
102
+ </svg>
103
+ <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, letterSpacing: "-0.3px", color: "#f0eeff" }}>
104
+ mirror<span style={{ color: "#1d4ed8" }}>.</span>
105
  </h1>
 
 
 
106
  </div>
107
  <EnrollView onEnrolled={() => setEnrollState("done")} onSkip={() => setEnrollState("done")} />
108
  </div>
109
  )
110
 
111
  return (
112
+ <>
113
+ {/* Ambient background blobs */}
114
+ <div style={{ position: "fixed", inset: 0, overflow: "hidden", pointerEvents: "none", zIndex: 0 }}>
115
+ <div style={{
116
+ position: "absolute", top: "-15%", right: "-8%",
117
+ width: 650, height: 650, borderRadius: "50%",
118
+ background: "radial-gradient(circle, rgba(29,78,216,0.09) 0%, transparent 68%)",
119
+ filter: "blur(1px)",
120
+ animation: "blobDrift1 22s ease-in-out infinite",
121
+ }} />
122
+ <div style={{
123
+ position: "absolute", bottom: "-12%", left: "-6%",
124
+ width: 550, height: 550, borderRadius: "50%",
125
+ background: "radial-gradient(circle, rgba(8,145,178,0.07) 0%, transparent 68%)",
126
+ filter: "blur(1px)",
127
+ animation: "blobDrift2 28s ease-in-out infinite",
128
+ }} />
129
+ </div>
130
+
131
+ <div style={{ maxWidth: 780, margin: "0 auto", padding: 24, position: "relative", zIndex: 1 }}>
132
 
133
  {/* Header */}
134
  <div style={{ marginBottom: 28, display: "flex",
135
  justifyContent: "space-between", alignItems: "flex-start" }}>
136
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
137
+ <svg width="26" height="26" viewBox="0 0 32 32" fill="none">
138
+ <defs>
139
+ <linearGradient id="hdr-g" x1="0" y1="0" x2="32" y2="0" gradientUnits="userSpaceOnUse">
140
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
141
+ </linearGradient>
142
+ </defs>
143
+ <rect x="2" y="12" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".4"/>
144
+ <rect x="8" y="8" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".65"/>
145
+ <rect x="14" y="4" width="4" height="12" rx="2" fill="url(#hdr-g)"/>
146
+ <rect x="20" y="8" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".65"/>
147
+ <rect x="26" y="12" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".4"/>
148
+ <line x1="0" y1="17.5" x2="32" y2="17.5" stroke="#1e2438" strokeWidth="1"/>
149
+ <rect x="2" y="18" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".18"/>
150
+ <rect x="8" y="18" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".3"/>
151
+ <rect x="14" y="18" width="4" height="12" rx="2" fill="url(#hdr-g)" opacity=".38"/>
152
+ <rect x="20" y="18" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".3"/>
153
+ <rect x="26" y="18" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".18"/>
154
+ </svg>
155
+ <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, letterSpacing: "-0.3px", color: "#f0eeff" }}>
156
+ mirror<span style={{ color: "#1d4ed8" }}>.</span>
157
  </h1>
 
 
 
158
  </div>
159
 
160
  <div style={{ position: "relative" }}>
 
178
  ) : (
179
  <>
180
  <button onClick={() => setShowAccountMenu(v => !v)}
181
+ style={{ background: "#151922", border: "1px solid #1e2438", borderRadius: 7,
182
  padding: "6px 14px", cursor: "pointer", fontSize: 13, color: "#8b89aa" }}>
183
  Account β–Ύ
184
  </button>
185
  {showAccountMenu && (
186
  <div style={{ position: "absolute", right: 0, top: "110%",
187
+ background: "#151922", border: "1px solid #1e2438", borderRadius: 10,
188
+ boxShadow: "0 8px 40px rgba(0,0,0,0.7)", zIndex: 20,
189
  minWidth: 200, overflow: "hidden" }}>
190
  <div style={{ padding: "8px 14px 6px", fontSize: 11,
191
+ color: "#4a4d6a", borderBottom: "1px solid #1e2438",
192
  textTransform: "uppercase", letterSpacing: 0.5 }}>
193
  Voice β€” {enrollState === "done" ? "enrolled" : "not enrolled"}
194
  </div>
195
  {[
196
+ { label: "How it works",
197
+ onClick: () => { setView("howItWorks"); setShowAccountMenu(false) },
198
+ color: "#f0eeff" },
199
  { label: enrollState === "done" ? "Retrain your voice" : "Enroll your voice",
200
  onClick: () => { setShowReenroll(true); setShowAccountMenu(false) },
201
  color: "#f0eeff" },
 
209
  <button key={label} onClick={onClick}
210
  style={{ display: "block", width: "100%", padding: "10px 14px",
211
  textAlign: "left", background: "none", border: "none",
212
+ borderBottom: "1px solid #1e2438",
213
  cursor: "pointer", fontSize: 13, color }}>
214
  {label}
215
  </button>
 
223
 
224
  {/* Nav */}
225
  <nav style={{ display: "flex", gap: 0, marginBottom: 32,
226
+ borderBottom: "1px solid #1e2438" }}>
227
  {[
228
  { key: "profile", label: "Profile" },
229
  { key: "upload", label: "Upload" },
230
  { key: "history", label: "History" },
231
  ].map(({ key, label }) => (
232
  <button key={key} onClick={() => setView(key)}
233
+ className={`nav-tab${view === key ? " active" : ""}`}
234
  style={{ background: "none", border: "none", cursor: "pointer",
235
  fontWeight: view === key ? 600 : 400, fontSize: 14,
236
+ color: view === key ? "#5b9cf6" : "#8b89aa",
237
+ padding: "0 16px 12px" }}>
 
238
  {label}
239
  </button>
240
  ))}
 
242
 
243
  {/* Re-enroll modal */}
244
  {showReenroll && (
245
+ <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
246
  zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center" }}>
247
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
248
  borderRadius: 16, maxWidth: 500, width: "90%", maxHeight: "90vh",
249
  overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.7)" }}>
250
  <div style={{ display: "flex", justifyContent: "space-between",
 
254
  </span>
255
  <button onClick={() => setShowReenroll(false)}
256
  style={{ background: "none", border: "none", cursor: "pointer",
257
+ fontSize: 20, color: "#4a4d6a", lineHeight: 1 }}>Γ—</button>
258
  </div>
259
  <EnrollView
260
  onEnrolled={() => { setShowReenroll(false); setEnrollState("done") }}
 
264
  </div>
265
  )}
266
 
267
+ {view === "howItWorks" && (
268
+ <HowItWorksView onBack={() => setView("profile")} />
269
+ )}
270
+
271
+ {/* Persistent views β€” kept mounted, but wrapper remounts each tab visit
272
+ so .view-enter animation fires every time you switch to that tab */}
273
  <div style={{ display: view === "profile" ? "block" : "none" }}>
274
+ <div key={`profile-${viewKeys.profile}`} className="view-enter">
275
+ <ProfileView active={view === "profile"} onUpload={() => setView("upload")} />
276
+ </div>
277
  </div>
278
  <div style={{ display: view === "upload" ? "block" : "none" }}>
279
+ <div key={`upload-${viewKeys.upload}`} className="view-enter">
280
+ <UploadView
281
+ onResults={(r) => { setResults(r); setView("results") }}
282
+ onActivate={() => setView("upload")}
283
+ />
284
+ </div>
285
  </div>
286
  {view === "results" && results && (
287
+ <div className="view-enter">
288
+ <ResultsView results={results} onBack={() => setView("history")} />
289
+ </div>
290
  )}
291
  <div style={{ display: view === "history" ? "block" : "none" }}>
292
+ <div key={`history-${viewKeys.history}`} className="view-enter">
293
+ <HistoryView active={view === "history"}
294
+ onSelect={(r) => { setResults(r); setView("results") }} />
295
+ </div>
296
  </div>
297
  </div>
298
+ </>
299
  )
300
  }
frontend/src/components/AuthView.jsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useState, useRef } from "react"
2
  import { supabase } from "../lib/supabase"
3
 
4
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
5
 
6
  export default function AuthView({ onAuth }) {
7
  const [mode, setMode] = useState("login")
@@ -46,19 +46,47 @@ export default function AuthView({ onAuth }) {
46
  }
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  return (
50
  <div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
51
  justifyContent: "center", padding: 24 }}>
52
- <div style={{ width: "100%", maxWidth: 360 }}>
53
 
54
  {/* Logo */}
55
  <div style={{ textAlign: "center", marginBottom: 36 }}>
56
- <div style={{ fontSize: 44, marginBottom: 14 }}>πŸͺž</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  <h1 style={{ fontSize: 24, fontWeight: 700, margin: "0 0 6px",
58
- letterSpacing: "-0.5px", background: G,
59
- WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
60
- backgroundClip: "text" }}>
61
- mirror
62
  </h1>
63
  <p style={{ color: "#4a4865", fontSize: 13, margin: 0 }}>
64
  {mode === "login"
@@ -68,9 +96,12 @@ export default function AuthView({ onAuth }) {
68
  </div>
69
 
70
  {/* Card */}
71
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
 
 
72
  borderRadius: 16, padding: 28,
73
- boxShadow: "0 8px 40px rgba(0,0,0,0.5)" }}>
 
74
 
75
  <form onSubmit={handleSubmit}>
76
  <div style={{ marginBottom: 16 }}>
@@ -118,18 +149,44 @@ export default function AuthView({ onAuth }) {
118
  )}
119
 
120
  <button type="submit" disabled={loading}
 
121
  style={{ width: "100%", padding: "13px 24px",
122
- background: loading ? "#1a1a2e" : G,
123
  color: loading ? "#4a4865" : "white",
124
- border: loading ? "1px solid #2a2a42" : "none",
125
  borderRadius: 8, fontSize: 15,
126
  cursor: loading ? "not-allowed" : "pointer", fontWeight: 600,
127
- boxShadow: loading ? "none" : "0 0 24px rgba(217,70,239,0.3)",
128
- transition: "all 0.15s" }}>
129
  {loading ? "…" : mode === "login" ? "Sign in" : "Create account"}
130
  </button>
131
  </form>
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  <div style={{ textAlign: "center", marginTop: 20 }}>
134
  <button
135
  onClick={() => { setMode(mode === "login" ? "signup" : "login"); setError(""); setMessage("") }}
 
1
  import { useState, useRef } from "react"
2
  import { supabase } from "../lib/supabase"
3
 
4
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
5
 
6
  export default function AuthView({ onAuth }) {
7
  const [mode, setMode] = useState("login")
 
46
  }
47
  }
48
 
49
+ const handleGoogle = async () => {
50
+ setLoading(true)
51
+ setError("")
52
+ const { error } = await supabase.auth.signInWithOAuth({
53
+ provider: "google",
54
+ options: { redirectTo: window.location.origin },
55
+ })
56
+ if (error) {
57
+ setError(error.message)
58
+ setLoading(false)
59
+ }
60
+ }
61
+
62
  return (
63
  <div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
64
  justifyContent: "center", padding: 24 }}>
65
+ <div style={{ width: "100%", maxWidth: 360 }} className="view-enter">
66
 
67
  {/* Logo */}
68
  <div style={{ textAlign: "center", marginBottom: 36 }}>
69
+ <svg width="48" height="48" viewBox="0 0 52 52" fill="none" style={{ marginBottom: 14 }}>
70
+ <defs>
71
+ <linearGradient id="auth-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
72
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
73
+ </linearGradient>
74
+ </defs>
75
+ <rect x="2" y="20" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".35"/>
76
+ <rect x="11" y="13" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".6"/>
77
+ <rect x="20" y="6" width="8" height="20" rx="4" fill="url(#auth-g)"/>
78
+ <rect x="31" y="13" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".6"/>
79
+ <rect x="40" y="20" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".35"/>
80
+ <line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
81
+ <rect x="2" y="29" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".15"/>
82
+ <rect x="11" y="29" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".27"/>
83
+ <rect x="20" y="29" width="8" height="20" rx="4" fill="url(#auth-g)" opacity=".33"/>
84
+ <rect x="31" y="29" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".27"/>
85
+ <rect x="40" y="29" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".15"/>
86
+ </svg>
87
  <h1 style={{ fontSize: 24, fontWeight: 700, margin: "0 0 6px",
88
+ letterSpacing: "-0.5px", color: "#f0eeff" }}>
89
+ mirror<span style={{ color: "#1d4ed8" }}>.</span>
 
 
90
  </h1>
91
  <p style={{ color: "#4a4865", fontSize: 13, margin: 0 }}>
92
  {mode === "login"
 
96
  </div>
97
 
98
  {/* Card */}
99
+ <div style={{
100
+ background: "linear-gradient(#151922, #151922) padding-box, linear-gradient(135deg, rgba(29,78,216,0.35), rgba(34,211,238,0.35)) border-box",
101
+ border: "1px solid transparent",
102
  borderRadius: 16, padding: 28,
103
+ boxShadow: "0 8px 40px rgba(0,0,0,0.5)"
104
+ }}>
105
 
106
  <form onSubmit={handleSubmit}>
107
  <div style={{ marginBottom: 16 }}>
 
149
  )}
150
 
151
  <button type="submit" disabled={loading}
152
+ className={loading ? "" : "btn-grad"}
153
  style={{ width: "100%", padding: "13px 24px",
154
+ background: loading ? "#151922" : G,
155
  color: loading ? "#4a4865" : "white",
156
+ border: loading ? "1px solid #1e2438" : "none",
157
  borderRadius: 8, fontSize: 15,
158
  cursor: loading ? "not-allowed" : "pointer", fontWeight: 600,
159
+ boxShadow: loading ? "none" : "0 0 24px rgba(29,78,216,0.3)" }}>
 
160
  {loading ? "…" : mode === "login" ? "Sign in" : "Create account"}
161
  </button>
162
  </form>
163
 
164
+ {/* Divider */}
165
+ <div style={{ display: "flex", alignItems: "center", gap: 12, margin: "20px 0" }}>
166
+ <div style={{ flex: 1, height: 1, background: "#1e2438" }} />
167
+ <span style={{ fontSize: 12, color: "#4a4865" }}>or</span>
168
+ <div style={{ flex: 1, height: 1, background: "#1e2438" }} />
169
+ </div>
170
+
171
+ {/* Google OAuth */}
172
+ <button type="button" onClick={handleGoogle} disabled={loading}
173
+ style={{ width: "100%", padding: "11px 24px", fontSize: 14, fontWeight: 500,
174
+ cursor: loading ? "not-allowed" : "pointer",
175
+ background: "#0e1320", color: "#d4d2e8",
176
+ border: "1px solid #1e2438", borderRadius: 8,
177
+ display: "flex", alignItems: "center", justifyContent: "center", gap: 10,
178
+ transition: "border-color 0.15s", opacity: loading ? 0.5 : 1 }}
179
+ onMouseEnter={e => e.currentTarget.style.borderColor = "#4a4865"}
180
+ onMouseLeave={e => e.currentTarget.style.borderColor = "#1e2438"}>
181
+ <svg width="17" height="17" viewBox="0 0 24 24">
182
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
183
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
184
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
185
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
186
+ </svg>
187
+ Continue with Google
188
+ </button>
189
+
190
  <div style={{ textAlign: "center", marginTop: 20 }}>
191
  <button
192
  onClick={() => { setMode(mode === "login" ? "signup" : "login"); setError(""); setMessage("") }}
frontend/src/components/EnrollView.jsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useState, useRef } from "react"
2
  import api from "../lib/api"
3
 
4
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
5
  const MIN_SECONDS = 20
6
  const MAX_SECONDS = 60
7
 
@@ -114,7 +114,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
114
  <button onClick={onEnrolled}
115
  style={{ padding: "12px 40px", background: G, color: "white",
116
  border: "none", borderRadius: 8, fontSize: 15, cursor: "pointer",
117
- fontWeight: 600, boxShadow: "0 0 24px rgba(217,70,239,0.3)" }}>
118
  Continue to app
119
  </button>
120
  </div>
@@ -138,8 +138,8 @@ export default function EnrollView({ onEnrolled, onSkip }) {
138
  {[1, 2, 3].map(r => (
139
  <div key={r} style={{
140
  width: 10, height: 10, borderRadius: "50%",
141
- background: r < round ? G : r === round ? "rgba(217,70,239,0.35)" : "#2a2a42",
142
- boxShadow: r <= round ? "0 0 6px rgba(217,70,239,0.35)" : "none",
143
  transition: "all 0.3s",
144
  }} />
145
  ))}
@@ -151,15 +151,15 @@ export default function EnrollView({ onEnrolled, onSkip }) {
151
  </div>
152
 
153
  {/* Round content */}
154
- <div style={{ background: "#14141f", borderRadius: 12, padding: 20,
155
- marginBottom: 14, border: "1px solid #2a2a42" }}>
156
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
157
  <span style={{ fontSize: 11, fontWeight: 700, color: "#8b89aa",
158
  textTransform: "uppercase", letterSpacing: 0.5 }}>
159
  Round {round}
160
  </span>
161
- <span style={{ fontSize: 11, background: "rgba(217,70,239,0.1)",
162
- color: "#e879f9", border: "1px solid rgba(217,70,239,0.25)",
163
  borderRadius: 20, padding: "2px 8px", fontWeight: 600 }}>
164
  {roundConfig.badge}
165
  </span>
@@ -178,7 +178,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
178
  ))
179
  ) : (
180
  <p style={{ margin: "0 0 4px", fontSize: 14, color: "#8b89aa", lineHeight: 1.7,
181
- background: "#1a1a2e", border: "1px solid #2a2a42", borderRadius: 8,
182
  padding: "12px 14px" }}>
183
  {roundConfig.content}
184
  </p>
@@ -188,14 +188,14 @@ export default function EnrollView({ onEnrolled, onSkip }) {
188
  </div>
189
 
190
  {/* Recording area */}
191
- <div style={{ background: "#14141f", borderRadius: 14, padding: 28,
192
- marginBottom: 18, textAlign: "center", border: "1px solid #2a2a42" }}>
193
 
194
  {state === "idle" && (
195
  <>
196
  <div style={{ fontSize: 44, marginBottom: 12 }}>🎀</div>
197
  <p style={{ fontSize: 13, color: "#8b89aa", margin: 0, lineHeight: 1.6 }}>
198
- Click <strong style={{ color: "#e879f9" }}>Start recording</strong>,
199
  then read the sentences above clearly.
200
  </p>
201
  </>
@@ -208,13 +208,13 @@ export default function EnrollView({ onEnrolled, onSkip }) {
208
  fontVariantNumeric: "tabular-nums" }}>
209
  {elapsed}s
210
  </div>
211
- <div style={{ height: 5, background: "#2a2a42", borderRadius: 3,
212
  margin: "14px 0 10px", overflow: "hidden" }}>
213
  <div style={{
214
  height: "100%", borderRadius: 3,
215
  background: canStop ? "#34d399" : G,
216
  boxShadow: canStop ? "0 0 8px rgba(52,211,153,0.5)"
217
- : "0 0 8px rgba(217,70,239,0.4)",
218
  width: `${progressPct}%`,
219
  transition: "width 0.8s linear, background 0.4s",
220
  }} />
@@ -265,7 +265,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
265
  style={{ width: "100%", padding: "13px 24px", background: G,
266
  color: "white", border: "none", borderRadius: 8, fontSize: 15,
267
  cursor: "pointer", fontWeight: 600, marginBottom: 10,
268
- boxShadow: "0 0 24px rgba(217,70,239,0.3)" }}>
269
  Start recording
270
  </button>
271
  )}
@@ -273,13 +273,13 @@ export default function EnrollView({ onEnrolled, onSkip }) {
273
  {state === "recording" && (
274
  <button onClick={stopRecording} disabled={!canStop}
275
  style={{ width: "100%", padding: "13px 24px",
276
- background: canStop ? G : "#14141f",
277
- color: canStop ? "white" : "#2a2a42",
278
- border: canStop ? "none" : "1px solid #2a2a42",
279
  borderRadius: 8, fontSize: 15,
280
  cursor: canStop ? "pointer" : "not-allowed",
281
  fontWeight: 600, marginBottom: 10,
282
- boxShadow: canStop ? "0 0 24px rgba(217,70,239,0.3)" : "none",
283
  transition: "all 0.3s" }}>
284
  {canStop ? `Stop β€” save round ${round}` : `Stop (${MIN_SECONDS - elapsed}s left)`}
285
  </button>
@@ -290,7 +290,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
290
  style={{ width: "100%", padding: "13px 24px", background: G,
291
  color: "white", border: "none", borderRadius: 8, fontSize: 15,
292
  cursor: "pointer", fontWeight: 600, marginBottom: 10,
293
- boxShadow: "0 0 24px rgba(217,70,239,0.3)" }}>
294
  {round < 3 ? `Continue to Round ${round + 1} β†’` : "Submit voice enrollment"}
295
  </button>
296
  )}
@@ -298,7 +298,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
298
  {(state === "idle" || state === "recording") && (
299
  <button onClick={onSkip} disabled={state === "recording"}
300
  style={{ width: "100%", padding: "10px 24px", background: "none",
301
- color: state === "recording" ? "#2a2a42" : "#4a4865",
302
  border: "none", fontSize: 13,
303
  cursor: state === "recording" ? "not-allowed" : "pointer" }}>
304
  Skip for now β€” analysis will still work, speaker detection may be less accurate
 
1
  import { useState, useRef } from "react"
2
  import api from "../lib/api"
3
 
4
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
5
  const MIN_SECONDS = 20
6
  const MAX_SECONDS = 60
7
 
 
114
  <button onClick={onEnrolled}
115
  style={{ padding: "12px 40px", background: G, color: "white",
116
  border: "none", borderRadius: 8, fontSize: 15, cursor: "pointer",
117
+ fontWeight: 600, boxShadow: "0 0 24px rgba(29,78,216,0.3)" }}>
118
  Continue to app
119
  </button>
120
  </div>
 
138
  {[1, 2, 3].map(r => (
139
  <div key={r} style={{
140
  width: 10, height: 10, borderRadius: "50%",
141
+ background: r < round ? G : r === round ? "rgba(29,78,216,0.35)" : "#1e2438",
142
+ boxShadow: r <= round ? "0 0 6px rgba(29,78,216,0.35)" : "none",
143
  transition: "all 0.3s",
144
  }} />
145
  ))}
 
151
  </div>
152
 
153
  {/* Round content */}
154
+ <div style={{ background: "#151922", borderRadius: 12, padding: 20,
155
+ marginBottom: 14, border: "1px solid #1e2438" }}>
156
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
157
  <span style={{ fontSize: 11, fontWeight: 700, color: "#8b89aa",
158
  textTransform: "uppercase", letterSpacing: 0.5 }}>
159
  Round {round}
160
  </span>
161
+ <span style={{ fontSize: 11, background: "rgba(29,78,216,0.1)",
162
+ color: "#5b9cf6", border: "1px solid rgba(29,78,216,0.25)",
163
  borderRadius: 20, padding: "2px 8px", fontWeight: 600 }}>
164
  {roundConfig.badge}
165
  </span>
 
178
  ))
179
  ) : (
180
  <p style={{ margin: "0 0 4px", fontSize: 14, color: "#8b89aa", lineHeight: 1.7,
181
+ background: "#131827", border: "1px solid #1e2438", borderRadius: 8,
182
  padding: "12px 14px" }}>
183
  {roundConfig.content}
184
  </p>
 
188
  </div>
189
 
190
  {/* Recording area */}
191
+ <div style={{ background: "#151922", borderRadius: 14, padding: 28,
192
+ marginBottom: 18, textAlign: "center", border: "1px solid #1e2438" }}>
193
 
194
  {state === "idle" && (
195
  <>
196
  <div style={{ fontSize: 44, marginBottom: 12 }}>🎀</div>
197
  <p style={{ fontSize: 13, color: "#8b89aa", margin: 0, lineHeight: 1.6 }}>
198
+ Click <strong style={{ color: "#5b9cf6" }}>Start recording</strong>,
199
  then read the sentences above clearly.
200
  </p>
201
  </>
 
208
  fontVariantNumeric: "tabular-nums" }}>
209
  {elapsed}s
210
  </div>
211
+ <div style={{ height: 5, background: "#1e2438", borderRadius: 3,
212
  margin: "14px 0 10px", overflow: "hidden" }}>
213
  <div style={{
214
  height: "100%", borderRadius: 3,
215
  background: canStop ? "#34d399" : G,
216
  boxShadow: canStop ? "0 0 8px rgba(52,211,153,0.5)"
217
+ : "0 0 8px rgba(29,78,216,0.4)",
218
  width: `${progressPct}%`,
219
  transition: "width 0.8s linear, background 0.4s",
220
  }} />
 
265
  style={{ width: "100%", padding: "13px 24px", background: G,
266
  color: "white", border: "none", borderRadius: 8, fontSize: 15,
267
  cursor: "pointer", fontWeight: 600, marginBottom: 10,
268
+ boxShadow: "0 0 24px rgba(29,78,216,0.3)" }}>
269
  Start recording
270
  </button>
271
  )}
 
273
  {state === "recording" && (
274
  <button onClick={stopRecording} disabled={!canStop}
275
  style={{ width: "100%", padding: "13px 24px",
276
+ background: canStop ? G : "#151922",
277
+ color: canStop ? "white" : "#1e2438",
278
+ border: canStop ? "none" : "1px solid #1e2438",
279
  borderRadius: 8, fontSize: 15,
280
  cursor: canStop ? "pointer" : "not-allowed",
281
  fontWeight: 600, marginBottom: 10,
282
+ boxShadow: canStop ? "0 0 24px rgba(29,78,216,0.3)" : "none",
283
  transition: "all 0.3s" }}>
284
  {canStop ? `Stop β€” save round ${round}` : `Stop (${MIN_SECONDS - elapsed}s left)`}
285
  </button>
 
290
  style={{ width: "100%", padding: "13px 24px", background: G,
291
  color: "white", border: "none", borderRadius: 8, fontSize: 15,
292
  cursor: "pointer", fontWeight: 600, marginBottom: 10,
293
+ boxShadow: "0 0 24px rgba(29,78,216,0.3)" }}>
294
  {round < 3 ? `Continue to Round ${round + 1} β†’` : "Submit voice enrollment"}
295
  </button>
296
  )}
 
298
  {(state === "idle" || state === "recording") && (
299
  <button onClick={onSkip} disabled={state === "recording"}
300
  style={{ width: "100%", padding: "10px 24px", background: "none",
301
+ color: state === "recording" ? "#1e2438" : "#4a4865",
302
  border: "none", fontSize: 13,
303
  cursor: state === "recording" ? "not-allowed" : "pointer" }}>
304
  Skip for now β€” analysis will still work, speaker detection may be less accurate
frontend/src/components/HistoryView.jsx CHANGED
@@ -1,14 +1,17 @@
1
  import { useState, useEffect } from "react"
2
  import api from "../lib/api"
 
3
 
4
  const CONTEXT_LABELS = {
5
- social: "Social", collaborative: "Collaborative", evaluative: "Evaluative",
6
- influential: "Influential", negotiation: "Negotiation", adversarial: "Adversarial",
7
- developmental: "Developmental", support: "Support", intimate: "Intimate",
8
- casual: "Casual", meeting: "Meeting", job_interview: "Job Interview",
9
- disagreement: "Disagreement", presentation: "Presentation",
10
- sales_call: "Sales Call", feedback_conversation: "Feedback",
11
- coaching_call: "Coaching", first_date: "First Date",
 
 
12
  }
13
 
14
  const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
@@ -21,7 +24,7 @@ function MiniScoreBar({ score }) {
21
  {Array.from({ length: 5 }).map((_, i) => (
22
  <div key={i} style={{
23
  width: 10, height: 4, borderRadius: 2,
24
- background: i < score ? color : "#2a2a42",
25
  }} />
26
  ))}
27
  </div>
@@ -94,9 +97,9 @@ export default function HistoryView({ onSelect, active = false }) {
94
  <button key={ctx} onClick={() => setContextFilter(ctx)}
95
  style={{ padding: "4px 12px", borderRadius: 20, fontSize: 11,
96
  cursor: "pointer", fontWeight: isActive ? 600 : 400,
97
- background: isActive ? "rgba(217,70,239,0.12)" : "#14141f",
98
- color: isActive ? "#e879f9" : "#8b89aa",
99
- border: isActive ? "1px solid rgba(217,70,239,0.3)" : "1px solid #2a2a42",
100
  transition: "all 0.15s" }}>
101
  {ctx === "all" ? "All" : (CONTEXT_LABELS[ctx] || ctx)}
102
  </button>
@@ -106,7 +109,7 @@ export default function HistoryView({ onSelect, active = false }) {
106
  </div>
107
 
108
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
109
- {filtered.map(s => {
110
  const dims = s.dimensions || {}
111
  const dimSummary = [
112
  { label: "Emotional", score: dims.emotional_state?.confidence?.score },
@@ -116,17 +119,10 @@ export default function HistoryView({ onSelect, active = false }) {
116
  const sessionTypes = getTypes(s)
117
 
118
  return (
119
- <div key={s.session_id}
120
- style={{ border: "1px solid #2a2a42", borderRadius: 12, padding: 16,
121
- background: "#14141f", transition: "border-color 0.15s, background 0.15s" }}
122
- onMouseEnter={e => {
123
- e.currentTarget.style.borderColor = "#3d3d60"
124
- e.currentTarget.style.background = "#1a1a2e"
125
- }}
126
- onMouseLeave={e => {
127
- e.currentTarget.style.borderColor = "#2a2a42"
128
- e.currentTarget.style.background = "#14141f"
129
- }}>
130
 
131
  <div onClick={() => onSelect({
132
  signals: s.signals, insights: s.insights,
@@ -136,7 +132,7 @@ export default function HistoryView({ onSelect, active = false }) {
136
  speaker_confirmed: s.speaker_confirmed || false,
137
  session_id: s.session_id,
138
  available_speakers: s.available_speakers || [],
139
- transcript: s.transcript || [],
140
  })} style={{ cursor: "pointer" }}>
141
 
142
  {/* Top row */}
@@ -146,10 +142,10 @@ export default function HistoryView({ onSelect, active = false }) {
146
  {sessionTypes.map((t, i) => (
147
  <span key={t} style={{
148
  fontSize: 11, fontWeight: i === 0 ? 700 : 500,
149
- color: i === 0 ? "#e879f9" : "#8b89aa",
150
  textTransform: "uppercase", letterSpacing: 0.4,
151
- background: i === 0 ? "rgba(217,70,239,0.1)" : "#1a1a2e",
152
- border: `1px solid ${i === 0 ? "rgba(217,70,239,0.25)" : "#2a2a42"}`,
153
  padding: "2px 8px", borderRadius: 4,
154
  }}>
155
  {CONTEXT_LABELS[t] || t}
@@ -188,7 +184,7 @@ export default function HistoryView({ onSelect, active = false }) {
188
  {/* Dimension score bars */}
189
  {dimSummary.length > 0 && (
190
  <div style={{ display: "flex", gap: 18, paddingTop: 10,
191
- borderTop: "1px solid #2a2a42" }}>
192
  {dimSummary.map(({ label, score }) => (
193
  <div key={label} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
194
  <span style={{ fontSize: 10, color: "#4a4865" }}>{label}</span>
@@ -200,7 +196,7 @@ export default function HistoryView({ onSelect, active = false }) {
200
  </div>
201
 
202
  {/* Delete controls */}
203
- <div style={{ marginTop: 10, paddingTop: 8, borderTop: "1px solid #2a2a42",
204
  display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8 }}>
205
  {confirmDeleteId === s.session_id ? (
206
  <>
@@ -227,18 +223,21 @@ export default function HistoryView({ onSelect, active = false }) {
227
  )}
228
  </div>
229
  </div>
 
230
  )
231
  })}
232
  </div>
233
 
234
  {sessions.length >= 3 && (
235
- <div style={{ marginTop: 20, padding: 14,
236
- background: "rgba(52,211,153,0.06)",
237
- border: "1px solid rgba(52,211,153,0.2)",
238
- borderRadius: 10, fontSize: 13, color: "#34d399" }}>
239
- ✨ {sessions.length} sessions recorded β€” your behavioral profile is active.
240
- Check the Profile tab.
241
- </div>
 
 
242
  )}
243
  </div>
244
  )
 
1
  import { useState, useEffect } from "react"
2
  import api from "../lib/api"
3
+ import Reveal from "./Reveal"
4
 
5
  const CONTEXT_LABELS = {
6
+ social: "Casual & Low-Stakes", collaborative: "Collaborative",
7
+ evaluative: "Interview & Review Β· High Stakes", influential: "Persuading & Pitching",
8
+ negotiation: "Negotiation", adversarial: "Conflict & Friction",
9
+ developmental: "Coaching & Feedback", support: "Supportive Listening",
10
+ intimate: "Deep Personal", casual: "Casual & Low-Stakes", meeting: "Meeting",
11
+ job_interview: "Interview & Review Β· High Stakes", disagreement: "Conflict & Friction",
12
+ presentation: "Interview & Review Β· High Stakes", sales_call: "Persuading & Pitching",
13
+ feedback_conversation: "Coaching & Feedback", coaching_call: "Coaching & Feedback",
14
+ first_date: "Deep Personal",
15
  }
16
 
17
  const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
 
24
  {Array.from({ length: 5 }).map((_, i) => (
25
  <div key={i} style={{
26
  width: 10, height: 4, borderRadius: 2,
27
+ background: i < score ? color : "#1e2438",
28
  }} />
29
  ))}
30
  </div>
 
97
  <button key={ctx} onClick={() => setContextFilter(ctx)}
98
  style={{ padding: "4px 12px", borderRadius: 20, fontSize: 11,
99
  cursor: "pointer", fontWeight: isActive ? 600 : 400,
100
+ background: isActive ? "rgba(29,78,216,0.12)" : "#151922",
101
+ color: isActive ? "#5b9cf6" : "#8b89aa",
102
+ border: isActive ? "1px solid rgba(29,78,216,0.3)" : "1px solid #1e2438",
103
  transition: "all 0.15s" }}>
104
  {ctx === "all" ? "All" : (CONTEXT_LABELS[ctx] || ctx)}
105
  </button>
 
109
  </div>
110
 
111
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
112
+ {filtered.map((s, i) => {
113
  const dims = s.dimensions || {}
114
  const dimSummary = [
115
  { label: "Emotional", score: dims.emotional_state?.confidence?.score },
 
119
  const sessionTypes = getTypes(s)
120
 
121
  return (
122
+ <Reveal key={s.session_id} delay={Math.min(i * 80, 280)}>
123
+ <div className="card"
124
+ style={{ border: "1px solid #1e2438", borderRadius: 12, padding: 16,
125
+ background: "#151922", boxShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
 
 
 
 
 
 
 
126
 
127
  <div onClick={() => onSelect({
128
  signals: s.signals, insights: s.insights,
 
132
  speaker_confirmed: s.speaker_confirmed || false,
133
  session_id: s.session_id,
134
  available_speakers: s.available_speakers || [],
135
+ fingerprint: s.fingerprint || null,
136
  })} style={{ cursor: "pointer" }}>
137
 
138
  {/* Top row */}
 
142
  {sessionTypes.map((t, i) => (
143
  <span key={t} style={{
144
  fontSize: 11, fontWeight: i === 0 ? 700 : 500,
145
+ color: i === 0 ? "#5b9cf6" : "#8b89aa",
146
  textTransform: "uppercase", letterSpacing: 0.4,
147
+ background: i === 0 ? "rgba(29,78,216,0.1)" : "#131827",
148
+ border: `1px solid ${i === 0 ? "rgba(29,78,216,0.25)" : "#1e2438"}`,
149
  padding: "2px 8px", borderRadius: 4,
150
  }}>
151
  {CONTEXT_LABELS[t] || t}
 
184
  {/* Dimension score bars */}
185
  {dimSummary.length > 0 && (
186
  <div style={{ display: "flex", gap: 18, paddingTop: 10,
187
+ borderTop: "1px solid #1e2438" }}>
188
  {dimSummary.map(({ label, score }) => (
189
  <div key={label} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
190
  <span style={{ fontSize: 10, color: "#4a4865" }}>{label}</span>
 
196
  </div>
197
 
198
  {/* Delete controls */}
199
+ <div style={{ marginTop: 10, paddingTop: 8, borderTop: "1px solid #1e2438",
200
  display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8 }}>
201
  {confirmDeleteId === s.session_id ? (
202
  <>
 
223
  )}
224
  </div>
225
  </div>
226
+ </Reveal>
227
  )
228
  })}
229
  </div>
230
 
231
  {sessions.length >= 3 && (
232
+ <Reveal>
233
+ <div style={{ marginTop: 20, padding: 14,
234
+ background: "rgba(52,211,153,0.06)",
235
+ border: "1px solid rgba(52,211,153,0.2)",
236
+ borderRadius: 10, fontSize: 13, color: "#34d399" }}>
237
+ ✨ {sessions.length} sessions recorded β€” your behavioral profile is active.
238
+ Check the Profile tab.
239
+ </div>
240
+ </Reveal>
241
  )}
242
  </div>
243
  )
frontend/src/components/HowItWorksView.jsx ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Reveal, { RevealItem } from "./Reveal"
2
+
3
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
4
+
5
+ const FEATURES = [
6
+ {
7
+ icon: "🎀",
8
+ color: "#5b9cf6",
9
+ title: "Real Conversation Analysis",
10
+ body: "We read what was actually said, not just extracted numbers. The AI identifies missed questions, interrupted thoughts, and moments you talked past each other.",
11
+ },
12
+ {
13
+ icon: "πŸ”„",
14
+ color: "#34d399",
15
+ title: "Mirror Feed",
16
+ body: "Patterns that only emerge across multiple sessions. After a few conversations, the mirror starts noticing what you consistently do β€” and don't do.",
17
+ },
18
+ {
19
+ icon: "🎯",
20
+ color: "#f59e0b",
21
+ title: "Context-Aware Insights",
22
+ body: "An interview is not a casual chat. We detect the type of conversation automatically and calibrate what good looks like for that specific setting.",
23
+ },
24
+ {
25
+ icon: "πŸ“Š",
26
+ color: "#818cf8",
27
+ title: "Your Behavioral Shape",
28
+ body: "Five dimensions tracked across all your sessions β€” Confidence, Assertiveness, Listening Quality, Composure, and Clarity β€” shown as a radar you can watch shift.",
29
+ },
30
+ {
31
+ icon: "πŸŽ™οΈ",
32
+ color: "#fb923c",
33
+ title: "Voice Recognition",
34
+ body: "Enroll your voice once. We identify which speaker is you across all future sessions automatically β€” no manual tagging needed.",
35
+ },
36
+ {
37
+ icon: "πŸ”’",
38
+ color: "#5b9cf6",
39
+ title: "Privacy by Design",
40
+ body: "Your audio is processed and discarded. Transcripts are analyzed and never stored. Only your behavioral patterns are kept β€” never your actual words.",
41
+ },
42
+ ]
43
+
44
+ const CONTEXT_TYPES = [
45
+ { label: "Interview & Review Β· High Stakes", desc: "Job interviews, performance reviews, presentations" },
46
+ { label: "Conflict & Friction", desc: "Disagreements, pushback, difficult conversations" },
47
+ { label: "Collaborative", desc: "Team meetings, brainstorming, joint problem-solving" },
48
+ { label: "Persuading & Pitching", desc: "Sales calls, pitches, convincing others" },
49
+ { label: "Negotiation", desc: "Deals, salary discussions, competing interests" },
50
+ { label: "Coaching & Feedback", desc: "Mentoring, giving criticism, guiding others" },
51
+ { label: "Supportive Listening", desc: "Emotional support, being there for someone" },
52
+ { label: "Deep Personal", desc: "Vulnerable, emotionally open conversations" },
53
+ { label: "Casual & Low-Stakes", desc: "Everyday chat, catching up, informal exchanges" },
54
+ ]
55
+
56
+ function Section({ label, title, subtitle, children }) {
57
+ return (
58
+ <div style={{ marginBottom: 64 }}>
59
+ {label && (
60
+ <div style={{ fontSize: 11, fontWeight: 700, color: "#5b9cf6",
61
+ textTransform: "uppercase", letterSpacing: 1.2, marginBottom: 10,
62
+ textAlign: "center" }}>
63
+ {label}
64
+ </div>
65
+ )}
66
+ {title && (
67
+ <h2 style={{ fontSize: 24, fontWeight: 800, color: "#f0eeff",
68
+ margin: "0 0 10px", letterSpacing: "-0.5px", textAlign: "center",
69
+ lineHeight: 1.25 }}>
70
+ {title}
71
+ </h2>
72
+ )}
73
+ {subtitle && (
74
+ <p style={{ fontSize: 14, color: "#6b6888", textAlign: "center",
75
+ margin: "0 0 32px", lineHeight: 1.75, maxWidth: 520, marginInline: "auto" }}>
76
+ {subtitle}
77
+ </p>
78
+ )}
79
+ {children}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ export default function HowItWorksView({ onBack }) {
85
+ return (
86
+ <div style={{ paddingBottom: 80 }} className="view-enter">
87
+
88
+ {/* Back */}
89
+ <button onClick={onBack}
90
+ style={{ background: "none", border: "none", cursor: "pointer",
91
+ color: "#4a4865", fontSize: 13, padding: 0, marginBottom: 40,
92
+ display: "flex", alignItems: "center", gap: 5 }}>
93
+ ← Back
94
+ </button>
95
+
96
+ {/* ── Hero ── */}
97
+ <Reveal>
98
+ <div style={{ textAlign: "center", marginBottom: 72 }}>
99
+ <div style={{ display: "flex", justifyContent: "center", marginBottom: 20 }}>
100
+ <svg width="52" height="52" viewBox="0 0 52 52" fill="none">
101
+ <defs>
102
+ <linearGradient id="hiw-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
103
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
104
+ </linearGradient>
105
+ </defs>
106
+ <rect x="2" y="20" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".35"/>
107
+ <rect x="11" y="13" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".6"/>
108
+ <rect x="20" y="6" width="8" height="20" rx="4" fill="url(#hiw-g)"/>
109
+ <rect x="31" y="13" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".6"/>
110
+ <rect x="40" y="20" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".35"/>
111
+ <line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
112
+ <rect x="2" y="29" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".15"/>
113
+ <rect x="11" y="29" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".27"/>
114
+ <rect x="20" y="29" width="8" height="20" rx="4" fill="url(#hiw-g)" opacity=".33"/>
115
+ <rect x="31" y="29" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".27"/>
116
+ <rect x="40" y="29" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".15"/>
117
+ </svg>
118
+ </div>
119
+ <h1 style={{ fontSize: 30, fontWeight: 800, margin: "0 0 6px",
120
+ letterSpacing: "-0.7px", lineHeight: 1.2, color: "#f0eeff" }}>
121
+ Your conversations,
122
+ </h1>
123
+ <h1 style={{ fontSize: 30, fontWeight: 800, margin: "0 0 20px",
124
+ letterSpacing: "-0.7px", lineHeight: 1.2,
125
+ background: G, WebkitBackgroundClip: "text",
126
+ WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
127
+ reflected back.
128
+ </h1>
129
+ <p style={{ fontSize: 14, color: "#6b6888", lineHeight: 1.85,
130
+ maxWidth: 460, margin: "0 auto" }}>
131
+ Most people go through hundreds of conversations without ever seeing their
132
+ own patterns. mirror changes that β€” not by telling you what to say,
133
+ but by showing you how you actually show up.
134
+ </p>
135
+ </div>
136
+ </Reveal>
137
+
138
+ {/* ── The Problem ── */}
139
+ <Reveal>
140
+ <Section label="The Problem" title="You can't see yourself clearly in the moment.">
141
+ <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
142
+ {[
143
+ <>In every conversation you're focused on what to say next. Nobody tells you that you interrupted four times, spoke for 70% of the conversation, or always go quiet when you're challenged.</>,
144
+ <><span style={{ color: "#c4c2d8", fontWeight: 600 }}>Good coaches cost hundreds per hour.</span> Therapy takes years. Most feedback from the people around you is filtered through politeness or filtered out entirely.</>,
145
+ <><span style={{ color: "#c4c2d8", fontWeight: 600 }}>That's why mirror exists.</span> Upload a recording and we show you exactly what's happening β€” not what you think is happening.</>,
146
+ ].map((text, i) => (
147
+ <p key={i} style={{ margin: 0, fontSize: 14, color: "#8b89aa",
148
+ lineHeight: 1.85, textAlign: "center", maxWidth: 540, marginInline: "auto" }}>
149
+ {text}
150
+ </p>
151
+ ))}
152
+ </div>
153
+ </Section>
154
+ </Reveal>
155
+
156
+ {/* ── How It Works ── */}
157
+ <Reveal>
158
+ <Section label="How It Works" title="It gets smarter with every session.">
159
+ <div style={{ display: "flex", flexDirection: "column",
160
+ maxWidth: 520, margin: "0 auto" }}>
161
+ {[
162
+ {
163
+ num: "1",
164
+ title: "Upload a recording",
165
+ body: "Any conversation β€” a meeting, a call, a catch-up. 2 to 20 minutes. Hindi or English. What you talked about doesn't matter. How you showed up does.",
166
+ },
167
+ {
168
+ num: "2",
169
+ title: "AI reads what was actually said",
170
+ body: "Not just numbers β€” the actual conversation. We find what you missed, what you repeated, how you listened, and where you lost the thread.",
171
+ },
172
+ {
173
+ num: "3",
174
+ title: "Your profile builds over time",
175
+ body: "One session gives immediate feedback. Three sessions reveals patterns. Ten sessions and the mirror knows your behavioral tendencies better than most people who know you.",
176
+ },
177
+ ].map(({ num, title, body }, i, arr) => (
178
+ <div key={num} style={{ display: "flex", gap: 20, position: "relative",
179
+ paddingBottom: i < arr.length - 1 ? 34 : 0 }}>
180
+ {i < arr.length - 1 && (
181
+ <div style={{ position: "absolute", left: 17, top: 42,
182
+ width: 2, height: "calc(100% - 14px)",
183
+ background: "linear-gradient(to bottom, rgba(29,78,216,0.25), transparent)" }} />
184
+ )}
185
+ <div style={{ width: 36, height: 36, borderRadius: "50%", flexShrink: 0,
186
+ background: "rgba(29,78,216,0.1)", border: "1.5px solid rgba(29,78,216,0.3)",
187
+ display: "flex", alignItems: "center", justifyContent: "center",
188
+ fontSize: 14, fontWeight: 700, color: "#5b9cf6", zIndex: 1 }}>
189
+ {num}
190
+ </div>
191
+ <div style={{ paddingTop: 5 }}>
192
+ <div style={{ fontSize: 14, fontWeight: 700, color: "#f0eeff",
193
+ marginBottom: 6 }}>{title}</div>
194
+ <div style={{ fontSize: 13, color: "#6b6888", lineHeight: 1.7 }}>{body}</div>
195
+ </div>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </Section>
200
+ </Reveal>
201
+
202
+ {/* ── Features Grid ── */}
203
+ <Reveal>
204
+ <Section label="What You Get" title="Everything the mirror sees."
205
+ subtitle="Each session adds signal. Here's what we track and what it builds into.">
206
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
207
+ {FEATURES.map(({ icon, color, title, body }, idx) => (
208
+ <RevealItem key={title} index={idx}>
209
+ <div className="card"
210
+ style={{ background: "#151922",
211
+ border: "1px solid #1e2438", borderRadius: 14, padding: "20px 18px" }}>
212
+ <div style={{ width: 42, height: 42, borderRadius: 11,
213
+ background: `${color}12`, border: `1px solid ${color}28`,
214
+ display: "flex", alignItems: "center", justifyContent: "center",
215
+ fontSize: 20, marginBottom: 14 }}>
216
+ {icon}
217
+ </div>
218
+ <div style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff",
219
+ marginBottom: 7, lineHeight: 1.35 }}>
220
+ {title}
221
+ </div>
222
+ <div style={{ fontSize: 12, color: "#6b6888", lineHeight: 1.65 }}>
223
+ {body}
224
+ </div>
225
+ </div>
226
+ </RevealItem>
227
+ ))}
228
+ </div>
229
+ </Section>
230
+ </Reveal>
231
+
232
+ {/* ── Context Types ── */}
233
+ <Reveal>
234
+ <Section label="Context Types" title="Every setting is different."
235
+ subtitle="We automatically detect what kind of conversation you had and adjust the feedback. The more types you record in, the fuller the picture.">
236
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
237
+ {CONTEXT_TYPES.map(({ label, desc }) => (
238
+ <div key={label} style={{ display: "flex", justifyContent: "space-between",
239
+ alignItems: "center", padding: "11px 16px",
240
+ background: "#151922", border: "1px solid #1e2438", borderRadius: 10,
241
+ gap: 12 }}>
242
+ <span style={{ fontSize: 13, fontWeight: 600, color: "#c4c2d8",
243
+ flexShrink: 0 }}>
244
+ {label}
245
+ </span>
246
+ <span style={{ fontSize: 12, color: "#4a4865", textAlign: "right" }}>
247
+ {desc}
248
+ </span>
249
+ </div>
250
+ ))}
251
+ </div>
252
+ </Section>
253
+ </Reveal>
254
+
255
+ {/* ── Privacy ── */}
256
+ <Reveal>
257
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
258
+ borderRadius: 14, padding: "28px 24px", textAlign: "center" }}>
259
+ <div style={{ fontSize: 32, marginBottom: 14 }}>πŸ”’</div>
260
+ <h3 style={{ fontSize: 17, fontWeight: 700, color: "#f0eeff",
261
+ margin: "0 0 10px" }}>
262
+ Privacy by design
263
+ </h3>
264
+ <p style={{ fontSize: 13, color: "#6b6888", lineHeight: 1.8, margin: 0,
265
+ maxWidth: 420, marginInline: "auto" }}>
266
+ Your audio is processed and immediately discarded. Transcripts are analyzed
267
+ and never stored. The only thing we keep is a behavioral summary β€”
268
+ never your actual words.
269
+ </p>
270
+ </div>
271
+ </Reveal>
272
+
273
+ </div>
274
+ )
275
+ }
frontend/src/components/OnboardingView.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState } from "react"
2
 
3
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
4
 
5
  const MOCK_DIMENSIONS = [
6
  { name: "Confidence", score: 68, label: "Assured" },
@@ -23,7 +23,7 @@ function MockDimBar({ name, score, label }) {
23
  {score}
24
  </span>
25
  </div>
26
- <div style={{ height: 4, background: "#2a2a42", borderRadius: 2 }}>
27
  <div style={{ height: "100%", borderRadius: 2,
28
  width: `${score}%`, background: G }} />
29
  </div>
@@ -34,7 +34,26 @@ function MockDimBar({ name, score, label }) {
34
  function StepConcept() {
35
  return (
36
  <div>
37
- <div style={{ fontSize: 44, textAlign: "center", marginBottom: 18 }}>πŸͺž</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  <h1 style={{ fontSize: 26, fontWeight: 700, color: "#f0eeff",
39
  margin: "0 0 14px", lineHeight: 1.25, textAlign: "center",
40
  letterSpacing: "-0.4px" }}>
@@ -43,7 +62,7 @@ function StepConcept() {
43
  <p style={{ fontSize: 14, color: "#8b89aa", lineHeight: 1.8,
44
  margin: "0 0 28px", textAlign: "center" }}>
45
  Most people go through hundreds of conversations without ever seeing their
46
- own patterns. Behavioural Mirror changes that β€” not by telling you what to
47
  say, but by reflecting how you actually show up.
48
  </p>
49
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
@@ -56,7 +75,7 @@ function StepConcept() {
56
  "Most people only get this from years of therapy or a very honest coach. This is a faster mirror."],
57
  ].map(([title, body]) => (
58
  <div key={title} style={{ display: "flex", gap: 14, padding: "14px 16px",
59
- background: "#14141f", border: "1px solid #2a2a42", borderRadius: 10 }}>
60
  <div style={{ width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
61
  background: G, marginTop: 5 }} />
62
  <div>
@@ -104,13 +123,13 @@ function StepHow() {
104
  {i < arr.length - 1 && (
105
  <div style={{ position: "absolute", left: 16, top: 38,
106
  width: 2, height: "calc(100% - 12px)",
107
- background: "#2a2a42", zIndex: 0 }} />
108
  )}
109
  <div style={{ width: 34, height: 34, borderRadius: "50%", flexShrink: 0,
110
- background: "rgba(217,70,239,0.1)",
111
- border: "1px solid rgba(217,70,239,0.3)",
112
  display: "flex", alignItems: "center", justifyContent: "center",
113
- fontSize: 13, fontWeight: 700, color: "#e879f9", zIndex: 1 }}>
114
  {num}
115
  </div>
116
  <div style={{ paddingBottom: i < arr.length - 1 ? 26 : 0 }}>
@@ -137,7 +156,7 @@ function StepPreview() {
137
  not generic advice.
138
  </p>
139
 
140
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
141
  borderRadius: 12, padding: "18px 20px", position: "relative" }}>
142
  <div style={{ position: "absolute", top: 12, right: 14, fontSize: 10,
143
  color: "#3a3a52", textTransform: "uppercase", letterSpacing: 0.6,
@@ -146,12 +165,29 @@ function StepPreview() {
146
  </div>
147
 
148
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
149
- <span style={{ fontSize: 14 }}>πŸͺž</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>The Mirror</span>
151
  </div>
152
 
153
  <p style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.85,
154
- margin: "0 0 18px", borderLeft: "2px solid rgba(217,70,239,0.35)",
155
  paddingLeft: 12 }}>
156
  You lead conversations confidently in collaborative settings, but pull back
157
  noticeably when you're being assessed. Your listening quality is consistently
@@ -164,7 +200,7 @@ function StepPreview() {
164
  </div>
165
 
166
  <div style={{ marginTop: 14, padding: "12px 14px",
167
- background: "#0e0e1a", border: "1px dashed #2a2a42", borderRadius: 8,
168
  display: "flex", gap: 12, alignItems: "flex-start" }}>
169
  <div style={{ fontSize: 13, color: "#4a4865", flexShrink: 0 }}>?</div>
170
  <div>
@@ -201,7 +237,7 @@ export default function OnboardingView({ onDone }) {
201
 
202
  return (
203
  <div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
204
- justifyContent: "center", padding: "32px 24px", background: "#09090f" }}>
205
  <div style={{ maxWidth: 500, width: "100%" }}>
206
 
207
  {/* Step indicators */}
@@ -213,7 +249,7 @@ export default function OnboardingView({ onDone }) {
213
  width: i === step ? 24 : 8,
214
  background: i <= step
215
  ? G
216
- : "#2a2a42",
217
  transition: "all 0.3s ease",
218
  }} />
219
  ))}
@@ -241,7 +277,7 @@ export default function OnboardingView({ onDone }) {
241
  onClick={step < STEPS.length - 1 ? () => setStep(s => s + 1) : finish}
242
  style={{ padding: "12px 28px", background: G, color: "white",
243
  border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
244
- fontWeight: 600, boxShadow: "0 0 24px rgba(217,70,239,0.25)" }}>
245
  {step < STEPS.length - 1 ? "Next β†’" : "Get started β†’"}
246
  </button>
247
  </div>
 
1
  import { useState } from "react"
2
 
3
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
4
 
5
  const MOCK_DIMENSIONS = [
6
  { name: "Confidence", score: 68, label: "Assured" },
 
23
  {score}
24
  </span>
25
  </div>
26
+ <div style={{ height: 4, background: "#1e2438", borderRadius: 2 }}>
27
  <div style={{ height: "100%", borderRadius: 2,
28
  width: `${score}%`, background: G }} />
29
  </div>
 
34
  function StepConcept() {
35
  return (
36
  <div>
37
+ <div style={{ display: "flex", justifyContent: "center", marginBottom: 18 }}>
38
+ <svg width="48" height="48" viewBox="0 0 52 52" fill="none">
39
+ <defs>
40
+ <linearGradient id="onb-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
41
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
42
+ </linearGradient>
43
+ </defs>
44
+ <rect x="2" y="20" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".35"/>
45
+ <rect x="11" y="13" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".6"/>
46
+ <rect x="20" y="6" width="8" height="20" rx="4" fill="url(#onb-g)"/>
47
+ <rect x="31" y="13" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".6"/>
48
+ <rect x="40" y="20" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".35"/>
49
+ <line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
50
+ <rect x="2" y="29" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".15"/>
51
+ <rect x="11" y="29" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".27"/>
52
+ <rect x="20" y="29" width="8" height="20" rx="4" fill="url(#onb-g)" opacity=".33"/>
53
+ <rect x="31" y="29" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".27"/>
54
+ <rect x="40" y="29" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".15"/>
55
+ </svg>
56
+ </div>
57
  <h1 style={{ fontSize: 26, fontWeight: 700, color: "#f0eeff",
58
  margin: "0 0 14px", lineHeight: 1.25, textAlign: "center",
59
  letterSpacing: "-0.4px" }}>
 
62
  <p style={{ fontSize: 14, color: "#8b89aa", lineHeight: 1.8,
63
  margin: "0 0 28px", textAlign: "center" }}>
64
  Most people go through hundreds of conversations without ever seeing their
65
+ own patterns. mirror. changes that β€” not by telling you what to
66
  say, but by reflecting how you actually show up.
67
  </p>
68
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
 
75
  "Most people only get this from years of therapy or a very honest coach. This is a faster mirror."],
76
  ].map(([title, body]) => (
77
  <div key={title} style={{ display: "flex", gap: 14, padding: "14px 16px",
78
+ background: "#151922", border: "1px solid #1e2438", borderRadius: 10 }}>
79
  <div style={{ width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
80
  background: G, marginTop: 5 }} />
81
  <div>
 
123
  {i < arr.length - 1 && (
124
  <div style={{ position: "absolute", left: 16, top: 38,
125
  width: 2, height: "calc(100% - 12px)",
126
+ background: "#1e2438", zIndex: 0 }} />
127
  )}
128
  <div style={{ width: 34, height: 34, borderRadius: "50%", flexShrink: 0,
129
+ background: "rgba(29,78,216,0.1)",
130
+ border: "1px solid rgba(29,78,216,0.3)",
131
  display: "flex", alignItems: "center", justifyContent: "center",
132
+ fontSize: 13, fontWeight: 700, color: "#5b9cf6", zIndex: 1 }}>
133
  {num}
134
  </div>
135
  <div style={{ paddingBottom: i < arr.length - 1 ? 26 : 0 }}>
 
156
  not generic advice.
157
  </p>
158
 
159
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
160
  borderRadius: 12, padding: "18px 20px", position: "relative" }}>
161
  <div style={{ position: "absolute", top: 12, right: 14, fontSize: 10,
162
  color: "#3a3a52", textTransform: "uppercase", letterSpacing: 0.6,
 
165
  </div>
166
 
167
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
168
+ <svg width="14" height="14" viewBox="0 0 32 32" fill="none" style={{ flexShrink: 0 }}>
169
+ <defs>
170
+ <linearGradient id="onb-sm-g" x1="0" y1="0" x2="32" y2="0" gradientUnits="userSpaceOnUse">
171
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
172
+ </linearGradient>
173
+ </defs>
174
+ <rect x="2" y="12" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".4"/>
175
+ <rect x="8" y="8" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".65"/>
176
+ <rect x="14" y="4" width="4" height="12" rx="2" fill="url(#onb-sm-g)"/>
177
+ <rect x="20" y="8" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".65"/>
178
+ <rect x="26" y="12" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".4"/>
179
+ <line x1="0" y1="17.5" x2="32" y2="17.5" stroke="#1e2438" strokeWidth="1"/>
180
+ <rect x="2" y="18" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".18"/>
181
+ <rect x="8" y="18" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".3"/>
182
+ <rect x="14" y="18" width="4" height="12" rx="2" fill="url(#onb-sm-g)" opacity=".38"/>
183
+ <rect x="20" y="18" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".3"/>
184
+ <rect x="26" y="18" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".18"/>
185
+ </svg>
186
  <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>The Mirror</span>
187
  </div>
188
 
189
  <p style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.85,
190
+ margin: "0 0 18px", borderLeft: "2px solid rgba(29,78,216,0.35)",
191
  paddingLeft: 12 }}>
192
  You lead conversations confidently in collaborative settings, but pull back
193
  noticeably when you're being assessed. Your listening quality is consistently
 
200
  </div>
201
 
202
  <div style={{ marginTop: 14, padding: "12px 14px",
203
+ background: "#0e1320", border: "1px dashed #1e2438", borderRadius: 8,
204
  display: "flex", gap: 12, alignItems: "flex-start" }}>
205
  <div style={{ fontSize: 13, color: "#4a4865", flexShrink: 0 }}>?</div>
206
  <div>
 
237
 
238
  return (
239
  <div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
240
+ justifyContent: "center", padding: "32px 24px", background: "#0f1117" }}>
241
  <div style={{ maxWidth: 500, width: "100%" }}>
242
 
243
  {/* Step indicators */}
 
249
  width: i === step ? 24 : 8,
250
  background: i <= step
251
  ? G
252
+ : "#1e2438",
253
  transition: "all 0.3s ease",
254
  }} />
255
  ))}
 
277
  onClick={step < STEPS.length - 1 ? () => setStep(s => s + 1) : finish}
278
  style={{ padding: "12px 28px", background: G, color: "white",
279
  border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
280
+ fontWeight: 600, boxShadow: "0 0 24px rgba(29,78,216,0.25)" }}>
281
  {step < STEPS.length - 1 ? "Next β†’" : "Get started β†’"}
282
  </button>
283
  </div>
frontend/src/components/ProfileView.jsx CHANGED
@@ -1,20 +1,24 @@
1
  import { useState, useEffect } from "react"
2
  import {
 
3
  LineChart, Line, XAxis, YAxis, Tooltip,
4
  ResponsiveContainer, CartesianGrid,
5
  } from "recharts"
6
  import api from "../lib/api"
 
7
 
8
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
9
 
10
  const CONTEXT_LABELS = {
11
- social: "Social", collaborative: "Collaborative", evaluative: "Evaluative",
12
- influential: "Influential", negotiation: "Negotiation", adversarial: "Adversarial",
13
- developmental: "Developmental", support: "Support", intimate: "Intimate",
14
- casual: "Casual", meeting: "Meeting", job_interview: "Job Interview",
15
- disagreement: "Disagreement", presentation: "Presentation",
16
- sales_call: "Sales Call", feedback_conversation: "Feedback",
17
- coaching_call: "Coaching", first_date: "First Date",
 
 
18
  }
19
 
20
  const SIGNAL_OPTIONS = [
@@ -26,193 +30,390 @@ const SIGNAL_OPTIONS = [
26
  { key: "response_latency", label: "Response Latency", unit: "s" },
27
  ]
28
 
29
- const CONTEXT_COLORS = {
30
- social: "#818cf8", collaborative: "#a78bfa", evaluative: "#f87171",
31
- influential: "#f59e0b", negotiation: "#22d3ee", adversarial: "#fb923c",
32
- developmental: "#34d399", support: "#2dd4bf", intimate: "#f472b6",
33
- casual: "#818cf8", meeting: "#a78bfa", job_interview: "#f87171",
34
- disagreement: "#fb923c", presentation: "#34d399",
35
- sales_call: "#f59e0b", feedback_conversation: "#818cf8",
36
- coaching_call: "#2dd4bf", first_date: "#f472b6",
37
  }
38
 
39
- const PATTERN_ICONS = {
40
- consistently_high: "↑", consistently_low: "↓",
41
- trending_improved: "β†—", trending_declined: "β†˜",
 
 
 
42
  }
43
 
44
- function DimensionCard({ dim }) {
45
- const [expanded, setExpanded] = useState(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  return (
47
- <div style={{ background: "#0e0e1a", border: "1px solid #2a2a42",
48
- borderRadius: 10, padding: "14px 16px", transition: "border-color 0.15s",
49
- borderColor: expanded ? "rgba(217,70,239,0.35)" : "#2a2a42" }}>
50
- <div onClick={() => setExpanded(!expanded)}
51
- style={{ display: "flex", justifyContent: "space-between",
52
- alignItems: "center", cursor: "pointer", userSelect: "none" }}>
53
- <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
54
- <span style={{ fontSize: 13, fontWeight: 600, color: "#f0eeff" }}>{dim.name}</span>
55
- <span style={{ fontSize: 11, color: "#4a4865" }}>{dim.label}</span>
56
- </div>
57
- <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
58
- <span style={{ fontSize: 15, fontWeight: 700,
59
- background: G, WebkitBackgroundClip: "text",
60
- WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
61
- {dim.score}
62
- </span>
63
- <span style={{ fontSize: 10, color: "#4a4865" }}>{expanded ? "β–²" : "β–Ό"}</span>
64
- </div>
65
- </div>
66
- <div style={{ height: 4, background: "#2a2a42", borderRadius: 2, margin: "10px 0 0" }}>
67
- <div style={{
68
- height: "100%", borderRadius: 2,
69
- width: `${dim.score}%`,
70
- background: G,
71
- transition: "width 0.6s ease",
72
- boxShadow: "0 0 6px rgba(217,70,239,0.3)",
73
- }} />
74
- </div>
75
- {expanded && dim.narrative && (
76
- <div style={{ fontSize: 13, color: "#8b89aa", lineHeight: 1.75,
77
- marginTop: 12, borderTop: "1px solid #2a2a42", paddingTop: 12 }}>
78
- {dim.narrative}
79
- </div>
80
- )}
81
  </div>
82
  )
83
  }
84
 
85
- function MirrorFeed({ sessions }) {
86
- const [expanded, setExpanded] = useState(false)
87
- if (!sessions || sessions.length === 0) return null
88
- const latest = sessions[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  return (
91
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
92
- borderRadius: 12, overflow: "hidden" }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- {/* Header */}
95
- <div onClick={() => setExpanded(v => !v)}
96
- style={{ padding: "14px 18px", display: "flex", justifyContent: "space-between",
97
- alignItems: "center", cursor: "pointer",
98
- borderBottom: expanded ? "1px solid #2a2a42" : "none" }}>
99
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
100
- <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>Mirror Feed</span>
101
- <span style={{ fontSize: 11, color: "#4a4865", background: "#1a1a2e",
102
- border: "1px solid #2a2a42", borderRadius: 10, padding: "1px 7px" }}>
103
- {sessions.length} session{sessions.length > 1 ? "s" : ""}
104
- </span>
105
- </div>
106
- <span style={{ fontSize: 11, color: "#4a4865" }}>
107
- {expanded ? "β–² close" : "β–Ό expand"}
108
- </span>
109
  </div>
 
 
 
 
 
 
 
 
110
 
111
- {!expanded ? (
112
- /* Collapsed: most recent session β€” 2 full + 3rd fading */
113
- <div style={{ padding: "14px 18px 16px" }}>
114
- <div style={{ fontSize: 11, marginBottom: 12,
115
- display: "flex", alignItems: "center", gap: 6 }}>
116
- <span style={{ color: "#4a4865" }}>{latest.date}</span>
117
- <span style={{ color: "#2a2a42" }}>Β·</span>
118
- <span style={{ color: CONTEXT_COLORS[latest.context] || "#8b89aa", fontWeight: 500 }}>
119
- {CONTEXT_LABELS[latest.context] || latest.context}
120
- </span>
121
- </div>
122
 
123
- {latest.highlights[0] && (
124
- <div style={{ display: "flex", gap: 10, marginBottom: 10, alignItems: "flex-start" }}>
125
- <span style={{ color: "#3a3a52", fontSize: 13, marginTop: 2, flexShrink: 0 }}>β€”</span>
126
- <span style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>
127
- {latest.highlights[0]}
128
- </span>
129
- </div>
130
- )}
131
 
132
- {latest.highlights[1] && (
133
- <div style={{ display: "flex", gap: 10, marginBottom: 10, alignItems: "flex-start" }}>
134
- <span style={{ color: "#3a3a52", fontSize: 13, marginTop: 2, flexShrink: 0 }}>β€”</span>
135
- <span style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>
136
- {latest.highlights[1]}
137
- </span>
138
- </div>
139
- )}
140
 
141
- {/* 3rd pointer β€” partial fade peek */}
142
- {latest.highlights[2] && (
143
- <div style={{ maxHeight: 22, overflow: "hidden",
144
- maskImage: "linear-gradient(to bottom, rgba(0,0,0,0.65) 0%, transparent 100%)",
145
- WebkitMaskImage: "linear-gradient(to bottom, rgba(0,0,0,0.65) 0%, transparent 100%)" }}>
146
- <div style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
147
- <span style={{ color: "#3a3a52", fontSize: 13, marginTop: 2, flexShrink: 0 }}>β€”</span>
148
- <span style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>
149
- {latest.highlights[2]}
150
- </span>
151
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  </div>
153
- )}
154
- </div>
155
- ) : (
156
- /* Expanded: scrollable across all sessions */
157
- <div style={{ maxHeight: 440, overflowY: "auto", padding: "16px 18px",
158
- scrollbarWidth: "thin", scrollbarColor: "#2a2a42 transparent" }}>
159
- {sessions.map((s, si) => (
160
- <div key={si} style={{ marginBottom: si < sessions.length - 1 ? 20 : 0 }}>
161
- <div style={{ fontSize: 11, marginBottom: 10,
162
- display: "flex", alignItems: "center", gap: 6 }}>
163
- <span style={{ color: "#4a4865" }}>{s.date}</span>
164
- <span style={{ color: "#2a2a42" }}>Β·</span>
165
- <span style={{ color: CONTEXT_COLORS[s.context] || "#8b89aa", fontWeight: 500 }}>
166
- {CONTEXT_LABELS[s.context] || s.context}
167
- </span>
168
  </div>
169
- {s.highlights.map((h, hi) => (
170
- <div key={hi} style={{ display: "flex", gap: 10, marginBottom: 10,
171
- alignItems: "flex-start" }}>
172
- <span style={{ color: "#3a3a52", fontSize: 13, marginTop: 2, flexShrink: 0 }}>β€”</span>
173
- <span style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>{h}</span>
174
- </div>
175
- ))}
176
- {si < sessions.length - 1 && (
177
- <div style={{ height: 1, background: "#1e1e2e", margin: "0 0 0" }} />
178
- )}
179
  </div>
180
- ))}
 
 
 
 
 
 
181
  </div>
182
- )}
183
  </div>
184
  )
185
  }
186
 
187
- function StatCard({ label, value, unit, sub }) {
 
 
 
 
 
 
188
  return (
189
- <div style={{ textAlign: "center", padding: "16px 10px",
190
- border: "1px solid #2a2a42", borderRadius: 10, background: "#14141f" }}>
191
- <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>{label}</div>
192
- <div style={{ fontSize: 22, fontWeight: 700,
193
- background: G, WebkitBackgroundClip: "text",
194
- WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
195
- {value}<span style={{ fontSize: 13, fontWeight: 500 }}>{unit}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  </div>
197
- {sub && <div style={{ fontSize: 11, color: "#4a4865", marginTop: 3 }}>{sub}</div>}
198
  </div>
199
  )
200
  }
201
 
202
- function SectionHeader({ title, sub }) {
 
 
 
 
 
 
 
 
203
  return (
204
- <div style={{ marginBottom: 12 }}>
205
- <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>{title}</h2>
206
- {sub && <p style={{ fontSize: 12, color: "#4a4865", margin: "4px 0 0" }}>{sub}</p>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  </div>
208
  )
209
  }
210
 
 
 
211
  export default function ProfileView({ active, onUpload }) {
212
  const [profile, setProfile] = useState(null)
213
  const [trends, setTrends] = useState([])
214
  const [loading, setLoading] = useState(true)
215
- const [activeSignal, setActiveSignal] = useState("talk_ratio")
216
 
217
  useEffect(() => {
218
  if (!active) return
@@ -233,46 +434,48 @@ export default function ProfileView({ active, onUpload }) {
233
  )
234
 
235
  if (!profile || profile.insufficient_data) {
236
- const count = profile?.session_count || 0
237
- const needed = 3 - count
238
  return (
239
  <div style={{ textAlign: "center", padding: "60px 20px" }}>
240
- <div style={{ fontSize: 52, marginBottom: 16 }}>πŸͺž</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  <h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px", color: "#f0eeff" }}>
242
- Your mirror is warming up
243
  </h2>
244
- <p style={{ fontSize: 14, color: "#8b89aa", margin: "0 0 6px", lineHeight: 1.6 }}>
245
- {count === 0
246
- ? "Upload your first conversation to get started."
247
- : `${count} session${count > 1 ? "s" : ""} recorded. Upload ${needed} more to unlock your behavioral profile.`}
248
  </p>
249
- {count > 0 && (
250
- <div style={{ display: "flex", justifyContent: "center", gap: 10,
251
- margin: "20px 0 28px" }}>
252
- {Array.from({ length: 3 }).map((_, i) => (
253
- <div key={i} style={{
254
- width: 12, height: 12, borderRadius: "50%",
255
- background: i < count ? G : "#2a2a42",
256
- boxShadow: i < count ? "0 0 8px rgba(217,70,239,0.4)" : "none",
257
- }} />
258
- ))}
259
- </div>
260
- )}
261
- <button onClick={onUpload}
262
  style={{ padding: "12px 28px", background: G, color: "white",
263
  border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
264
- fontWeight: 600, boxShadow: "0 0 24px rgba(217,70,239,0.3)" }}>
265
  Upload a conversation
266
  </button>
267
  </div>
268
  )
269
  }
270
 
271
- const { overall, by_context, patterns, trends: trendLines,
272
- recurring_coaching, session_count, personality, blind_spots,
273
- completeness, completeness_label, session_highlights } = profile
274
 
275
- const signalConfig = SIGNAL_OPTIONS.find(s => s.key === activeSignal)
276
 
277
  const chartData = trends.map((point, i) => ({
278
  ...point,
@@ -280,355 +483,202 @@ export default function ProfileView({ active, onUpload }) {
280
  date: new Date(point.date).toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
281
  }))
282
 
 
 
283
  return (
284
- <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
285
 
286
  {/* Header */}
287
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
288
- <div>
 
289
  <h2 style={{ fontSize: 16, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
290
  Your Behavioral Profile
291
  </h2>
292
- <p style={{ fontSize: 12, color: "#4a4865", margin: "4px 0 0" }}>
293
- Based on {session_count} session{session_count > 1 ? "s" : ""}
294
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  </div>
296
- <button onClick={onUpload}
 
297
  style={{ padding: "8px 18px", background: G, color: "white",
298
  border: "none", borderRadius: 7, fontSize: 13, cursor: "pointer",
299
- fontWeight: 600, boxShadow: "0 0 18px rgba(217,70,239,0.25)" }}>
 
300
  + Upload
301
  </button>
302
  </div>
 
303
 
304
- {/* Completeness bar */}
305
- {completeness != null && (
306
- <div style={{ padding: "14px 16px", background: "#14141f",
307
- border: "1px solid #2a2a42", borderRadius: 10 }}>
308
- <div style={{ display: "flex", justifyContent: "space-between",
309
- alignItems: "baseline", marginBottom: 8 }}>
310
- <span style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa" }}>
311
- Mirror depth β€” {completeness_label}
312
- </span>
313
- <span style={{ fontSize: 13, fontWeight: 700,
314
- background: G, WebkitBackgroundClip: "text",
315
- WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
316
- {completeness}%
317
- </span>
318
- </div>
319
- <div style={{ height: 5, background: "#2a2a42", borderRadius: 3 }}>
320
- <div style={{
321
- height: "100%", borderRadius: 3,
322
- width: `${completeness}%`,
323
- background: G,
324
- boxShadow: "0 0 8px rgba(217,70,239,0.3)",
325
- transition: "width 0.6s ease",
326
- }} />
327
- </div>
328
- <div style={{ fontSize: 11, color: "#4a4865", marginTop: 7 }}>
329
- {completeness < 100
330
- ? "Add more sessions and cover different conversation types to deepen the model."
331
- : "The mirror has a strong picture of who you are across contexts."}
332
- </div>
333
  </div>
334
- )}
 
 
335
 
336
- {/* The Mirror */}
337
  {personality && (
338
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
339
- borderRadius: 12, padding: "20px 22px" }}>
340
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
341
- <span style={{ fontSize: 15 }}>πŸͺž</span>
342
- <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
343
- The Mirror
344
- </h2>
345
- </div>
346
- <p style={{ fontSize: 14, color: "#c4c2d8", lineHeight: 1.85, margin: "0 0 20px",
347
- borderLeft: "2px solid rgba(217,70,239,0.35)", paddingLeft: 14 }}>
348
- {personality.paragraph}
349
- </p>
350
-
351
- {/* Session delta */}
352
- {personality.last_delta?.changes?.length > 0 && (
353
- <div style={{ marginBottom: 18, padding: "10px 14px",
354
- background: "rgba(217,70,239,0.06)", border: "1px solid rgba(217,70,239,0.2)",
355
- borderRadius: 8 }}>
356
- <div style={{ fontSize: 11, fontWeight: 600, color: "#a855f7",
357
- textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8 }}>
358
- What changed after your last session
359
- </div>
360
- <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
361
- {personality.last_delta.changes.map((c, i) => (
362
- <div key={i} style={{
363
- display: "flex", alignItems: "center", gap: 5,
364
- padding: "4px 10px", borderRadius: 20, fontSize: 12, fontWeight: 600,
365
- background: c.direction === "up"
366
- ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
367
- border: `1px solid ${c.direction === "up"
368
- ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
369
- color: c.direction === "up" ? "#34d399" : "#f87171",
370
- }}>
371
- <span>{c.direction === "up" ? "↑" : "↓"}</span>
372
- <span>{c.dimension}</span>
373
- <span style={{ opacity: 0.7 }}>
374
- {c.direction === "up" ? "+" : ""}{c.diff} pts
375
  </span>
376
- </div>
377
- ))}
378
- </div>
379
  </div>
380
- )}
381
-
382
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
383
- {personality.dimensions?.map(dim => (
384
- <DimensionCard key={dim.key} dim={dim} />
385
- ))}
386
  </div>
387
  </div>
 
388
  )}
389
 
390
- {/* Mirror Feed */}
391
- <MirrorFeed sessions={session_highlights} />
392
-
393
- {/* Averages */}
394
- <div>
395
- <SectionHeader title="Your Averages" sub="Across all sessions" />
396
- <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
397
- <StatCard label="Talk Ratio" value={overall.talk_ratio} unit="%" sub="avg across sessions" />
398
- <StatCard label="Speech Rate" value={overall.wpm} unit=" wpm" sub="words per minute" />
399
- <StatCard label="Filler Rate" value={overall.filler_rate} unit="/100w" sub="avg filler words" />
400
- <StatCard label="Interruptions" value={overall.interruptions_given} unit="x" sub="per session avg" />
401
- <StatCard label="Response Latency" value={overall.response_latency} unit="s" sub="avg pause before reply" />
402
- <StatCard label="Silence" value={overall.silence_ratio} unit="%" sub="of conversation" />
403
- </div>
404
- </div>
405
-
406
- {/* Consistent patterns */}
407
- {patterns?.length > 0 && (
408
  <div>
409
- <SectionHeader title="Consistent Patterns"
410
- sub="Behaviors that show up repeatedly across your sessions" />
411
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
412
- {patterns.map((p, i) => (
413
- <div key={i} style={{ display: "flex", gap: 12, alignItems: "flex-start",
414
- padding: "12px 14px", background: "#14141f",
415
- border: "1px solid #2a2a42", borderRadius: 10 }}>
416
- <span style={{ fontSize: 18, lineHeight: 1, marginTop: 1,
417
- background: G, WebkitBackgroundClip: "text",
418
- WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
419
- {PATTERN_ICONS[p.type] || "β€’"}
420
- </span>
421
- <div>
422
- <div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
423
- textTransform: "uppercase", letterSpacing: 0.4, marginBottom: 3 }}>
424
- {p.signal.replace(/_/g, " ")}
425
- </div>
426
- <div style={{ fontSize: 13, color: "#f0eeff" }}>{p.detail}</div>
427
- </div>
428
- </div>
429
- ))}
430
  </div>
 
431
  </div>
 
432
  )}
433
 
434
- {/* Signal trends */}
435
- {chartData.length >= 2 && (
 
436
  <div>
437
- <SectionHeader title="Signal Trends"
438
- sub="How your communication patterns change over time" />
439
-
440
- <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
441
- {SIGNAL_OPTIONS.map(s => {
442
- const isActive = activeSignal === s.key
443
- return (
444
- <button key={s.key} onClick={() => setActiveSignal(s.key)}
445
- style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
446
- cursor: "pointer", fontWeight: isActive ? 600 : 400,
447
- background: isActive ? "rgba(217,70,239,0.12)" : "#14141f",
448
- color: isActive ? "#e879f9" : "#8b89aa",
449
- border: isActive ? "1px solid rgba(217,70,239,0.3)" : "1px solid #2a2a42",
450
- transition: "all 0.15s" }}>
451
- {s.label}
452
- </button>
453
- )
454
- })}
455
  </div>
456
 
457
- {trendLines?.length > 0 && (
458
- <div style={{ marginBottom: 10 }}>
459
- {trendLines.filter(t => t.signal === activeSignal).map((t, i) => (
460
- <div key={i} style={{
461
- display: "inline-flex", alignItems: "center", gap: 6,
462
- padding: "4px 12px", borderRadius: 20, fontSize: 12, marginBottom: 8,
463
- background: t.direction === "improved"
464
- ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
465
- border: `1px solid ${t.direction === "improved"
466
- ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
467
- color: t.direction === "improved" ? "#34d399" : "#f87171",
468
- }}>
469
- {t.direction === "improved" ? "β†—" : "β†˜"}
470
- {signalConfig?.label} {t.direction}: {t.old}{signalConfig?.unit} β†’ {t.new}{signalConfig?.unit}
471
- </div>
472
- ))}
473
- </div>
474
- )}
475
 
476
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
477
- borderRadius: 10, padding: 16 }}>
478
- <ResponsiveContainer width="100%" height={200}>
479
- <LineChart data={chartData}>
480
- <CartesianGrid strokeDasharray="3 3" stroke="#2a2a42" />
481
- <XAxis dataKey="label" fontSize={11} tick={{ fill: "#8b89aa" }}
482
- axisLine={{ stroke: "#2a2a42" }} tickLine={{ stroke: "#2a2a42" }} />
483
- <YAxis fontSize={11} tick={{ fill: "#8b89aa" }} width={38}
484
- axisLine={{ stroke: "#2a2a42" }} tickLine={{ stroke: "#2a2a42" }}
485
- tickFormatter={v => `${v}${signalConfig?.unit || ""}`} />
486
- <Tooltip
487
- contentStyle={{ background: "#14141f", border: "1px solid #2a2a42",
488
- borderRadius: 8 }}
489
- labelStyle={{ color: "#8b89aa" }}
490
- itemStyle={{ color: "#f0eeff" }}
491
- formatter={(v) => [`${v}${signalConfig?.unit || ""}`, signalConfig?.label]}
492
- labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ""}
493
- />
494
- <Line type="monotone" dataKey={activeSignal}
495
- stroke="#d946ef" strokeWidth={2.5}
496
- dot={{ r: 4, fill: "#d946ef", strokeWidth: 0 }}
497
- activeDot={{ r: 6, fill: "#f97316" }}
498
- />
499
- </LineChart>
500
- </ResponsiveContainer>
501
  </div>
502
- </div>
503
- )}
504
 
505
- {/* You across contexts */}
506
- {by_context && Object.keys(by_context).length > 0 && (
507
- <div>
508
- <SectionHeader title="You Across Contexts"
509
- sub="Your averages broken down by conversation type" />
510
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
511
- {Object.entries(by_context).map(([ctx, data]) => (
512
- <div key={ctx} style={{ padding: "14px 16px", background: "#14141f",
513
- border: "1px solid #2a2a42", borderRadius: 10 }}>
514
- <div style={{ display: "flex", justifyContent: "space-between",
515
- alignItems: "center", marginBottom: 10 }}>
516
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
517
- <div style={{ width: 8, height: 8, borderRadius: "50%",
518
- background: CONTEXT_COLORS[ctx] || "#8b89aa",
519
- boxShadow: `0 0 6px ${CONTEXT_COLORS[ctx] || "#8b89aa"}60` }} />
520
- <span style={{ fontSize: 13, fontWeight: 600, color: "#f0eeff" }}>
521
- {CONTEXT_LABELS[ctx] || ctx}
522
- </span>
523
- </div>
524
- <span style={{ fontSize: 11, color: "#4a4865" }}>
525
- {data.count} session{data.count > 1 ? "s" : ""}
526
- </span>
527
- </div>
528
- <div style={{ display: "flex", gap: 24 }}>
529
- {[
530
- { label: "Talk", value: `${data.talk_ratio}%` },
531
- { label: "WPM", value: `${data.wpm}` },
532
- { label: "Fillers", value: `${data.filler_rate}/100w` },
533
- ].map(({ label, value }) => (
534
- <div key={label}>
535
- <div style={{ fontSize: 11, color: "#4a4865" }}>{label}</div>
536
- <div style={{ fontSize: 14, fontWeight: 600, color: "#f0eeff" }}>{value}</div>
537
- </div>
538
- ))}
539
- </div>
540
  </div>
541
- ))}
542
- </div>
 
 
 
543
  </div>
 
544
  )}
545
 
546
- {/* Growth tracking */}
547
- {recurring_coaching?.length > 0 && (
548
- <div>
549
- <SectionHeader title="Growth Tracking"
550
- sub="Areas that keep coming up in your coaching β€” watch these" />
551
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
552
- {recurring_coaching.map((item, i) => {
553
- const intensity = Math.min(item.count / session_count, 1)
554
- const barColor = intensity > 0.6 ? "#f87171" : intensity > 0.3 ? "#f59e0b" : "#34d399"
555
- return (
556
- <div key={i} style={{ padding: "12px 16px", background: "#14141f",
557
- border: "1px solid #2a2a42", borderRadius: 10,
558
- borderLeft: `3px solid ${barColor}` }}>
559
- <div style={{ display: "flex", justifyContent: "space-between",
560
- alignItems: "center", marginBottom: 10 }}>
561
- <span style={{ fontSize: 13, fontWeight: 600, color: "#f0eeff" }}>
562
- {item.area}
563
- </span>
564
- <span style={{ fontSize: 11, color: "#4a4865" }}>
565
- flagged in {item.count}/{session_count} sessions
566
- </span>
567
- </div>
568
- <div style={{ height: 4, background: "#2a2a42",
569
- borderRadius: 2, overflow: "hidden" }}>
570
- <div style={{
571
- height: "100%", borderRadius: 2,
572
- width: `${Math.round(intensity * 100)}%`,
573
- background: barColor,
574
- boxShadow: `0 0 6px ${barColor}60`,
575
- }} />
576
- </div>
577
- </div>
578
- )
579
- })}
580
- </div>
581
- </div>
582
  )}
583
 
584
- {(!patterns || patterns.length === 0) &&
585
- (!recurring_coaching || recurring_coaching.length === 0) && (
586
- <div style={{ background: "#14141f", borderRadius: 10, padding: 20,
587
- textAlign: "center", border: "1px solid #2a2a42" }}>
588
- <p style={{ fontSize: 13, color: "#8b89aa", margin: 0, lineHeight: 1.6 }}>
589
- Patterns will appear as you add more sessions.
590
- Keep uploading conversations to build a richer picture.
591
- </p>
592
- </div>
593
  )}
594
 
595
- {/* Blind spots */}
596
- {blind_spots?.length > 0 && (
597
- <div>
598
- <SectionHeader title="What the mirror can't see yet"
599
- sub="Upload these conversation types to fill in the gaps" />
600
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
601
- {blind_spots.map((spot, i) => (
602
- <div key={i} style={{ display: "flex", gap: 14, alignItems: "flex-start",
603
- padding: "14px 16px", background: "#14141f",
604
- border: "1px solid #2a2a42", borderRadius: 10 }}>
605
- <div style={{ width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
606
- border: "1.5px dashed #3a3a52",
607
- display: "flex", alignItems: "center", justifyContent: "center",
608
- fontSize: 13, color: "#4a4865", marginTop: 1 }}>
609
- ?
610
- </div>
611
- <div>
612
- <div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
613
- textTransform: "uppercase", letterSpacing: 0.4, marginBottom: 4 }}>
614
- {spot.label}
615
- </div>
616
- <div style={{ fontSize: 13, color: "#6b6888", lineHeight: 1.65 }}>
617
- {spot.message}
618
- </div>
619
- </div>
620
- </div>
621
- ))}
622
- </div>
623
- <button onClick={onUpload}
624
- style={{ marginTop: 12, width: "100%", padding: "10px 0",
625
- background: "transparent", color: "#8b89aa",
626
- border: "1px dashed #3a3a52", borderRadius: 8,
627
- fontSize: 13, cursor: "pointer", fontWeight: 500 }}>
628
- Upload a conversation β†’
629
- </button>
630
- </div>
631
- )}
632
  </div>
633
  )
634
  }
 
1
  import { useState, useEffect } from "react"
2
  import {
3
+ RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
4
  LineChart, Line, XAxis, YAxis, Tooltip,
5
  ResponsiveContainer, CartesianGrid,
6
  } from "recharts"
7
  import api from "../lib/api"
8
+ import Reveal, { RevealItem } from "./Reveal"
9
 
10
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
11
 
12
  const CONTEXT_LABELS = {
13
+ social: "Casual & Low-Stakes", collaborative: "Collaborative",
14
+ evaluative: "Interview & Review Β· High Stakes", influential: "Persuading & Pitching",
15
+ negotiation: "Negotiation", adversarial: "Conflict & Friction",
16
+ developmental: "Coaching & Feedback", support: "Supportive Listening",
17
+ intimate: "Deep Personal", casual: "Casual & Low-Stakes", meeting: "Meeting",
18
+ job_interview: "Interview & Review Β· High Stakes", disagreement: "Conflict & Friction",
19
+ presentation: "Interview & Review Β· High Stakes", sales_call: "Persuading & Pitching",
20
+ feedback_conversation: "Coaching & Feedback", coaching_call: "Coaching & Feedback",
21
+ first_date: "Deep Personal",
22
  }
23
 
24
  const SIGNAL_OPTIONS = [
 
30
  { key: "response_latency", label: "Response Latency", unit: "s" },
31
  ]
32
 
33
+ const TALK_RATIO_NORMS = {
34
+ evaluative: [55, 80], collaborative: [30, 55], social: [35, 65],
35
+ influential: [48, 68], negotiation: [35, 55], adversarial: [35, 55],
36
+ developmental: [25, 45], support: [15, 40], intimate: [35, 60],
 
 
 
 
37
  }
38
 
39
+ const DIMENSION_KEYWORDS = {
40
+ confidence: s => s >= 65 ? "Confident" : s >= 40 ? "Measured" : "Cautious",
41
+ assertiveness: s => s >= 65 ? "Direct" : s >= 40 ? "Balanced" : "Reserved",
42
+ listening: s => s >= 65 ? "Present" : s >= 40 ? "Attentive" : "Directive",
43
+ composure: s => s >= 65 ? "Composed" : s >= 40 ? "Steady" : "Reactive",
44
+ clarity: s => s >= 65 ? "Clear" : s >= 40 ? "Articulate" : "Complex",
45
  }
46
 
47
+ function deriveKeywords(dimensions) {
48
+ if (!dimensions?.length) return []
49
+ return [...dimensions]
50
+ .sort((a, b) => b.score - a.score)
51
+ .slice(0, 3)
52
+ .map(d => DIMENSION_KEYWORDS[d.key]?.(d.score))
53
+ .filter(Boolean)
54
+ }
55
+
56
+ function useCountUp(target, duration = 900) {
57
+ const [value, setValue] = useState(0)
58
+ useEffect(() => {
59
+ if (!target) return
60
+ const start = Date.now()
61
+ const tick = () => {
62
+ const elapsed = Date.now() - start
63
+ const progress = Math.min(elapsed / duration, 1)
64
+ const eased = 1 - Math.pow(1 - progress, 3)
65
+ setValue(Math.round(eased * target))
66
+ if (progress < 1) requestAnimationFrame(tick)
67
+ }
68
+ requestAnimationFrame(tick)
69
+ }, [target])
70
+ return value
71
+ }
72
+
73
+ // ── Section label ─────────────────────────────────────────────────
74
+
75
+ function SectionLabel({ children }) {
76
  return (
77
+ <div style={{
78
+ fontSize: 10, fontWeight: 700, letterSpacing: 1.4,
79
+ textTransform: "uppercase", marginBottom: 4,
80
+ background: G, WebkitBackgroundClip: "text",
81
+ WebkitTextFillColor: "transparent", backgroundClip: "text",
82
+ }}>
83
+ {children}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
  )
86
  }
87
 
88
+ // ── Mirror Feed ───────────────────────────────────────────────────
89
+
90
+ const FEED_TYPE_CONFIG = {
91
+ context_contrast: { color: "#818cf8", label: "Context contrast", icon: "↕" },
92
+ trend_up: { color: "#34d399", label: "Improving", icon: "↑" },
93
+ trend_down: { color: "#fb923c", label: "Declining", icon: "↓" },
94
+ pattern: { color: "#f59e0b", label: "Consistent pattern", icon: "●" },
95
+ }
96
+
97
+ function MirrorFeed({ insights }) {
98
+ if (!insights) return null
99
+
100
+ if (!insights.length) {
101
+ return (
102
+ <p style={{ fontSize: 13, color: "#4a4d6a", margin: 0, lineHeight: 1.7 }}>
103
+ Upload conversations across different contexts β€” patterns that span multiple
104
+ sessions will appear here.
105
+ </p>
106
+ )
107
+ }
108
 
109
  return (
110
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
111
+ borderRadius: 12, overflow: "hidden",
112
+ boxShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
113
+ {insights.map((insight, i) => {
114
+ const cfg = FEED_TYPE_CONFIG[insight.type] || FEED_TYPE_CONFIG.pattern
115
+ return (
116
+ <RevealItem key={i} index={i}>
117
+ <div style={{
118
+ display: "flex", gap: 14, padding: "14px 18px",
119
+ borderBottom: i < insights.length - 1 ? "1px solid #131827" : "none",
120
+ alignItems: "flex-start",
121
+ }}>
122
+ <div style={{
123
+ width: 32, height: 32, borderRadius: 8, flexShrink: 0,
124
+ background: `${cfg.color}18`,
125
+ border: `1px solid ${cfg.color}30`,
126
+ display: "flex", alignItems: "center", justifyContent: "center",
127
+ fontSize: 14, color: cfg.color, fontWeight: 700, marginTop: 1,
128
+ }}>
129
+ {cfg.icon}
130
+ </div>
131
+ <div style={{ flex: 1, minWidth: 0 }}>
132
+ <div style={{ fontSize: 11, fontWeight: 700, color: cfg.color,
133
+ textTransform: "uppercase", letterSpacing: 0.6, marginBottom: 5 }}>
134
+ {cfg.label}
135
+ </div>
136
+ <p style={{ margin: 0, fontSize: 13, color: "#c4c2d8", lineHeight: 1.75 }}>
137
+ {insight.text}
138
+ </p>
139
+ {insight.tip && (
140
+ <p style={{
141
+ margin: "10px 0 0", fontSize: 12, color: "#6b6888", lineHeight: 1.65,
142
+ paddingTop: 10, borderTop: "1px solid #131827",
143
+ }}>
144
+ <span style={{ color: "#4a4865", fontWeight: 600 }}>β€” </span>
145
+ {insight.tip}
146
+ </p>
147
+ )}
148
+ </div>
149
+ </div>
150
+ </RevealItem>
151
+ )
152
+ })}
153
+ </div>
154
+ )
155
+ }
156
 
157
+ // ── Flat dimension bar ─────────────────────────────��──────────────
158
+
159
+ function FlatBar({ d }) {
160
+ const score = useCountUp(d.score)
161
+ return (
162
+ <div style={{ display: "flex", alignItems: "center", gap: 14,
163
+ padding: "10px 0", borderBottom: "1px solid #131827" }}>
164
+ <span style={{ fontSize: 13, fontWeight: 500, color: "#c4c2d8",
165
+ minWidth: 130, flexShrink: 0 }}>{d.name}</span>
166
+ <div style={{ flex: 1, height: 4, background: "#1e2438", borderRadius: 2 }}>
167
+ <div style={{ height: "100%", borderRadius: 2, width: `${score}%`,
168
+ background: G, transition: "width 0.5s ease",
169
+ boxShadow: "0 0 8px rgba(59,130,246,0.4)" }} />
 
 
170
  </div>
171
+ <span style={{ fontSize: 14, fontWeight: 700, minWidth: 30, textAlign: "right",
172
+ background: G, WebkitBackgroundClip: "text",
173
+ WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
174
+ {score}
175
+ </span>
176
+ </div>
177
+ )
178
+ }
179
 
180
+ // ── Pentagon radar chart ──────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
181
 
182
+ function PentagonChart({ dimensions }) {
183
+ if (!dimensions?.length) return null
 
 
 
 
 
 
184
 
185
+ const SHORT = {
186
+ "Listening Quality": "Listening",
187
+ "Communication Clarity": "Clarity",
188
+ }
 
 
 
 
189
 
190
+ const radarData = dimensions.map(d => ({
191
+ subject: SHORT[d.name] || d.name,
192
+ score: d.score,
193
+ fullMark: 100,
194
+ }))
195
+
196
+ return (
197
+ <div style={{
198
+ background: "#151922", border: "1px solid #1e2438",
199
+ borderRadius: 12, padding: "8px 0",
200
+ boxShadow: "0 2px 16px rgba(0,0,0,0.3)"
201
+ }}>
202
+ <ResponsiveContainer width="100%" height={220}>
203
+ <RadarChart data={radarData} cx="50%" cy="50%" outerRadius="66%">
204
+ <PolarGrid stroke="#1e2438" />
205
+ <PolarAngleAxis dataKey="subject" tick={{ fill: "#8b89aa", fontSize: 11 }} />
206
+ <PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
207
+ <Radar dataKey="score"
208
+ stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.18} strokeWidth={2.5} />
209
+ </RadarChart>
210
+ </ResponsiveContainer>
211
+ </div>
212
+ )
213
+ }
214
+
215
+ // ── You Across Contexts ───────────────────────────────────────────
216
+
217
+ function ContextComparison({ byContext }) {
218
+ const contexts = Object.keys(byContext || {})
219
+ const [activeCtx, setActiveCtx] = useState(contexts[0] || null)
220
+ if (!contexts.length || !activeCtx) return null
221
+
222
+ const data = byContext[activeCtx]
223
+ const norm = TALK_RATIO_NORMS[activeCtx]
224
+ const withinNorm = norm ? (data.talk_ratio >= norm[0] && data.talk_ratio <= norm[1]) : null
225
+ const normColor = withinNorm === null ? "#8b89aa" : withinNorm ? "#34d399" : "#f59e0b"
226
+
227
+ return (
228
+ <div>
229
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
230
+ {contexts.map(ctx => (
231
+ <button key={ctx} onClick={() => setActiveCtx(ctx)}
232
+ style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
233
+ cursor: "pointer", border: "1px solid", transition: "all 0.15s",
234
+ background: activeCtx === ctx ? "rgba(59,130,246,0.12)" : "#151922",
235
+ color: activeCtx === ctx ? "#60a5fa" : "#8b89aa",
236
+ borderColor: activeCtx === ctx ? "rgba(59,130,246,0.3)" : "#1e2438" }}>
237
+ {CONTEXT_LABELS[ctx] || ctx}
238
+ <span style={{ opacity: 0.5, marginLeft: 5, fontSize: 10 }}>
239
+ {byContext[ctx].count}Γ—
240
+ </span>
241
+ </button>
242
+ ))}
243
+ </div>
244
+
245
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
246
+ borderRadius: 10, padding: "16px 18px" }}>
247
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
248
+ <div>
249
+ <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>Talk ratio</div>
250
+ <div style={{ fontSize: 22, fontWeight: 700, color: "#f0eeff", lineHeight: 1 }}>
251
+ {data.talk_ratio}<span style={{ fontSize: 13, color: "#8b89aa" }}>%</span>
252
  </div>
253
+ {norm && (
254
+ <div style={{ fontSize: 11, color: normColor, marginTop: 4 }}>
255
+ {withinNorm ? "βœ“" : "↑"} norm {norm[0]}–{norm[1]}%
 
 
 
 
 
 
 
 
 
 
 
 
256
  </div>
257
+ )}
258
+ </div>
259
+ <div>
260
+ <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>Speech rate</div>
261
+ <div style={{ fontSize: 22, fontWeight: 700, color: "#f0eeff", lineHeight: 1 }}>
262
+ {data.wpm}<span style={{ fontSize: 13, color: "#8b89aa" }}> wpm</span>
 
 
 
 
263
  </div>
264
+ </div>
265
+ <div>
266
+ <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>Filler rate</div>
267
+ <div style={{ fontSize: 22, fontWeight: 700, color: "#f0eeff", lineHeight: 1 }}>
268
+ {data.filler_rate}<span style={{ fontSize: 13, color: "#8b89aa" }}>/100w</span>
269
+ </div>
270
+ </div>
271
  </div>
272
+ </div>
273
  </div>
274
  )
275
  }
276
 
277
+ // ── What's Changed ────────────────────────────────────────────────
278
+
279
+ function WhatsChanged({ trendLines, lastDelta }) {
280
+ const dimChanges = lastDelta?.changes || []
281
+ const signalTrends = trendLines || []
282
+ if (!dimChanges.length && !signalTrends.length) return null
283
+
284
  return (
285
+ <div>
286
+ <div style={{ marginBottom: 12 }}>
287
+ <SectionLabel>Trends</SectionLabel>
288
+ <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
289
+ What's Changed
290
+ </h2>
291
+ <p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
292
+ Movement detected across your sessions
293
+ </p>
294
+ </div>
295
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
296
+ {dimChanges.map((c, i) => (
297
+ <div key={i} style={{
298
+ display: "flex", alignItems: "center", gap: 5,
299
+ padding: "7px 13px", borderRadius: 20, fontSize: 12, fontWeight: 600,
300
+ background: c.direction === "up" ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
301
+ border: `1px solid ${c.direction === "up" ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
302
+ color: c.direction === "up" ? "#34d399" : "#f87171",
303
+ }}>
304
+ {c.direction === "up" ? "↑" : "↓"} {c.dimension}
305
+ <span style={{ opacity: 0.7 }}>
306
+ {" "}{c.direction === "up" ? "+" : ""}{c.diff}pts
307
+ </span>
308
+ </div>
309
+ ))}
310
+ {signalTrends.map((t, i) => (
311
+ <div key={`t${i}`} style={{
312
+ display: "flex", alignItems: "center", gap: 5,
313
+ padding: "7px 13px", borderRadius: 20, fontSize: 12, fontWeight: 600,
314
+ background: t.direction === "improved" ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
315
+ border: `1px solid ${t.direction === "improved" ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
316
+ color: t.direction === "improved" ? "#34d399" : "#f87171",
317
+ }}>
318
+ {t.direction === "improved" ? "β†—" : "β†˜"}
319
+ {" "}{t.signal.replace(/_/g, " ")}
320
+ <span style={{ opacity: 0.7 }}>
321
+ {" "}{t.old}{t.unit} β†’ {t.new}{t.unit}
322
+ </span>
323
+ </div>
324
+ ))}
325
  </div>
 
326
  </div>
327
  )
328
  }
329
 
330
+ // ── Signal Trends (collapsible) ───────────────────────────────────
331
+
332
+ function SignalTrends({ chartData, trendLines }) {
333
+ const [open, setOpen] = useState(false)
334
+ const [activeSignal, setActiveSignal] = useState("talk_ratio")
335
+ if (chartData.length < 2) return null
336
+
337
+ const signalConfig = SIGNAL_OPTIONS.find(s => s.key === activeSignal)
338
+
339
  return (
340
+ <div className="card" style={{ border: "1px solid #1e2438", borderRadius: 12,
341
+ background: "#151922", overflow: "hidden" }}>
342
+ <div onClick={() => setOpen(v => !v)}
343
+ style={{ padding: "14px 18px", display: "flex", justifyContent: "space-between",
344
+ alignItems: "center", cursor: "pointer",
345
+ borderBottom: open ? "1px solid #1e2438" : "none" }}>
346
+ <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>Signal Trends</span>
347
+ <span style={{ fontSize: 11, color: "#4a4865" }}>{open ? "β–² close" : "β–Ό expand"}</span>
348
+ </div>
349
+
350
+ {open && (
351
+ <div style={{ padding: "16px 18px" }}>
352
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
353
+ {SIGNAL_OPTIONS.map(s => {
354
+ const isActive = activeSignal === s.key
355
+ return (
356
+ <button key={s.key} onClick={() => setActiveSignal(s.key)}
357
+ style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
358
+ cursor: "pointer", fontWeight: isActive ? 600 : 400,
359
+ background: isActive ? "rgba(59,130,246,0.12)" : "#0e1320",
360
+ color: isActive ? "#60a5fa" : "#8b89aa",
361
+ border: isActive ? "1px solid rgba(59,130,246,0.3)" : "1px solid #1e2438",
362
+ transition: "all 0.15s" }}>
363
+ {s.label}
364
+ </button>
365
+ )
366
+ })}
367
+ </div>
368
+
369
+ {trendLines?.filter(t => t.signal === activeSignal).map((t, i) => (
370
+ <div key={i} style={{
371
+ display: "inline-flex", alignItems: "center", gap: 6,
372
+ padding: "4px 12px", borderRadius: 20, fontSize: 12, marginBottom: 10,
373
+ background: t.direction === "improved" ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
374
+ border: `1px solid ${t.direction === "improved" ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
375
+ color: t.direction === "improved" ? "#34d399" : "#f87171",
376
+ }}>
377
+ {t.direction === "improved" ? "β†—" : "β†˜"}
378
+ {" "}{signalConfig?.label} {t.direction}: {t.old}{signalConfig?.unit} β†’ {t.new}{signalConfig?.unit}
379
+ </div>
380
+ ))}
381
+
382
+ <div style={{ borderRadius: 8, padding: "12px 0" }}>
383
+ <ResponsiveContainer width="100%" height={180}>
384
+ <LineChart data={chartData}>
385
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e2438" />
386
+ <XAxis dataKey="label" fontSize={11} tick={{ fill: "#8b89aa" }}
387
+ axisLine={{ stroke: "#1e2438" }} tickLine={{ stroke: "#1e2438" }} />
388
+ <YAxis fontSize={11} tick={{ fill: "#8b89aa" }} width={38}
389
+ axisLine={{ stroke: "#1e2438" }} tickLine={{ stroke: "#1e2438" }}
390
+ tickFormatter={v => `${v}${signalConfig?.unit || ""}`} />
391
+ <Tooltip
392
+ contentStyle={{ background: "#151922", border: "1px solid #1e2438", borderRadius: 8 }}
393
+ labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
394
+ formatter={v => [`${v}${signalConfig?.unit || ""}`, signalConfig?.label]}
395
+ labelFormatter={(_, p) => p?.[0]?.payload?.date || ""}
396
+ />
397
+ <Line type="monotone" dataKey={activeSignal}
398
+ stroke="#3b82f6" strokeWidth={2.5}
399
+ dot={{ r: 4, fill: "#3b82f6", strokeWidth: 0 }}
400
+ activeDot={{ r: 6, fill: "#22d3ee" }} />
401
+ </LineChart>
402
+ </ResponsiveContainer>
403
+ </div>
404
+ </div>
405
+ )}
406
  </div>
407
  )
408
  }
409
 
410
+ // ── Main ProfileView ──────────────────────────────────────────────
411
+
412
  export default function ProfileView({ active, onUpload }) {
413
  const [profile, setProfile] = useState(null)
414
  const [trends, setTrends] = useState([])
415
  const [loading, setLoading] = useState(true)
416
+ const [blindSpotsOpen, setBlindSpotsOpen] = useState(false)
417
 
418
  useEffect(() => {
419
  if (!active) return
 
434
  )
435
 
436
  if (!profile || profile.insufficient_data) {
 
 
437
  return (
438
  <div style={{ textAlign: "center", padding: "60px 20px" }}>
439
+ <div style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}>
440
+ <svg width="52" height="52" viewBox="0 0 52 52" fill="none">
441
+ <defs>
442
+ <linearGradient id="pv-empty-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
443
+ <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
444
+ </linearGradient>
445
+ </defs>
446
+ <rect x="2" y="20" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".35"/>
447
+ <rect x="11" y="13" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".6"/>
448
+ <rect x="20" y="6" width="8" height="20" rx="4" fill="url(#pv-empty-g)"/>
449
+ <rect x="31" y="13" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".6"/>
450
+ <rect x="40" y="20" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".35"/>
451
+ <line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
452
+ <rect x="2" y="29" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".15"/>
453
+ <rect x="11" y="29" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".27"/>
454
+ <rect x="20" y="29" width="8" height="20" rx="4" fill="url(#pv-empty-g)" opacity=".33"/>
455
+ <rect x="31" y="29" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".27"/>
456
+ <rect x="40" y="29" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".15"/>
457
+ </svg>
458
+ </div>
459
  <h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px", color: "#f0eeff" }}>
460
+ Your mirror is waiting
461
  </h2>
462
+ <p style={{ fontSize: 14, color: "#8b89aa", margin: "0 0 24px", lineHeight: 1.6 }}>
463
+ Upload your first conversation to see your behavioral profile.
 
 
464
  </p>
465
+ <button onClick={onUpload} className="btn-grad"
 
 
 
 
 
 
 
 
 
 
 
 
466
  style={{ padding: "12px 28px", background: G, color: "white",
467
  border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
468
+ fontWeight: 600, boxShadow: "0 0 24px rgba(59,130,246,0.3)" }}>
469
  Upload a conversation
470
  </button>
471
  </div>
472
  )
473
  }
474
 
475
+ const { by_context, trends: trendLines, session_count, personality,
476
+ blind_spots, completeness, completeness_label, mirror_feed } = profile
 
477
 
478
+ const keywords = deriveKeywords(personality?.dimensions)
479
 
480
  const chartData = trends.map((point, i) => ({
481
  ...point,
 
483
  date: new Date(point.date).toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
484
  }))
485
 
486
+ const hasContextData = by_context && Object.keys(by_context).length > 0
487
+
488
  return (
489
+ <div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
490
 
491
  {/* Header */}
492
+ <Reveal>
493
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
494
+ <div style={{ flex: 1, minWidth: 0 }}>
495
  <h2 style={{ fontSize: 16, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
496
  Your Behavioral Profile
497
  </h2>
498
+
499
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
500
+ <span style={{ fontSize: 12, color: "#4a4865" }}>
501
+ {session_count} session{session_count > 1 ? "s" : ""}
502
+ </span>
503
+ {completeness_label && (
504
+ <>
505
+ <span style={{ color: "#1e2438", fontSize: 12 }}>Β·</span>
506
+ <span style={{ fontSize: 12, color: "#4a4865" }}>{completeness_label}</span>
507
+ <div style={{ width: 60, height: 3, background: "#1e2438",
508
+ borderRadius: 2, overflow: "hidden" }}>
509
+ <div style={{ height: "100%", borderRadius: 2,
510
+ width: `${completeness}%`, background: G,
511
+ transition: "width 0.6s ease" }} />
512
+ </div>
513
+ </>
514
+ )}
515
+ </div>
516
+
517
+ {blind_spots?.length > 0 && (
518
+ <div style={{ marginTop: 10 }}>
519
+ <button
520
+ onClick={() => setBlindSpotsOpen(v => !v)}
521
+ style={{ background: "none", border: "none", padding: 0, cursor: "pointer",
522
+ display: "flex", alignItems: "center", gap: 5 }}>
523
+ <span style={{ fontSize: 10, color: "#3a3a52", display: "inline-block",
524
+ transform: blindSpotsOpen ? "rotate(90deg)" : "rotate(0deg)",
525
+ transition: "transform 0.2s" }}>β–Ά</span>
526
+ <span style={{ fontSize: 12, color: "#4a4865" }}>
527
+ {blind_spots.length} context{blind_spots.length > 1 ? "s" : ""} missing
528
+ </span>
529
+ </button>
530
+ {blindSpotsOpen && (
531
+ <div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
532
+ {blind_spots.map((spot, i) => (
533
+ <div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
534
+ <span style={{ fontSize: 11, color: "#3a3a52", marginTop: 1, flexShrink: 0 }}>Β·</span>
535
+ <div>
536
+ <span style={{ fontSize: 12, fontWeight: 600, color: "#6b6888" }}>{spot.label}</span>
537
+ <span style={{ fontSize: 12, color: "#4a4865" }}> β€” {spot.message}</span>
538
+ </div>
539
+ </div>
540
+ ))}
541
+ </div>
542
+ )}
543
+ </div>
544
+ )}
545
  </div>
546
+
547
+ <button onClick={onUpload} className="btn-grad"
548
  style={{ padding: "8px 18px", background: G, color: "white",
549
  border: "none", borderRadius: 7, fontSize: 13, cursor: "pointer",
550
+ fontWeight: 600, boxShadow: "0 0 18px rgba(59,130,246,0.25)", flexShrink: 0,
551
+ marginLeft: 16 }}>
552
  + Upload
553
  </button>
554
  </div>
555
+ </Reveal>
556
 
557
+ {/* Mirror Feed */}
558
+ <Reveal delay={80}>
559
+ <div>
560
+ <div style={{ marginBottom: 12 }}>
561
+ <SectionLabel>Cross-Session</SectionLabel>
562
+ <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
563
+ Mirror Feed
564
+ </h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  </div>
566
+ <MirrorFeed insights={mirror_feed} />
567
+ </div>
568
+ </Reveal>
569
 
570
+ {/* Your Portrait */}
571
  {personality && (
572
+ <Reveal>
573
+ <div>
574
+ <SectionLabel>Your Portrait</SectionLabel>
575
+ <div style={{
576
+ borderLeft: "3px solid rgba(59,130,246,0.65)",
577
+ paddingLeft: 16, marginTop: 10,
578
+ }}>
579
+ <div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
580
+ <p style={{ flex: 1, fontSize: 14, color: "#c4c2d8",
581
+ lineHeight: 1.9, margin: 0 }}>
582
+ {personality.paragraph}
583
+ </p>
584
+ {keywords.length > 0 && (
585
+ <div style={{ display: "flex", flexDirection: "column", gap: 6, flexShrink: 0 }}>
586
+ {keywords.map((kw, i) => (
587
+ <span key={i} style={{
588
+ fontSize: 12, fontWeight: 600, padding: "5px 14px",
589
+ borderRadius: 6, letterSpacing: 0.2,
590
+ background: "linear-gradient(#151922, #151922) padding-box, linear-gradient(135deg, rgba(59,130,246,0.5), rgba(34,211,238,0.5)) border-box",
591
+ border: "1px solid transparent",
592
+ color: "#60a5fa", whiteSpace: "nowrap",
593
+ }}>
594
+ {kw}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  </span>
596
+ ))}
597
+ </div>
598
+ )}
599
  </div>
 
 
 
 
 
 
600
  </div>
601
  </div>
602
+ </Reveal>
603
  )}
604
 
605
+ {/* You Across Contexts */}
606
+ {hasContextData && (
607
+ <Reveal>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  <div>
609
+ <div style={{ marginBottom: 16 }}>
610
+ <SectionLabel>Context Breakdown</SectionLabel>
611
+ <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
612
+ You Across Contexts
613
+ </h2>
614
+ <p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
615
+ How your patterns shift depending on the room
616
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  </div>
618
+ <ContextComparison byContext={by_context} />
619
  </div>
620
+ </Reveal>
621
  )}
622
 
623
+ {/* Your Shape β€” pentagon + bars */}
624
+ {personality?.dimensions?.length > 0 && (
625
+ <Reveal>
626
  <div>
627
+ <div style={{ marginBottom: 16 }}>
628
+ <SectionLabel>5 Dimensions</SectionLabel>
629
+ <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
630
+ Your Shape
631
+ </h2>
632
+ <p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
633
+ How you show up across five behavioral axes
634
+ </p>
 
 
 
 
 
 
 
 
 
 
635
  </div>
636
 
637
+ {/* Pentagon chart */}
638
+ <PentagonChart dimensions={personality.dimensions} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
 
640
+ {/* All 5 flat bars */}
641
+ <div style={{ marginTop: 20 }}>
642
+ {personality.dimensions.map((d, i) => (
643
+ <RevealItem key={d.key} index={i}>
644
+ <FlatBar d={d} />
645
+ </RevealItem>
646
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  </div>
 
 
648
 
649
+ {/* Shape narrative */}
650
+ {personality.shape_narrative && (
651
+ <div style={{ marginTop: 14, padding: "14px 16px",
652
+ background: "#0e1320", border: "1px solid #1e2438",
653
+ borderLeft: "3px solid rgba(29,78,216,0.4)",
654
+ borderRadius: 10 }}>
655
+ <div style={{ fontSize: 11, fontWeight: 600, color: "#4a4865",
656
+ textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8 }}>
657
+ What this shape means
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  </div>
659
+ <p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.75 }}>
660
+ {personality.shape_narrative}
661
+ </p>
662
+ </div>
663
+ )}
664
  </div>
665
+ </Reveal>
666
  )}
667
 
668
+ {/* What's Changed */}
669
+ {((personality?.last_delta?.changes?.length ?? 0) > 0 || (trendLines?.length ?? 0) > 0) && (
670
+ <Reveal>
671
+ <WhatsChanged trendLines={trendLines} lastDelta={personality?.last_delta} />
672
+ </Reveal>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  )}
674
 
675
+ {/* Signal Trends β€” collapsible */}
676
+ {chartData.length >= 2 && (
677
+ <Reveal>
678
+ <SignalTrends chartData={chartData} trendLines={trendLines} />
679
+ </Reveal>
 
 
 
 
680
  )}
681
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  </div>
683
  )
684
  }
frontend/src/components/ResultsView.jsx CHANGED
@@ -1,8 +1,9 @@
1
  import { useState } from "react"
 
2
  import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"
3
  import api from "../lib/api"
4
 
5
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
6
 
7
  const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
8
 
@@ -13,7 +14,7 @@ function ScoreBar({ score, max = 5 }) {
13
  {Array.from({ length: max }).map((_, i) => (
14
  <div key={i} style={{
15
  width: 20, height: 7, borderRadius: 4,
16
- background: i < score ? color : "#2a2a42",
17
  boxShadow: i < score ? `0 0 6px ${color}50` : "none",
18
  transition: "all 0.2s",
19
  }} />
@@ -26,8 +27,8 @@ function DimensionCard({ title, icon, items, narrative }) {
26
  const [expanded, setExpanded] = useState(false)
27
 
28
  return (
29
- <div style={{ border: "1px solid #2a2a42", borderRadius: 12,
30
- background: "#14141f", marginBottom: 10, overflow: "hidden" }}>
31
  <div onClick={() => setExpanded(!expanded)}
32
  style={{ padding: "14px 16px", cursor: "pointer",
33
  display: "flex", justifyContent: "space-between", alignItems: "center" }}>
@@ -52,8 +53,8 @@ function DimensionCard({ title, icon, items, narrative }) {
52
  </div>
53
 
54
  {expanded && narrative && (
55
- <div style={{ padding: "14px 16px", borderTop: "1px solid #2a2a42",
56
- background: "#1a1a2e", fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
57
  {narrative}
58
  </div>
59
  )}
@@ -66,8 +67,8 @@ function CoachingCard({ suggestion }) {
66
  const color = priorityColors[suggestion.priority] || "#8b89aa"
67
 
68
  return (
69
- <div style={{ border: "1px solid #2a2a42", borderRadius: 12,
70
- padding: 16, background: "#14141f", borderLeft: `3px solid ${color}` }}>
71
  <span style={{ fontSize: 11, fontWeight: 700, color,
72
  textTransform: "uppercase", letterSpacing: 0.5 }}>
73
  #{suggestion.priority} β€” {suggestion.area}
@@ -81,7 +82,7 @@ function CoachingCard({ suggestion }) {
81
  πŸ’‘ {suggestion.suggestion}
82
  </div>
83
  <div style={{ fontSize: 12, color: "#8b89aa", lineHeight: 1.5,
84
- padding: "10px 12px", background: "#1a1a2e", borderRadius: 8 }}>
85
  <strong style={{ color: "#f0eeff" }}>Why it matters:</strong> {suggestion.why_it_matters}
86
  </div>
87
  </div>
@@ -91,18 +92,18 @@ function CoachingCard({ suggestion }) {
91
  function ObservationCard({ obs, sessionId }) {
92
  const [resonance, setResonance] = useState(null)
93
  const signalColors = {
94
- talk_ratio: "#818cf8", speech_rate: "#a78bfa",
95
  speech_acceleration: "#f472b6", pauses: "#f59e0b",
96
  interruptions: "#f87171", filler_words: "#fb923c",
97
- vocal_energy: "#22d3ee", questions: "#34d399",
98
  monologue: "#818cf8", vocabulary_richness: "#a3e635",
99
- silence_ratio: "#8b89aa", pitch: "#c084fc", engagement: "#2dd4bf"
100
  }
101
  const color = signalColors[obs.signal] || "#8b89aa"
102
 
103
  return (
104
- <div style={{ border: "1px solid #2a2a42", borderRadius: 10,
105
- padding: 16, background: "#14141f", borderLeft: `3px solid ${color}` }}>
106
  <div style={{ fontSize: 11, color, fontWeight: 600,
107
  textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 }}>
108
  {obs.signal.replace(/_/g, " ")}
@@ -132,8 +133,8 @@ function ObservationCard({ obs, sessionId }) {
132
  } catch {}
133
  }
134
  }}
135
- style={{ padding: "5px 14px", border: "1px solid #2a2a42",
136
- borderRadius: 20, background: "#1a1a2e", cursor: "pointer",
137
  fontSize: 12, color: "#8b89aa" }}>
138
  {label}
139
  </button>
@@ -149,10 +150,10 @@ function ObservationCard({ obs, sessionId }) {
149
 
150
  function ReflectCard({ item, index }) {
151
  const [open, setOpen] = useState(false)
152
- const accent = ["#818cf8", "#a78bfa", "#c084fc"][index % 3]
153
  return (
154
- <div style={{ border: "1px solid #2a2a42", borderRadius: 12,
155
- background: "#14141f", overflow: "hidden", cursor: "pointer" }}
156
  onClick={() => setOpen(v => !v)}>
157
  <div style={{ padding: "16px 18px", display: "flex",
158
  justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
@@ -169,8 +170,8 @@ function ReflectCard({ item, index }) {
169
  </span>
170
  </div>
171
  {open && (
172
- <div style={{ padding: "14px 18px", borderTop: "1px solid #2a2a42",
173
- background: "#1a1a2e" }}>
174
  <p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
175
  {item.answer}
176
  </p>
@@ -202,7 +203,7 @@ export default function ResultsView({ results, onBack }) {
202
  const [activeTab, setActiveTab] = useState("overview")
203
 
204
  const { signals, insights, dimensions, filename, detected_speaker,
205
- session_id, voiceprint_confidence, transcript } = liveResults
206
 
207
  const confPct = voiceprint_confidence != null ? Math.round(voiceprint_confidence * 100) : null
208
  const confLabel = confPct == null ? null : confPct >= 55 ? "high" : confPct >= 40 ? "medium" : "low"
@@ -232,7 +233,7 @@ export default function ResultsView({ results, onBack }) {
232
  }
233
  }
234
 
235
- const tabs = ["overview", "dimensions", "coaching", "reflect", "signals", "transcript"]
236
  const otherSpeakers = (liveResults.available_speakers || []).filter(s => s !== detected_speaker)
237
 
238
  return (
@@ -258,21 +259,21 @@ export default function ResultsView({ results, onBack }) {
258
  {otherSpeakers.length > 0 && (
259
  <div style={{ position: "relative" }}>
260
  <button onClick={() => setShowSpeakerSwitch(v => !v)}
261
- style={{ fontSize: 12, color: "#8b89aa", background: "#14141f",
262
- border: "1px solid #2a2a42", borderRadius: 20,
263
  padding: "3px 12px", cursor: "pointer" }}>
264
  {reanalyzing ? "Switching…" : "Not you?"}
265
  </button>
266
  {showSpeakerSwitch && !reanalyzing && (
267
  <div style={{ position: "absolute", right: 0, top: "110%",
268
- background: "#14141f", border: "1px solid #2a2a42", borderRadius: 10,
269
  boxShadow: "0 8px 24px rgba(0,0,0,0.6)", zIndex: 10,
270
  minWidth: 160, overflow: "hidden" }}>
271
  {otherSpeakers.map(s => (
272
  <button key={s} onClick={() => handleReanalyze(s)}
273
  style={{ display: "block", width: "100%", padding: "10px 14px",
274
  textAlign: "left", background: "none", border: "none",
275
- borderBottom: "1px solid #2a2a42",
276
  cursor: "pointer", fontSize: 13, color: "#f0eeff" }}>
277
  Switch to {speakerLabel(s)}
278
  </button>
@@ -304,15 +305,14 @@ export default function ResultsView({ results, onBack }) {
304
  )}
305
 
306
  {/* Tabs */}
307
- <div style={{ display: "flex", marginBottom: 20, borderBottom: "1px solid #2a2a42" }}>
308
  {tabs.map(tab => (
309
  <button key={tab} onClick={() => setActiveTab(tab)}
 
310
  style={{ background: "none", border: "none", cursor: "pointer",
311
  padding: "8px 14px", fontSize: 13, textTransform: "capitalize",
312
  fontWeight: activeTab === tab ? 600 : 400,
313
- color: activeTab === tab ? "#e879f9" : "#8b89aa",
314
- borderBottom: activeTab === tab ? "2px solid #e879f9" : "2px solid transparent",
315
- transition: "color 0.15s" }}>
316
  {tab}
317
  </button>
318
  ))}
@@ -320,7 +320,7 @@ export default function ResultsView({ results, onBack }) {
320
 
321
  {/* ── OVERVIEW TAB ── */}
322
  {activeTab === "overview" && (
323
- <div>
324
  {/* Type chips */}
325
  {(() => {
326
  const types = insights.conversation_types || []
@@ -332,9 +332,9 @@ export default function ResultsView({ results, onBack }) {
332
  <span key={t} style={{
333
  fontSize: 11, fontWeight: i === 0 ? 700 : 500,
334
  padding: "3px 10px", borderRadius: 20,
335
- background: i === 0 ? "rgba(217,70,239,0.12)" : "#1a1a2e",
336
- color: i === 0 ? "#e879f9" : "#8b89aa",
337
- border: `1px solid ${i === 0 ? "rgba(217,70,239,0.3)" : "#2a2a42"}`,
338
  textTransform: "uppercase", letterSpacing: 0.4,
339
  }}>
340
  {LABELS[t] || t}
@@ -362,11 +362,11 @@ export default function ResultsView({ results, onBack }) {
362
 
363
  {/* Notable pattern β€” promoted to headline position */}
364
  {insights.notable_pattern && (
365
- <div style={{ background: "rgba(217,70,239,0.06)",
366
- border: "1px solid rgba(217,70,239,0.2)",
367
  borderRadius: 10, padding: 16, marginBottom: 14 }}>
368
  <div style={{ fontSize: 11, fontWeight: 600,
369
- color: "#e879f9", marginBottom: 6,
370
  textTransform: "uppercase", letterSpacing: 0.5 }}>
371
  Notable Pattern
372
  </div>
@@ -377,9 +377,9 @@ export default function ResultsView({ results, onBack }) {
377
  )}
378
 
379
  {/* Communication Pattern */}
380
- <div style={{ background: "#14141f", borderRadius: 12,
381
- padding: 18, marginBottom: 16, borderLeft: "3px solid #d946ef" }}>
382
- <div style={{ fontSize: 11, fontWeight: 600, color: "#e879f9",
383
  marginBottom: 8, textTransform: "uppercase", letterSpacing: 0.5 }}>
384
  Communication Pattern
385
  </div>
@@ -413,7 +413,7 @@ export default function ResultsView({ results, onBack }) {
413
  { label: "Fillers", value: `${signals.filler_words.rate_per_100_words}`, unit: "/100w" },
414
  ].map(({ label, value, unit }) => (
415
  <div key={label} style={{ textAlign: "center", padding: "16px 8px",
416
- border: "1px solid #2a2a42", borderRadius: 10, background: "#14141f" }}>
417
  <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 6 }}>{label}</div>
418
  <div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1,
419
  background: G, WebkitBackgroundClip: "text",
@@ -436,7 +436,7 @@ export default function ResultsView({ results, onBack }) {
436
  ? "β€”" : signals.vocal_energy.trend },
437
  ].map(({ label, value }) => (
438
  <div key={label} style={{ textAlign: "center", padding: "14px 8px",
439
- border: "1px solid #2a2a42", borderRadius: 10, background: "#14141f" }}>
440
  <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>{label}</div>
441
  <div style={{ fontSize: 18, fontWeight: 700, color: "#f0eeff",
442
  textTransform: "capitalize" }}>{value}</div>
@@ -444,15 +444,15 @@ export default function ResultsView({ results, onBack }) {
444
  ))}
445
  </div>
446
 
447
- </div>
448
  )}
449
 
450
  {/* ── DIMENSIONS TAB ── */}
451
  {activeTab === "dimensions" && dimensions && (
452
- <div>
453
  <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
454
  Scores are probabilistic proxies based on behavioral signals β€”
455
- not psychological diagnoses. Tap any card to read the interpretation.
456
  </p>
457
  {dimensions.emotional_state && (
458
  <DimensionCard title="Your Emotional State" icon="🧠"
@@ -490,8 +490,8 @@ export default function ResultsView({ results, onBack }) {
490
 
491
  {/* Conversation Arc β€” moved from Overview */}
492
  {dimensions?.conversation_arc && (
493
- <div style={{ border: "1px solid #2a2a42", borderRadius: 12,
494
- padding: 16, background: "#14141f" }}>
495
  <div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
496
  marginBottom: 14, textTransform: "uppercase", letterSpacing: 0.5 }}>
497
  Conversation Arc
@@ -512,7 +512,7 @@ export default function ResultsView({ results, onBack }) {
512
  </div>
513
  {dimensions.conversation_arc.turning_point?.detected && (
514
  <div style={{ marginTop: 12, fontSize: 12, color: "#8b89aa",
515
- background: "#1a1a2e", padding: "8px 12px", borderRadius: 8 }}>
516
  ⚑ {dimensions.conversation_arc.turning_point.detail}
517
  </div>
518
  )}
@@ -524,19 +524,21 @@ export default function ResultsView({ results, onBack }) {
524
  )}
525
  </div>
526
  )}
527
- </div>
528
  )}
529
 
530
  {/* ── COACHING TAB ── */}
531
  {activeTab === "coaching" && (
532
- <div>
533
  <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
534
  Specific suggestions based on patterns observed in this conversation.
535
  Ranked by potential impact.
536
  </p>
537
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
538
  {insights.coaching_suggestions?.map((s, i) => (
539
- <CoachingCard key={i} suggestion={s} />
 
 
540
  ))}
541
  {(!insights.coaching_suggestions || insights.coaching_suggestions.length === 0) && (
542
  <div style={{ textAlign: "center", padding: 32, color: "#4a4865" }}>
@@ -544,12 +546,12 @@ export default function ResultsView({ results, onBack }) {
544
  </div>
545
  )}
546
  </div>
547
- </div>
548
  )}
549
 
550
  {/* ── REFLECT TAB ── */}
551
  {activeTab === "reflect" && (
552
- <div>
553
  <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 20, lineHeight: 1.6 }}>
554
  Questions grounded in what happened in this conversation. Tap any to read the insight.
555
  </p>
@@ -566,39 +568,41 @@ export default function ResultsView({ results, onBack }) {
566
  ) : (
567
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
568
  {insights.reflection_questions.map((item, i) => (
569
- <ReflectCard key={i} item={item} index={i} />
 
 
570
  ))}
571
  </div>
572
  )}
573
- </div>
574
  )}
575
 
576
  {/* ── SIGNALS TAB ── */}
577
  {activeTab === "signals" && (
578
- <div>
579
  {signals.timeline?.length > 1 && (
580
  <div style={{ marginBottom: 24 }}>
581
  <h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#f0eeff" }}>
582
  Speech Rate Over Time
583
  </h3>
584
- <div style={{ background: "#14141f", border: "1px solid #2a2a42",
585
  borderRadius: 10, padding: 16 }}>
586
  <ResponsiveContainer width="100%" height={150}>
587
  <LineChart data={signals.timeline}>
588
  <XAxis dataKey="window_start_s" tickFormatter={formatTime} fontSize={11}
589
- tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#2a2a42" }}
590
- tickLine={{ stroke: "#2a2a42" }} />
591
  <YAxis domain={["auto", "auto"]} fontSize={11} width={35}
592
- tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#2a2a42" }}
593
- tickLine={{ stroke: "#2a2a42" }} />
594
  <Tooltip
595
- contentStyle={{ background: "#14141f", border: "1px solid #2a2a42",
596
  borderRadius: 8 }}
597
  labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
598
  labelFormatter={v => `At ${formatTime(v)}`}
599
  formatter={v => [`${Math.round(v)} wpm`, "Speech rate"]} />
600
  <Line type="monotone" dataKey="speech_rate_wpm"
601
- stroke="#d946ef" strokeWidth={2} dot={false} />
602
  </LineChart>
603
  </ResponsiveContainer>
604
  </div>
@@ -624,62 +628,51 @@ export default function ResultsView({ results, onBack }) {
624
  { label: "Within-turn pauses", value: signals.pauses.within_turn_pauses.count },
625
  { label: "Your turns", value: signals.turn_dynamics.user_turns },
626
  ].map(({ label, value }) => (
627
- <div key={label} style={{ padding: 12, border: "1px solid #2a2a42",
628
- borderRadius: 8, background: "#14141f" }}>
629
  <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 4 }}>{label}</div>
630
  <div style={{ fontSize: 16, fontWeight: 600, color: "#f0eeff" }}>{value}</div>
631
  </div>
632
  ))}
633
  </div>
634
- </div>
635
  )}
636
 
637
- {/* ── TRANSCRIPT TAB ── */}
638
- {activeTab === "transcript" && (
639
- <div>
640
- {(!transcript || transcript.length === 0) ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  <div style={{ textAlign: "center", padding: 48, color: "#4a4865" }}>
642
  <div style={{ fontSize: 32, marginBottom: 12 }}>πŸ“„</div>
643
  <p style={{ fontSize: 14, color: "#8b89aa" }}>
644
- Transcript not available for this session.
 
 
 
645
  </p>
646
- </div>
647
- ) : (
648
- <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
649
- {transcript.map((seg, i) => {
650
- const isUser = seg.speaker === detected_speaker
651
- return (
652
- <div key={i} style={{
653
- display: "flex", gap: 12, padding: "8px 12px", borderRadius: 8,
654
- background: isUser ? "rgba(217,70,239,0.06)" : "#14141f",
655
- border: `1px solid ${isUser ? "rgba(217,70,239,0.18)" : "#2a2a42"}`,
656
- }}>
657
- <div style={{ minWidth: 72, flexShrink: 0 }}>
658
- <div style={{ fontSize: 11, fontWeight: 600,
659
- color: isUser ? "#e879f9" : "#8b89aa" }}>
660
- {isUser ? "You" : speakerLabel(seg.speaker)}
661
- </div>
662
- <div style={{ fontSize: 10, color: "#4a4865", marginTop: 1 }}>
663
- {seg.start != null
664
- ? `${Math.floor(seg.start / 60)}:${String(Math.round(seg.start % 60)).padStart(2, "0")}`
665
- : ""}
666
- </div>
667
- </div>
668
- <p style={{ margin: 0, fontSize: 13, color: "#f0eeff", lineHeight: 1.6 }}>
669
- {seg.text}
670
- </p>
671
- </div>
672
- )
673
- })}
674
  </div>
675
  )}
676
- </div>
677
  )}
678
 
679
  {/* Disclaimer */}
680
  <div style={{ fontSize: 11, color: "#4a4865", padding: 14, marginTop: 20,
681
- background: "#14141f", borderRadius: 8, lineHeight: 1.7,
682
- border: "1px solid #2a2a42" }}>
683
  <strong style={{ color: "#8b89aa" }}>Note:</strong> All scores and observations are
684
  probabilistic proxies based on acoustic and linguistic patterns. They are not validated
685
  psychological assessments. Use as prompts for self-reflection only.
 
1
  import { useState } from "react"
2
+ import Reveal, { RevealItem } from "./Reveal"
3
  import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"
4
  import api from "../lib/api"
5
 
6
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
7
 
8
  const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
9
 
 
14
  {Array.from({ length: max }).map((_, i) => (
15
  <div key={i} style={{
16
  width: 20, height: 7, borderRadius: 4,
17
+ background: i < score ? color : "#1e2438",
18
  boxShadow: i < score ? `0 0 6px ${color}50` : "none",
19
  transition: "all 0.2s",
20
  }} />
 
27
  const [expanded, setExpanded] = useState(false)
28
 
29
  return (
30
+ <div style={{ border: "1px solid #1e2438", borderRadius: 12,
31
+ background: "#151922", marginBottom: 10, overflow: "hidden" }}>
32
  <div onClick={() => setExpanded(!expanded)}
33
  style={{ padding: "14px 16px", cursor: "pointer",
34
  display: "flex", justifyContent: "space-between", alignItems: "center" }}>
 
53
  </div>
54
 
55
  {expanded && narrative && (
56
+ <div style={{ padding: "14px 16px", borderTop: "1px solid #1e2438",
57
+ background: "#131827", fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
58
  {narrative}
59
  </div>
60
  )}
 
67
  const color = priorityColors[suggestion.priority] || "#8b89aa"
68
 
69
  return (
70
+ <div style={{ border: "1px solid #1e2438", borderRadius: 12,
71
+ padding: 16, background: "#151922", borderLeft: `3px solid ${color}` }}>
72
  <span style={{ fontSize: 11, fontWeight: 700, color,
73
  textTransform: "uppercase", letterSpacing: 0.5 }}>
74
  #{suggestion.priority} β€” {suggestion.area}
 
82
  πŸ’‘ {suggestion.suggestion}
83
  </div>
84
  <div style={{ fontSize: 12, color: "#8b89aa", lineHeight: 1.5,
85
+ padding: "10px 12px", background: "#131827", borderRadius: 8 }}>
86
  <strong style={{ color: "#f0eeff" }}>Why it matters:</strong> {suggestion.why_it_matters}
87
  </div>
88
  </div>
 
92
  function ObservationCard({ obs, sessionId }) {
93
  const [resonance, setResonance] = useState(null)
94
  const signalColors = {
95
+ talk_ratio: "#818cf8", speech_rate: "#5b9cf6",
96
  speech_acceleration: "#f472b6", pauses: "#f59e0b",
97
  interruptions: "#f87171", filler_words: "#fb923c",
98
+ vocal_energy: "#0891b2", questions: "#34d399",
99
  monologue: "#818cf8", vocabulary_richness: "#a3e635",
100
+ silence_ratio: "#8b89aa", pitch: "#0891b2", engagement: "#5b9cf6"
101
  }
102
  const color = signalColors[obs.signal] || "#8b89aa"
103
 
104
  return (
105
+ <div style={{ border: "1px solid #1e2438", borderRadius: 10,
106
+ padding: 16, background: "#151922", borderLeft: `3px solid ${color}` }}>
107
  <div style={{ fontSize: 11, color, fontWeight: 600,
108
  textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 }}>
109
  {obs.signal.replace(/_/g, " ")}
 
133
  } catch {}
134
  }
135
  }}
136
+ style={{ padding: "5px 14px", border: "1px solid #1e2438",
137
+ borderRadius: 20, background: "#131827", cursor: "pointer",
138
  fontSize: 12, color: "#8b89aa" }}>
139
  {label}
140
  </button>
 
150
 
151
  function ReflectCard({ item, index }) {
152
  const [open, setOpen] = useState(false)
153
+ const accent = ["#818cf8", "#5b9cf6", "#0891b2"][index % 3]
154
  return (
155
+ <div style={{ border: "1px solid #1e2438", borderRadius: 12,
156
+ background: "#151922", overflow: "hidden", cursor: "pointer" }}
157
  onClick={() => setOpen(v => !v)}>
158
  <div style={{ padding: "16px 18px", display: "flex",
159
  justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
 
170
  </span>
171
  </div>
172
  {open && (
173
+ <div style={{ padding: "14px 18px", borderTop: "1px solid #1e2438",
174
+ background: "#131827" }}>
175
  <p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
176
  {item.answer}
177
  </p>
 
203
  const [activeTab, setActiveTab] = useState("overview")
204
 
205
  const { signals, insights, dimensions, filename, detected_speaker,
206
+ session_id, voiceprint_confidence, fingerprint } = liveResults
207
 
208
  const confPct = voiceprint_confidence != null ? Math.round(voiceprint_confidence * 100) : null
209
  const confLabel = confPct == null ? null : confPct >= 55 ? "high" : confPct >= 40 ? "medium" : "low"
 
233
  }
234
  }
235
 
236
+ const tabs = ["overview", "dimensions", "coaching", "reflect", "signals", "summary"]
237
  const otherSpeakers = (liveResults.available_speakers || []).filter(s => s !== detected_speaker)
238
 
239
  return (
 
259
  {otherSpeakers.length > 0 && (
260
  <div style={{ position: "relative" }}>
261
  <button onClick={() => setShowSpeakerSwitch(v => !v)}
262
+ style={{ fontSize: 12, color: "#8b89aa", background: "#151922",
263
+ border: "1px solid #1e2438", borderRadius: 20,
264
  padding: "3px 12px", cursor: "pointer" }}>
265
  {reanalyzing ? "Switching…" : "Not you?"}
266
  </button>
267
  {showSpeakerSwitch && !reanalyzing && (
268
  <div style={{ position: "absolute", right: 0, top: "110%",
269
+ background: "#151922", border: "1px solid #1e2438", borderRadius: 10,
270
  boxShadow: "0 8px 24px rgba(0,0,0,0.6)", zIndex: 10,
271
  minWidth: 160, overflow: "hidden" }}>
272
  {otherSpeakers.map(s => (
273
  <button key={s} onClick={() => handleReanalyze(s)}
274
  style={{ display: "block", width: "100%", padding: "10px 14px",
275
  textAlign: "left", background: "none", border: "none",
276
+ borderBottom: "1px solid #1e2438",
277
  cursor: "pointer", fontSize: 13, color: "#f0eeff" }}>
278
  Switch to {speakerLabel(s)}
279
  </button>
 
305
  )}
306
 
307
  {/* Tabs */}
308
+ <div style={{ display: "flex", marginBottom: 20, borderBottom: "1px solid #1e2438" }}>
309
  {tabs.map(tab => (
310
  <button key={tab} onClick={() => setActiveTab(tab)}
311
+ className={`nav-tab${activeTab === tab ? " active" : ""}`}
312
  style={{ background: "none", border: "none", cursor: "pointer",
313
  padding: "8px 14px", fontSize: 13, textTransform: "capitalize",
314
  fontWeight: activeTab === tab ? 600 : 400,
315
+ color: activeTab === tab ? "#5b9cf6" : "#8b89aa" }}>
 
 
316
  {tab}
317
  </button>
318
  ))}
 
320
 
321
  {/* ── OVERVIEW TAB ── */}
322
  {activeTab === "overview" && (
323
+ <Reveal>
324
  {/* Type chips */}
325
  {(() => {
326
  const types = insights.conversation_types || []
 
332
  <span key={t} style={{
333
  fontSize: 11, fontWeight: i === 0 ? 700 : 500,
334
  padding: "3px 10px", borderRadius: 20,
335
+ background: i === 0 ? "rgba(29,78,216,0.12)" : "#131827",
336
+ color: i === 0 ? "#5b9cf6" : "#8b89aa",
337
+ border: `1px solid ${i === 0 ? "rgba(29,78,216,0.3)" : "#1e2438"}`,
338
  textTransform: "uppercase", letterSpacing: 0.4,
339
  }}>
340
  {LABELS[t] || t}
 
362
 
363
  {/* Notable pattern β€” promoted to headline position */}
364
  {insights.notable_pattern && (
365
+ <div style={{ background: "rgba(29,78,216,0.06)",
366
+ border: "1px solid rgba(29,78,216,0.2)",
367
  borderRadius: 10, padding: 16, marginBottom: 14 }}>
368
  <div style={{ fontSize: 11, fontWeight: 600,
369
+ color: "#5b9cf6", marginBottom: 6,
370
  textTransform: "uppercase", letterSpacing: 0.5 }}>
371
  Notable Pattern
372
  </div>
 
377
  )}
378
 
379
  {/* Communication Pattern */}
380
+ <div style={{ background: "#151922", borderRadius: 12,
381
+ padding: 18, marginBottom: 16, borderLeft: "3px solid #1d4ed8" }}>
382
+ <div style={{ fontSize: 11, fontWeight: 600, color: "#5b9cf6",
383
  marginBottom: 8, textTransform: "uppercase", letterSpacing: 0.5 }}>
384
  Communication Pattern
385
  </div>
 
413
  { label: "Fillers", value: `${signals.filler_words.rate_per_100_words}`, unit: "/100w" },
414
  ].map(({ label, value, unit }) => (
415
  <div key={label} style={{ textAlign: "center", padding: "16px 8px",
416
+ border: "1px solid #1e2438", borderRadius: 10, background: "#151922" }}>
417
  <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 6 }}>{label}</div>
418
  <div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1,
419
  background: G, WebkitBackgroundClip: "text",
 
436
  ? "β€”" : signals.vocal_energy.trend },
437
  ].map(({ label, value }) => (
438
  <div key={label} style={{ textAlign: "center", padding: "14px 8px",
439
+ border: "1px solid #1e2438", borderRadius: 10, background: "#151922" }}>
440
  <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>{label}</div>
441
  <div style={{ fontSize: 18, fontWeight: 700, color: "#f0eeff",
442
  textTransform: "capitalize" }}>{value}</div>
 
444
  ))}
445
  </div>
446
 
447
+ </Reveal>
448
  )}
449
 
450
  {/* ── DIMENSIONS TAB ── */}
451
  {activeTab === "dimensions" && dimensions && (
452
+ <Reveal>
453
  <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
454
  Scores are probabilistic proxies based on behavioral signals β€”
455
+ not psychological diagnoses. Expand any card to read the interpretation.
456
  </p>
457
  {dimensions.emotional_state && (
458
  <DimensionCard title="Your Emotional State" icon="🧠"
 
490
 
491
  {/* Conversation Arc β€” moved from Overview */}
492
  {dimensions?.conversation_arc && (
493
+ <div style={{ border: "1px solid #1e2438", borderRadius: 12,
494
+ padding: 16, background: "#151922" }}>
495
  <div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
496
  marginBottom: 14, textTransform: "uppercase", letterSpacing: 0.5 }}>
497
  Conversation Arc
 
512
  </div>
513
  {dimensions.conversation_arc.turning_point?.detected && (
514
  <div style={{ marginTop: 12, fontSize: 12, color: "#8b89aa",
515
+ background: "#131827", padding: "8px 12px", borderRadius: 8 }}>
516
  ⚑ {dimensions.conversation_arc.turning_point.detail}
517
  </div>
518
  )}
 
524
  )}
525
  </div>
526
  )}
527
+ </Reveal>
528
  )}
529
 
530
  {/* ── COACHING TAB ── */}
531
  {activeTab === "coaching" && (
532
+ <Reveal>
533
  <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
534
  Specific suggestions based on patterns observed in this conversation.
535
  Ranked by potential impact.
536
  </p>
537
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
538
  {insights.coaching_suggestions?.map((s, i) => (
539
+ <RevealItem key={i} index={i}>
540
+ <CoachingCard suggestion={s} />
541
+ </RevealItem>
542
  ))}
543
  {(!insights.coaching_suggestions || insights.coaching_suggestions.length === 0) && (
544
  <div style={{ textAlign: "center", padding: 32, color: "#4a4865" }}>
 
546
  </div>
547
  )}
548
  </div>
549
+ </Reveal>
550
  )}
551
 
552
  {/* ── REFLECT TAB ── */}
553
  {activeTab === "reflect" && (
554
+ <Reveal>
555
  <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 20, lineHeight: 1.6 }}>
556
  Questions grounded in what happened in this conversation. Tap any to read the insight.
557
  </p>
 
568
  ) : (
569
  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
570
  {insights.reflection_questions.map((item, i) => (
571
+ <RevealItem key={i} index={i}>
572
+ <ReflectCard item={item} index={i} />
573
+ </RevealItem>
574
  ))}
575
  </div>
576
  )}
577
+ </Reveal>
578
  )}
579
 
580
  {/* ── SIGNALS TAB ── */}
581
  {activeTab === "signals" && (
582
+ <Reveal>
583
  {signals.timeline?.length > 1 && (
584
  <div style={{ marginBottom: 24 }}>
585
  <h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#f0eeff" }}>
586
  Speech Rate Over Time
587
  </h3>
588
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
589
  borderRadius: 10, padding: 16 }}>
590
  <ResponsiveContainer width="100%" height={150}>
591
  <LineChart data={signals.timeline}>
592
  <XAxis dataKey="window_start_s" tickFormatter={formatTime} fontSize={11}
593
+ tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#1e2438" }}
594
+ tickLine={{ stroke: "#1e2438" }} />
595
  <YAxis domain={["auto", "auto"]} fontSize={11} width={35}
596
+ tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#1e2438" }}
597
+ tickLine={{ stroke: "#1e2438" }} />
598
  <Tooltip
599
+ contentStyle={{ background: "#151922", border: "1px solid #1e2438",
600
  borderRadius: 8 }}
601
  labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
602
  labelFormatter={v => `At ${formatTime(v)}`}
603
  formatter={v => [`${Math.round(v)} wpm`, "Speech rate"]} />
604
  <Line type="monotone" dataKey="speech_rate_wpm"
605
+ stroke="#1d4ed8" strokeWidth={2} dot={false} />
606
  </LineChart>
607
  </ResponsiveContainer>
608
  </div>
 
628
  { label: "Within-turn pauses", value: signals.pauses.within_turn_pauses.count },
629
  { label: "Your turns", value: signals.turn_dynamics.user_turns },
630
  ].map(({ label, value }) => (
631
+ <div key={label} style={{ padding: 12, border: "1px solid #1e2438",
632
+ borderRadius: 8, background: "#151922" }}>
633
  <div style={{ fontSize: 11, color: "#4a4865", marginBottom: 4 }}>{label}</div>
634
  <div style={{ fontSize: 16, fontWeight: 600, color: "#f0eeff" }}>{value}</div>
635
  </div>
636
  ))}
637
  </div>
638
+ </Reveal>
639
  )}
640
 
641
+ {/* ── SUMMARY TAB ── */}
642
+ {activeTab === "summary" && (
643
+ <Reveal>
644
+ <p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
645
+ A compact behavioral summary of this session β€” used to build your cross-session profile over time.
646
+ </p>
647
+ {fingerprint ? (
648
+ <div style={{ background: "#151922", border: "1px solid #1e2438",
649
+ borderRadius: 12, padding: 20 }}>
650
+ <div style={{ fontSize: 11, fontWeight: 600, color: "#4a4865",
651
+ textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 12 }}>
652
+ Behavioral Fingerprint
653
+ </div>
654
+ <p style={{ margin: 0, fontSize: 14, color: "#d4d2e8", lineHeight: 1.85 }}>
655
+ {fingerprint}
656
+ </p>
657
+ </div>
658
+ ) : (
659
  <div style={{ textAlign: "center", padding: 48, color: "#4a4865" }}>
660
  <div style={{ fontSize: 32, marginBottom: 12 }}>πŸ“„</div>
661
  <p style={{ fontSize: 14, color: "#8b89aa" }}>
662
+ Summary not available for this session.
663
+ </p>
664
+ <p style={{ fontSize: 12, color: "#4a4865", marginTop: 4 }}>
665
+ New sessions will include this automatically.
666
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  </div>
668
  )}
669
+ </Reveal>
670
  )}
671
 
672
  {/* Disclaimer */}
673
  <div style={{ fontSize: 11, color: "#4a4865", padding: 14, marginTop: 20,
674
+ background: "#151922", borderRadius: 8, lineHeight: 1.7,
675
+ border: "1px solid #1e2438" }}>
676
  <strong style={{ color: "#8b89aa" }}>Note:</strong> All scores and observations are
677
  probabilistic proxies based on acoustic and linguistic patterns. They are not validated
678
  psychological assessments. Use as prompts for self-reflection only.
frontend/src/components/Reveal.jsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useEffect, useRef, useState } from "react"
2
+
3
+ const RevealCtx = createContext(false)
4
+
5
+ // Used inside a <Reveal> to stagger child elements.
6
+ // Opacity-only so it works safely inside overflow:hidden containers.
7
+ export function RevealItem({ children, index = 0, style }) {
8
+ const visible = useContext(RevealCtx)
9
+ return (
10
+ <div style={{
11
+ opacity: visible ? 1 : 0,
12
+ transition: `opacity 0.4s ease ${index * 70}ms`,
13
+ ...style,
14
+ }}>
15
+ {children}
16
+ </div>
17
+ )
18
+ }
19
+
20
+ export default function Reveal({ children, delay = 0, style }) {
21
+ const ref = useRef(null)
22
+ const [visible, setVisible] = useState(false)
23
+
24
+ useEffect(() => {
25
+ const el = ref.current
26
+ if (!el) return
27
+ const observer = new IntersectionObserver(
28
+ ([entry]) => {
29
+ if (entry.isIntersecting) {
30
+ setVisible(true)
31
+ observer.disconnect()
32
+ }
33
+ },
34
+ { threshold: 0.12 }
35
+ )
36
+ observer.observe(el)
37
+ return () => observer.disconnect()
38
+ }, [])
39
+
40
+ return (
41
+ <RevealCtx.Provider value={visible}>
42
+ <div
43
+ ref={ref}
44
+ style={{
45
+ opacity: visible ? 1 : 0,
46
+ transform: visible ? "translateY(0)" : "translateY(44px)",
47
+ transition: `opacity 0.65s cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 0.65s cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
48
+ ...style,
49
+ }}
50
+ >
51
+ {children}
52
+ </div>
53
+ </RevealCtx.Provider>
54
+ )
55
+ }
frontend/src/components/UploadView.jsx CHANGED
@@ -1,8 +1,9 @@
1
  import { useState, useEffect, useRef } from "react"
2
  import api from "../lib/api"
 
3
 
4
  const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
5
- const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
6
 
7
  const PREPARE_STEPS = [
8
  { key: "transcribing", label: "Transcribing audio" },
@@ -36,14 +37,14 @@ function StepProgress({ steps, currentStep, title, onCancel }) {
36
  <div key={step.key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
37
  <div style={{
38
  width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
39
- background: isDone ? G : isCurrent ? "rgba(217,70,239,0.1)" : "#14141f",
40
- border: `2px solid ${isDone ? "transparent" : isCurrent ? "#d946ef" : "#2a2a42"}`,
41
  display: "flex", alignItems: "center", justifyContent: "center",
42
- boxShadow: isCurrent ? "0 0 16px rgba(217,70,239,0.4)" : "none",
43
  transition: "all 0.3s",
44
  }}>
45
  {isDone && <span style={{ color: "white", fontSize: 12, fontWeight: 700 }}>βœ“</span>}
46
- {isCurrent && <div style={{ width: 8, height: 8, background: "#d946ef",
47
  borderRadius: "50%" }} />}
48
  </div>
49
  <div>
@@ -55,7 +56,7 @@ function StepProgress({ steps, currentStep, title, onCancel }) {
55
  {step.label}
56
  </span>
57
  {isCurrent && (
58
- <span style={{ fontSize: 12, color: "#e879f9", marginLeft: 6 }}>
59
  β€” in progress…
60
  </span>
61
  )}
@@ -69,7 +70,7 @@ function StepProgress({ steps, currentStep, title, onCancel }) {
69
  </p>
70
  {onCancel && (
71
  <button onClick={onCancel}
72
- style={{ marginTop: 12, background: "none", border: "1px solid #2a2a42",
73
  borderRadius: 6, padding: "6px 18px", fontSize: 12, color: "#4a4865",
74
  cursor: "pointer" }}>
75
  Cancel
@@ -287,17 +288,17 @@ export default function UploadView({ onResults, onActivate }) {
287
  const hasFile = !!file
288
 
289
  return (
290
- <div>
291
  {/* Drop zone */}
292
  <div
293
  onClick={() => document.getElementById("file-input").click()}
294
  style={{
295
- border: `2px dashed ${hasFile ? "#d946ef" : "#2a2a42"}`,
296
  borderRadius: 16, padding: "52px 32px",
297
  textAlign: "center", marginBottom: 20, cursor: "pointer",
298
- background: hasFile ? "rgba(217,70,239,0.04)" : "#14141f",
299
  transition: "all 0.2s",
300
- boxShadow: hasFile ? "inset 0 0 40px rgba(217,70,239,0.04)" : "none",
301
  }}>
302
  <input id="file-input" type="file" accept="*/*"
303
  style={{ display: "none" }}
@@ -335,20 +336,20 @@ export default function UploadView({ onResults, onActivate }) {
335
  )}
336
 
337
  <button onClick={handlePrepare} disabled={!hasFile}
 
338
  style={{ width: "100%", padding: "15px 24px",
339
- background: hasFile ? G : "#14141f",
340
  color: hasFile ? "white" : "#2a2a42",
341
- border: hasFile ? "none" : "1px solid #2a2a42",
342
  borderRadius: 10, fontSize: 15,
343
  cursor: hasFile ? "pointer" : "not-allowed", fontWeight: 600,
344
- boxShadow: hasFile ? "0 0 28px rgba(217,70,239,0.3)" : "none",
345
- transition: "all 0.2s" }}>
346
  Analyze Conversation
347
  </button>
348
 
349
  <p style={{ fontSize: 11, color: "#4a4865", textAlign: "center", marginTop: 12 }}>
350
  πŸ”’ Audio is deleted immediately after analysis. Only behavioral signals are stored.
351
  </p>
352
- </div>
353
  )
354
  }
 
1
  import { useState, useEffect, useRef } from "react"
2
  import api from "../lib/api"
3
+ import Reveal from "./Reveal"
4
 
5
  const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
6
+ const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
7
 
8
  const PREPARE_STEPS = [
9
  { key: "transcribing", label: "Transcribing audio" },
 
37
  <div key={step.key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
38
  <div style={{
39
  width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
40
+ background: isDone ? G : isCurrent ? "rgba(29,78,216,0.1)" : "#151922",
41
+ border: `2px solid ${isDone ? "transparent" : isCurrent ? "#1d4ed8" : "#1e2438"}`,
42
  display: "flex", alignItems: "center", justifyContent: "center",
43
+ boxShadow: isCurrent ? "0 0 16px rgba(29,78,216,0.4)" : "none",
44
  transition: "all 0.3s",
45
  }}>
46
  {isDone && <span style={{ color: "white", fontSize: 12, fontWeight: 700 }}>βœ“</span>}
47
+ {isCurrent && <div style={{ width: 8, height: 8, background: "#1d4ed8",
48
  borderRadius: "50%" }} />}
49
  </div>
50
  <div>
 
56
  {step.label}
57
  </span>
58
  {isCurrent && (
59
+ <span style={{ fontSize: 12, color: "#5b9cf6", marginLeft: 6 }}>
60
  β€” in progress…
61
  </span>
62
  )}
 
70
  </p>
71
  {onCancel && (
72
  <button onClick={onCancel}
73
+ style={{ marginTop: 12, background: "none", border: "1px solid #1e2438",
74
  borderRadius: 6, padding: "6px 18px", fontSize: 12, color: "#4a4865",
75
  cursor: "pointer" }}>
76
  Cancel
 
288
  const hasFile = !!file
289
 
290
  return (
291
+ <Reveal>
292
  {/* Drop zone */}
293
  <div
294
  onClick={() => document.getElementById("file-input").click()}
295
  style={{
296
+ border: `2px dashed ${hasFile ? "#1d4ed8" : "#1e2438"}`,
297
  borderRadius: 16, padding: "52px 32px",
298
  textAlign: "center", marginBottom: 20, cursor: "pointer",
299
+ background: hasFile ? "rgba(29,78,216,0.04)" : "#151922",
300
  transition: "all 0.2s",
301
+ boxShadow: hasFile ? "inset 0 0 40px rgba(29,78,216,0.04)" : "none",
302
  }}>
303
  <input id="file-input" type="file" accept="*/*"
304
  style={{ display: "none" }}
 
336
  )}
337
 
338
  <button onClick={handlePrepare} disabled={!hasFile}
339
+ className={hasFile ? "btn-grad" : ""}
340
  style={{ width: "100%", padding: "15px 24px",
341
+ background: hasFile ? G : "#151922",
342
  color: hasFile ? "white" : "#2a2a42",
343
+ border: hasFile ? "none" : "1px solid #1e2438",
344
  borderRadius: 10, fontSize: 15,
345
  cursor: hasFile ? "pointer" : "not-allowed", fontWeight: 600,
346
+ boxShadow: hasFile ? "0 0 28px rgba(29,78,216,0.3)" : "none" }}>
 
347
  Analyze Conversation
348
  </button>
349
 
350
  <p style={{ fontSize: 11, color: "#4a4865", textAlign: "center", marginTop: 12 }}>
351
  πŸ”’ Audio is deleted immediately after analysis. Only behavioral signals are stored.
352
  </p>
353
+ </Reveal>
354
  )
355
  }
frontend/src/index.css CHANGED
@@ -5,50 +5,155 @@
5
  }
6
 
7
  body {
8
- background: #0d0d18;
 
 
9
  color: #f0eeff;
10
  font-family: 'Inter', system-ui, -apple-system, sans-serif;
11
  -webkit-font-smoothing: antialiased;
12
  -moz-osx-font-smoothing: grayscale;
13
  }
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  input, textarea, select {
16
  font-family: inherit;
17
- background: #1a1a2e;
18
  color: #f0eeff;
19
- border: 1px solid #2a2a42;
20
  border-radius: 8px;
21
  outline: none;
22
- transition: border-color 0.15s;
23
  }
24
 
25
  input:focus, textarea:focus, select:focus {
26
- border-color: #d946ef;
27
- box-shadow: 0 0 0 2px rgba(217,70,239,0.12);
28
  }
29
 
30
  input::placeholder, textarea::placeholder {
31
- color: #4a4865;
32
  }
33
 
34
  button {
35
  font-family: inherit;
36
  }
37
 
38
- ::-webkit-scrollbar {
39
- width: 6px;
40
- height: 6px;
41
- }
42
 
43
- ::-webkit-scrollbar-track {
44
- background: #0d0d18;
 
45
  }
46
 
47
- ::-webkit-scrollbar-thumb {
48
- background: #2a2a42;
49
- border-radius: 3px;
 
 
 
 
 
 
 
 
50
  }
51
 
52
- ::-webkit-scrollbar-thumb:hover {
53
- background: #3d3d60;
54
  }
 
 
 
 
 
 
 
 
5
  }
6
 
7
  body {
8
+ background: #0f1117;
9
+ background-image: radial-gradient(rgba(255,255,255,0.028) 1px, transparent 1px);
10
+ background-size: 28px 28px;
11
  color: #f0eeff;
12
  font-family: 'Inter', system-ui, -apple-system, sans-serif;
13
  -webkit-font-smoothing: antialiased;
14
  -moz-osx-font-smoothing: grayscale;
15
  }
16
 
17
+ /* ── Animations ────────────────────────────────────────── */
18
+
19
+ @keyframes viewEnter {
20
+ from { opacity: 0; transform: translateY(16px); }
21
+ to { opacity: 1; transform: translateY(0); }
22
+ }
23
+
24
+ @keyframes fadeUp {
25
+ from { opacity: 0; transform: translateY(8px); }
26
+ to { opacity: 1; transform: translateY(0); }
27
+ }
28
+
29
+ @keyframes fadeIn {
30
+ from { opacity: 0; }
31
+ to { opacity: 1; }
32
+ }
33
+
34
+ @keyframes pulse-glow {
35
+ 0%, 100% { box-shadow: 0 0 20px rgba(29,78,216,0.3); }
36
+ 50% { box-shadow: 0 0 40px rgba(29,78,216,0.6); }
37
+ }
38
+
39
+ @keyframes shimmer {
40
+ from { background-position: -200% center; }
41
+ to { background-position: 200% center; }
42
+ }
43
+
44
+ @keyframes blobDrift1 {
45
+ 0% { transform: translate(0, 0) scale(1); }
46
+ 30% { transform: translate(40px, -30px) scale(1.06); }
47
+ 65% { transform: translate(-20px, 25px) scale(0.96); }
48
+ 100% { transform: translate(25px, 15px) scale(1.03); }
49
+ }
50
+
51
+ @keyframes blobDrift2 {
52
+ 0% { transform: translate(0, 0) scale(1); }
53
+ 30% { transform: translate(-35px, 40px) scale(1.04); }
54
+ 65% { transform: translate(25px, -20px) scale(0.97); }
55
+ 100% { transform: translate(-15px, 30px) scale(1.05); }
56
+ }
57
+
58
+ /* ── View transitions ──────────────────────────────────── */
59
+
60
+ .view-enter {
61
+ animation: viewEnter 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
62
+ }
63
+
64
+ .fade-in {
65
+ animation: fadeIn 0.2s ease forwards;
66
+ }
67
+
68
+ /* ── Cards ─────────────────────────────────────────────── */
69
+
70
+ .card {
71
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
72
+ will-change: transform;
73
+ }
74
+
75
+ .card:hover {
76
+ transform: translateY(-4px);
77
+ border-color: #2e3464 !important;
78
+ box-shadow: 0 16px 48px rgba(0,0,0,0.6), 0 0 0 1px rgba(29,78,216,0.15) !important;
79
+ }
80
+
81
+ /* ── Buttons ────────────────────────────────────────────── */
82
+
83
+ .btn-grad {
84
+ transition: transform 0.15s ease, box-shadow 0.2s ease;
85
+ cursor: pointer;
86
+ }
87
+
88
+ .btn-grad:hover {
89
+ transform: translateY(-2px);
90
+ box-shadow: 0 10px 32px rgba(29,78,216,0.45) !important;
91
+ }
92
+
93
+ .btn-grad:active {
94
+ transform: scale(0.97) !important;
95
+ }
96
+
97
+ .btn-ghost {
98
+ transition: color 0.15s ease, background 0.15s ease;
99
+ }
100
+
101
+ .btn-ghost:hover {
102
+ color: #c4c2d8 !important;
103
+ }
104
+
105
+ /* ── Inputs ─────────────────────────────────────────────── */
106
+
107
  input, textarea, select {
108
  font-family: inherit;
109
+ background: #0e1320;
110
  color: #f0eeff;
111
+ border: 1px solid #1e2438;
112
  border-radius: 8px;
113
  outline: none;
114
+ transition: border-color 0.18s, box-shadow 0.18s;
115
  }
116
 
117
  input:focus, textarea:focus, select:focus {
118
+ border-color: #1d4ed8;
119
+ box-shadow: 0 0 0 3px rgba(29,78,216,0.15);
120
  }
121
 
122
  input::placeholder, textarea::placeholder {
123
+ color: #2e3a52;
124
  }
125
 
126
  button {
127
  font-family: inherit;
128
  }
129
 
130
+ /* ── Nav tab indicator ──────────────────────────────────── */
 
 
 
131
 
132
+ .nav-tab {
133
+ position: relative;
134
+ transition: color 0.18s ease;
135
  }
136
 
137
+ .nav-tab::after {
138
+ content: '';
139
+ position: absolute;
140
+ bottom: -1px;
141
+ left: 0;
142
+ right: 0;
143
+ height: 2px;
144
+ border-radius: 2px 2px 0 0;
145
+ background: linear-gradient(90deg, #1d4ed8, #0891b2);
146
+ transform: scaleX(0);
147
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
148
  }
149
 
150
+ .nav-tab.active::after {
151
+ transform: scaleX(1);
152
  }
153
+
154
+ /* ── Scrollbar ──────────────────────────────────────────── */
155
+
156
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
157
+ ::-webkit-scrollbar-track { background: #0f1117; }
158
+ ::-webkit-scrollbar-thumb { background: #1e2438; border-radius: 3px; }
159
+ ::-webkit-scrollbar-thumb:hover { background: #2e3464; }