File size: 33,075 Bytes
d4c6e81
 
d1cde70
d4c6e81
 
 
 
d1cde70
401c4a8
d4c6e81
d1cde70
d4c6e81
 
d1cde70
 
 
 
 
 
 
 
 
d4c6e81
 
22395e0
 
 
 
 
ed3866e
22395e0
 
 
 
 
 
 
 
 
 
 
 
 
ed3866e
d4c6e81
22395e0
 
 
 
 
 
ed3866e
 
 
d4c6e81
 
ed3866e
 
22395e0
 
ed3866e
22395e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed3866e
d1cde70
22395e0
 
 
 
 
ed3866e
 
 
 
 
 
22395e0
 
 
 
 
 
 
 
 
ed3866e
22395e0
 
 
 
 
 
 
ed3866e
 
 
 
 
 
d1cde70
 
ed3866e
d1cde70
 
 
d4c6e81
d1cde70
 
 
 
 
 
 
d4c6e81
 
 
 
ed3866e
 
 
 
 
d4c6e81
22395e0
 
 
 
 
 
 
 
 
 
 
 
 
ed3866e
 
d4c6e81
ed3866e
 
22395e0
 
 
 
d1cde70
22395e0
 
 
d1cde70
 
 
 
 
 
 
ed3866e
d1cde70
 
22395e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1cde70
22395e0
 
 
d1cde70
 
 
 
 
 
ed3866e
d1cde70
ed3866e
 
22395e0
 
d1cde70
ed3866e
 
 
 
3a2b5d9
ed3866e
 
22395e0
 
 
 
 
ed3866e
 
 
 
 
 
d1cde70
ed3866e
 
 
 
 
 
 
 
 
 
 
 
 
 
d1cde70
ed3866e
 
 
c1392fe
ed3866e
 
 
 
 
 
 
 
 
d1cde70
ed3866e
 
 
d4c6e81
 
 
 
ed3866e
d1cde70
ed3866e
22395e0
 
d4c6e81
ed3866e
 
 
 
 
22395e0
 
 
 
 
d1cde70
ed3866e
 
 
 
 
d1cde70
22395e0
 
 
 
ed3866e
 
 
d1cde70
22395e0
 
 
ed3866e
d1cde70
d4c6e81
 
ed3866e
d4c6e81
 
 
ed3866e
d1cde70
ed3866e
d1cde70
 
 
 
 
 
d4c6e81
d1cde70
 
 
 
 
 
ed3866e
d1cde70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed3866e
 
d1cde70
 
 
 
 
d4c6e81
 
 
 
22395e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed3866e
d1cde70
0507574
d4c6e81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1cde70
7dd0d81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1cde70
d4c6e81
d1cde70
d4c6e81
d1cde70
0507574
d4c6e81
0338292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4c6e81
 
 
 
22395e0
401c4a8
d4c6e81
ed3866e
 
d4c6e81
 
 
 
 
 
 
 
d1cde70
d4c6e81
401c4a8
 
 
 
d1cde70
401c4a8
 
 
 
 
 
 
 
 
d1cde70
401c4a8
 
d4c6e81
d1cde70
d4c6e81
22395e0
 
 
401c4a8
 
 
 
 
 
 
22395e0
 
 
 
 
 
 
 
ed3866e
 
5748a9d
d1cde70
ed3866e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22395e0
ed3866e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22395e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed3866e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbd59f3
ed3866e
 
 
 
 
d4c6e81
 
d1cde70
d4c6e81
 
ed3866e
d1cde70
 
ed3866e
d1cde70
d4c6e81
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
import { useState, useEffect } from "react"
import {
  RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
  LineChart, Line, XAxis, YAxis, Tooltip,
  ResponsiveContainer, CartesianGrid,
} from "recharts"
import api from "../lib/api"
import Reveal, { RevealItem } from "./Reveal"
import { SIGNAL_COLORS } from "../lib/dimensionColors"

const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"

const CONTEXT_LABELS = {
  social: "Casual & Low-Stakes", collaborative: "Collaborative",
  evaluative: "Interview & Review Β· High Stakes", influential: "Persuading & Pitching",
  negotiation: "Negotiation", adversarial: "Conflict & Friction",
  developmental: "Coaching & Feedback", support: "Supportive Listening",
  intimate: "Deep Personal", casual: "Casual & Low-Stakes", meeting: "Meeting",
  job_interview: "Interview & Review Β· High Stakes", disagreement: "Conflict & Friction",
  presentation: "Interview & Review Β· High Stakes", sales_call: "Persuading & Pitching",
  feedback_conversation: "Coaching & Feedback", coaching_call: "Coaching & Feedback",
  first_date: "Deep Personal",
}

// Mirrors backend/pipeline/portrait_synthesizer.py's _SIGNAL_FORMAT exactly β€”
// kept as a literal mirror (same scale/fmt/unit per key) rather than a fresh
// guess, since two independently hand-maintained copies drifting apart is
// exactly what caused this map to go stale after the 9β†’15 dimension rebuild.
// pacing_arc/energy_arc intentionally absent β€” categorical, see formatMean.
const SIGNAL_FORMAT = {
  talk_ratio:                 { scale: 100, digits: 0, unit: "% of speaking time" },
  curiosity:                  { scale: 1,   digits: 2, unit: " question-turns per 100 words" },
  turn_taking_assertiveness:  { scale: 1,   digits: 1, unit: " interruptions per 10 speaker changes" },
  conversational_drive:       { scale: 100, digits: 0, unit: "% drive score" },
  hedging:                    { scale: 1,   digits: 1, unit: " hedging phrases per 100 words" },
  directness:                 { scale: 1,   digits: 1, unit: " direct/assertive phrases per 100 words" },
  building_on_others:         { scale: 100, digits: 0, unit: "% of your turns build on someone else's point" },
  pace:                       { scale: 1,   digits: 0, unit: " words per minute" },
  vocal_expressiveness:       { scale: 1,   digits: 1, unit: " Hz of pitch variation" },
  turn_length:                { scale: 1,   digits: 1, unit: "s per turn on average" },
  vocabulary_richness:        { scale: 100, digits: 0, unit: "% unique words in a typical stretch of speech" },
  fillers:                    { scale: 1,   digits: 2, unit: " filler words per 100 words" },
  response_latency:           { scale: 1,   digits: 1, unit: "s before responding" },
}

const CATEGORICAL_SIGNAL_KEYS = new Set(["pacing_arc", "energy_arc"])

function formatMean(signalKey, mean, modeLabel) {
  if (CATEGORICAL_SIGNAL_KEYS.has(signalKey)) {
    return modeLabel ? `consistently ${modeLabel}` : ""
  }
  const f = SIGNAL_FORMAT[signalKey]
  if (!f || mean == null) return ""
  return `${(mean * f.scale).toFixed(f.digits)}${f.unit}`
}

// Practical chart-scaling ranges only β€” NOT a comparative score. Each signal's
// natural range is mapped to 0-100 purely so the spectrum chart has something
// sensible to plot; nothing here is shown to the user as a number. Categorical
// signals (pacing_arc, energy_arc) use CATEGORICAL_SPECTRUM_POSITIONS instead.
const SPECTRUM_RANGES = {
  talk_ratio:                 [0, 1],
  curiosity:                  [0, 10],
  turn_taking_assertiveness:  [0, 5],
  conversational_drive:       [0, 1],
  hedging:                    [0, 10],
  directness:                 [0, 10],
  building_on_others:         [0, 1],
  pace:                       [80, 220],
  vocal_expressiveness:       [10, 80],
  turn_length:                [3, 45],
  vocabulary_richness:        [0.3, 0.8],
  fillers:                    [0, 8],
  response_latency:           [0, 5],
}

// Fixed positions for categorical dimensions β€” same "chart-scaling only, not a
// comparative score" spirit as SPECTRUM_RANGES, just for a mode label instead
// of a numeric mean. "stable" sits at the visual center, the two directional
// extremes split toward either side.
const CATEGORICAL_SPECTRUM_POSITIONS = {
  pacing_arc: { decelerating: 25, stable: 50, accelerating: 75 },
  energy_arc: { decreasing: 25, stable: 50, increasing: 75 },
}

function spectrumPosition(signalKey, mean, modeLabel) {
  if (CATEGORICAL_SIGNAL_KEYS.has(signalKey)) {
    const positions = CATEGORICAL_SPECTRUM_POSITIONS[signalKey]
    return positions?.[modeLabel] ?? 0
  }
  const [lo, hi] = SPECTRUM_RANGES[signalKey] || [0, 1]
  if (mean == null) return 0
  const pct = ((mean - lo) / (hi - lo)) * 100
  return Math.max(4, Math.min(100, pct))
}


// Curated to 7 β€” deliberately not all 15 tracked dimensions ("we can just
// best 5-7 trends which are really worthy to earn that place"). Selection
// follows CLAUDE.md's own priority order (relational lead, one delivery
// slot) β€” pacing_arc/energy_arc excluded structurally (categorical, can't
// plot on a line chart); building_on_others/vocabulary_richness/
// vocal_expressiveness/turn_length/response_latency/fillers excluded per
// weaknesses already on record elsewhere in this project (weakest proxy,
// framing risk, audio noise, noisier delivery signals, "never the headline").
const SIGNAL_OPTIONS = [
  { key: "talk_ratio",       label: "Talk-share",               unit: "%" },
  { key: "curiosity_rate",   label: "Curiosity",                unit: "/100w" },
  { key: "hedging_rate",     label: "Hedging",                  unit: "/100w" },
  { key: "directness_rate",  label: "Directness",               unit: "/100w" },
  { key: "drive_score",      label: "Conversational drive",     unit: "%" },
  { key: "wpm",              label: "Pace",                     unit: "wpm" },
  { key: "turn_taking_rate", label: "Turn-taking assertiveness", unit: "/10" },
]

const FRAMING_CONFIG = {
  strength:    { color: "#34d399", label: "Strength" },
  growth_area: { color: "#fb923c", label: "Growth area" },
  observation: { color: "#818cf8", label: "Observation" },
}


// ── Section label ─────────────────────────────────────────────────

function SectionLabel({ children }) {
  return (
    <div style={{
      fontSize: 10, fontWeight: 700, letterSpacing: 1.4,
      textTransform: "uppercase", marginBottom: 4,
      background: G, WebkitBackgroundClip: "text",
      WebkitTextFillColor: "transparent", backgroundClip: "text",
    }}>
      {children}
    </div>
  )
}

// ── Spectrum chart ─────────────────────────────────────────────────
// Real measured signals as axes (never invented trait names), self-relative
// chart-scaling (never a population comparison), no number printed anywhere β€”
// CLAUDE.md's "fingerprint spectrums", not a graded score. Only steady signals
// get a plotted point; not-yet-steady axes are omitted rather than faked.

// Custom PolarAngleAxis tick β€” tints each axis label with that signal's
// identity color from SIGNAL_COLORS, ties Shape into the same color language
// as Reflected Back / Context-Shift cards without a meaningless multi-color
// fill region (a radar's fill only makes sense as one series).
function ColoredAxisTick({ x, y, payload, colorBySubject }) {
  const color = colorBySubject[payload.value] || "#8b89aa"
  return (
    <text x={x} y={y} textAnchor="middle" fontSize={11} fill={color}>
      {payload.value}
    </text>
  )
}

function SpectrumChart({ steady }) {
  if (!steady?.length) return null

  const radarData = steady.map(s => ({
    subject: s.label,
    value: spectrumPosition(s.signal_key, s.mean, s.mode_label),
    recent: s.recent_mean != null
      ? spectrumPosition(s.signal_key, s.recent_mean, s.mode_label)
      : null,
  }))
  const colorBySubject = Object.fromEntries(
    steady.map(s => [s.label, SIGNAL_COLORS[s.signal_key] || "#8b89aa"])
  )

  return (
    <div style={{
      background: "#151922", border: "1px solid #1e2438",
      borderRadius: 12, padding: "8px 0",
      boxShadow: "0 2px 16px rgba(0,0,0,0.3)"
    }}>
      <ResponsiveContainer width="100%" height={260}>
        <RadarChart data={radarData} cx="50%" cy="50%" outerRadius="66%">
          <PolarGrid stroke="#1e2438" />
          <PolarAngleAxis dataKey="subject"
            tick={<ColoredAxisTick colorBySubject={colorBySubject} />} />
          {/* niceTicks="none" is load-bearing, not decorative: Recharts 3.x's
              default niceTicks="auto" can silently EXTEND even a fixed [0,100]
              domain (combineAxisDomainWithNiceTicks takes Math.max(domainMax,
              lastNiceTickValue)) β€” without this, a value of exactly 100 was
              rendering at ~55% of the outer radius instead of 100%, discovered
              via a Playwright pixel-position check, not visible from the code
              alone. Confirmed via recharts/es6/state/selectors/axisSelectors.js. */}
          {/* type="number" / niceTicks="none" / allowDataOverflow / explicit
              radiusAxisId matching are all real, best-practice hardening for
              a fixed 0-100 domain (verified: Recharts' default niceTicks
              can silently expand a fixed domain β€” see combineAxisDomainWithNiceTicks
              in recharts/es6/state/selectors/axisSelectors.js). Flagging
              honestly: even with all of these, a Playwright pixel-measurement
              check found the *absolute* size of the plotted polygon is still
              compressed relative to the grid's outer ring in this Recharts
              version (3.8.1) β€” confirmed via tick-label ground truth, not
              speculation. The distortion is uniform across all axes in a
              single render (verified: every axis in a 4-signal test showed
              the identical ratio), so relative shape/proportions β€” the
              actual information this chart carries, per its own "no numbers,
              just shape" design β€” are NOT corrupted, only the polygon's
              absolute size within the frame looks smaller than intended.
              Root cause not fully isolated after substantial investigation;
              worth a follow-up pass, not blocking this build. */}
          <PolarRadiusAxis radiusAxisId={0} type="number" domain={[0, 100]} niceTicks="none" allowDataOverflow tick={false} axisLine={false} />
          <Radar radiusAxisId={0} dataKey="value" name="Established"
            stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.18} strokeWidth={2.5} />
          <Radar radiusAxisId={0} dataKey="recent" name="Recently"
            stroke="#f0eeff" fill="none" strokeWidth={1.75} strokeDasharray="4 3"
            connectNulls={false} />
        </RadarChart>
      </ResponsiveContainer>
    </div>
  )
}

// ── Steady signals β€” established patterns ──────────────────────────

function SteadySignalCard({ item, i }) {
  const cfg = FRAMING_CONFIG[item.framing] || FRAMING_CONFIG.observation
  const dotColor = SIGNAL_COLORS[item.signal_key] || "#8b89aa"
  const evidence = formatMean(item.signal_key, item.mean, item.mode_label)
  return (
    <RevealItem index={i}>
    <div style={{
      padding: "14px 16px", borderRadius: 10,
      background: "#151922", border: "1px solid #1e2438",
      borderLeft: `3px solid ${dotColor}`,
    }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
        <span style={{ display: "flex", alignItems: "center", gap: 7 }}>
          <span style={{ width: 7, height: 7, borderRadius: "50%", background: dotColor, flexShrink: 0 }} />
          <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>
            {item.label}
          </span>
        </span>
        <span style={{ fontSize: 11, color: cfg.color, fontWeight: 600,
          background: `${cfg.color}15`, border: `1px solid ${cfg.color}30`,
          borderRadius: 20, padding: "3px 10px", whiteSpace: "nowrap", marginLeft: 12 }}>
          {cfg.label}
        </span>
      </div>
      {item.note && (
        <p style={{ margin: "0 0 8px", fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
          {item.note}
        </p>
      )}
      {evidence && (
        <p style={{ margin: 0, fontSize: 11, color: "#4a4865" }}>
          Evidence: {evidence} Β· based on {item.sample_count} sessions
        </p>
      )}
    </div>
    </RevealItem>
  )
}

// ── Still forming β€” not enough evidence yet ────────────────────────

function StillFormingRow({ item }) {
  const pct = Math.min(100, Math.round((item.sample_count / item.min_needed) * 100))
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12,
      padding: "9px 0", borderBottom: "1px solid #131827" }}>
      <span style={{ fontSize: 13, color: "#8b89aa", minWidth: 150, flexShrink: 0 }}>
        {item.label}
      </span>
      <div style={{ flex: 1, height: 3, background: "#1e2438", borderRadius: 2 }}>
        <div style={{ height: "100%", borderRadius: 2, width: `${pct}%`,
          background: "#3a3a52" }} />
      </div>
      <span style={{ fontSize: 11, color: "#4a4865", whiteSpace: "nowrap" }}>
        {item.sample_count} of {item.min_needed} sessions
      </span>
    </div>
  )
}

// ── How you shift by context ───────────────────────────────────────

function ContextShiftCard({ item, i }) {
  const dotColor = SIGNAL_COLORS[item.signal_key] || "#8b89aa"
  const isCategorical = CATEGORICAL_SIGNAL_KEYS.has(item.signal_key)
  return (
    <RevealItem index={i}>
    <div style={{
      padding: "14px 16px", borderRadius: 10,
      background: "#151922", border: "1px solid #1e2438",
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
        <span style={{ width: 7, height: 7, borderRadius: "50%", background: dotColor, flexShrink: 0 }} />
        <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>
          {item.label}
        </span>
      </div>
      {item.note && (
        <p style={{ margin: "0 0 10px", fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
          {item.note}
        </p>
      )}
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
        {/* by_context values are a raw mean (continuous signals) or the mode
            label itself (categorical signals) β€” matches main.py's value_field
            selection in get_profile(). */}
        {Object.entries(item.by_context).map(([ctx, value]) => (
          <span key={ctx} style={{
            fontSize: 11, color: "#8b89aa", background: "#0e1320",
            border: "1px solid #1e2438", borderRadius: 20, padding: "4px 12px",
          }}>
            {CONTEXT_LABELS[ctx] || ctx}: {isCategorical
              ? formatMean(item.signal_key, null, value)
              : formatMean(item.signal_key, value)}
          </span>
        ))}
      </div>
    </div>
    </RevealItem>
  )
}

// ── Signal Trends (collapsible, raw history β€” not a pattern claim) ─

function SignalTrends({ chartData }) {
  const [open, setOpen] = useState(false)
  const [activeSignal, setActiveSignal] = useState("talk_ratio")
  if (chartData.length < 2) return null

  const signalConfig = SIGNAL_OPTIONS.find(s => s.key === activeSignal)

  return (
    <div className="card" style={{ border: "1px solid #1e2438", borderRadius: 12,
      background: "#151922", overflow: "hidden" }}>
      <div onClick={() => setOpen(v => !v)}
        style={{ padding: "14px 18px", display: "flex", justifyContent: "space-between",
          alignItems: "center", cursor: "pointer",
          borderBottom: open ? "1px solid #1e2438" : "none" }}>
        <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>Signal History</span>
        <span style={{ fontSize: 11, color: "#4a4865" }}>{open ? "β–² close" : "β–Ό expand"}</span>
      </div>

      {open && (
        <div style={{ padding: "16px 18px" }}>
          <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
            {SIGNAL_OPTIONS.map(s => {
              const isActive = activeSignal === s.key
              return (
                <button key={s.key} onClick={() => setActiveSignal(s.key)}
                  style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
                    cursor: "pointer", fontWeight: isActive ? 600 : 400,
                    background: isActive ? "rgba(59,130,246,0.12)" : "#0e1320",
                    color: isActive ? "#60a5fa" : "#8b89aa",
                    border: isActive ? "1px solid rgba(59,130,246,0.3)" : "1px solid #1e2438",
                    transition: "all 0.15s" }}>
                  {s.label}
                </button>
              )
            })}
          </div>

          <div style={{ borderRadius: 8, padding: "12px 0" }}>
            <ResponsiveContainer width="100%" height={180}>
              <LineChart data={chartData}>
                <CartesianGrid strokeDasharray="3 3" stroke="#1e2438" />
                <XAxis dataKey="label" fontSize={11} tick={{ fill: "#8b89aa" }}
                  axisLine={{ stroke: "#1e2438" }} tickLine={{ stroke: "#1e2438" }} />
                <YAxis fontSize={11} tick={{ fill: "#8b89aa" }} width={38}
                  axisLine={{ stroke: "#1e2438" }} tickLine={{ stroke: "#1e2438" }}
                  tickFormatter={v => `${v}${signalConfig?.unit || ""}`} />
                <Tooltip
                  contentStyle={{ background: "#151922", border: "1px solid #1e2438", borderRadius: 8 }}
                  labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
                  formatter={v => [`${v}${signalConfig?.unit || ""}`, signalConfig?.label]}
                  labelFormatter={(_, p) => p?.[0]?.payload?.date || ""}
                />
                <Line type="monotone" dataKey={activeSignal}
                  stroke="#3b82f6" strokeWidth={2.5}
                  dot={{ r: 4, fill: "#3b82f6", strokeWidth: 0 }}
                  activeDot={{ r: 6, fill: "#22d3ee" }}
                  connectNulls />
              </LineChart>
            </ResponsiveContainer>
          </div>
        </div>
      )}
    </div>
  )
}

// ── Portrait paragraph + tags ────────────────────────────────────────
// Narrative connective tissue between several established signals, plus a
// handful of short behavior-phrase chips β€” deliberately kept even though it
// overlaps with the itemized cards below, for the visual/skimmable value.
// Absent entirely (not a placeholder) until >=1 signal is steady.

function PortraitParagraph({ text, tags }) {
  if (!text) return null
  return (
    <div>
      <p style={{
        margin: tags?.length ? "0 0 10px" : 0, fontSize: 14, color: "#c4c2d8",
        lineHeight: 1.75, fontStyle: "italic",
      }}>
        "{text}"
      </p>
      {tags?.length > 0 && (
        <div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
          {tags.map((tag, i) => (
            <span key={i} style={{
              fontSize: 11.5, fontWeight: 600, color: "#a5b4fc",
              background: "rgba(129,140,248,0.10)", border: "1px solid rgba(129,140,248,0.22)",
              borderRadius: 20, padding: "4px 11px", whiteSpace: "nowrap",
            }}>
              {tag}
            </span>
          ))}
        </div>
      )}
    </div>
  )
}

// ── Global indicators strip ──────────────────────────────────────────
// Plain observational facts about the user's own history (not evidence-gated
// patterns) β€” visible from session 1. Option B styling: stat tiles with a
// colored left-border accent, reusing HomeView's existing card language.

function formatDuration(totalSeconds) {
  if (!totalSeconds) return "0m"
  const h = Math.floor(totalSeconds / 3600)
  const m = Math.round((totalSeconds % 3600) / 60)
  return h > 0 ? `${h}h ${m}m` : `${m}m`
}

function StatTile({ value, label, accent }) {
  return (
    <div style={{
      background: "#151922", border: "1px solid #1e2438",
      borderLeft: `3px solid ${accent}`, borderRadius: 10, padding: "11px 12px",
    }}>
      <div style={{ fontSize: 19, fontWeight: 700, color: "#f0eeff",
        fontVariantNumeric: "tabular-nums", letterSpacing: "-0.3px", lineHeight: 1.15 }}>
        {value}
      </div>
      <div style={{ marginTop: 3, fontSize: 10.5, color: "#6b6888" }}>
        {label}
      </div>
    </div>
  )
}

function IndicatorsStrip({ indicators }) {
  if (!indicators) return null
  const tiles = [
    { value: indicators.session_count, label: "Sessions", accent: "#5b9cf6" },
    { value: formatDuration(indicators.time_recorded_s), label: "Recorded", accent: "#34d399" },
    indicators.avg_talk_share != null && {
      value: `${Math.round(indicators.avg_talk_share * 100)}%`, label: "Avg talk-share", accent: "#818cf8",
    },
    indicators.avg_pace != null && {
      value: Math.round(indicators.avg_pace), label: "Avg WPM", accent: "#f59e0b",
    },
    { value: `${indicators.contexts_covered} / ${indicators.contexts_total}`, label: "Contexts", accent: "#22d3ee" },
  ].filter(Boolean)

  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(96px, 1fr))", gap: 8 }}>
      {tiles.map((t, i) => <StatTile key={i} {...t} />)}
    </div>
  )
}

// ── Where you might grow β€” coaching synthesis ────────────────────────
// Distilled recurring themes from coaching_suggestions, grouped by
// dimension_key server-side β€” a concrete-instance complement to Reflected
// Back's aggregate framing, not a restatement of it.

function CoachingCard({ item, i }) {
  const dotColor = SIGNAL_COLORS[item.dimension_key] || "#8b89aa"
  return (
    <RevealItem index={i}>
    <div style={{
      padding: "14px 16px", borderRadius: 10,
      background: "#151922", border: "1px solid #1e2438",
      borderLeft: `3px solid ${dotColor}`,
    }}>
      {item.pattern && (
        <p style={{ margin: "0 0 8px", fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
          {item.pattern}
        </p>
      )}
      {item.suggestion && (
        <p style={{ margin: "0 0 8px", fontSize: 12.5, color: "#a5a3c2", lineHeight: 1.6 }}>
          <span style={{ fontWeight: 600, color: "#c4c2d8" }}>Try: </span>
          {item.suggestion}
        </p>
      )}
      <p style={{ margin: 0, fontSize: 11, color: "#4a4865" }}>
        Came up in {item.recurrence_count} of your recent sessions
      </p>
    </div>
    </RevealItem>
  )
}

// ── Main ProfileView ("You" page) ──────────────────────────────────

export default function ProfileView({ active }) {
  const [profile, setProfile] = useState(null)
  const [trends, setTrends] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (!active) return
    setLoading(true)
    Promise.all([api.get("/api/profile"), api.get("/api/trends")])
      .then(([profileRes, trendsRes]) => {
        setProfile(profileRes.data)
        setTrends(trendsRes.data.data || [])
      })
      .catch(console.error)
      .finally(() => setLoading(false))
  }, [active])

  if (loading) return (
    <div style={{ textAlign: "center", padding: 60, color: "#4a4865" }}>
      Loading your profile…
    </div>
  )

  if (!profile || profile.insufficient_data) {
    return (
      <div style={{ textAlign: "center", padding: "60px 20px" }}>
        <div style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}>
          <svg width="52" height="52" viewBox="0 0 52 52" fill="none">
            <defs>
              <linearGradient id="pv-empty-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
                <stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
              </linearGradient>
            </defs>
            <rect x="2"  y="20" width="6"  height="6"  rx="3" fill="url(#pv-empty-g)" opacity=".35"/>
            <rect x="11" y="13" width="6"  height="13" rx="3" fill="url(#pv-empty-g)" opacity=".6"/>
            <rect x="20" y="6"  width="8"  height="20" rx="4" fill="url(#pv-empty-g)"/>
            <rect x="31" y="13" width="6"  height="13" rx="3" fill="url(#pv-empty-g)" opacity=".6"/>
            <rect x="40" y="20" width="6"  height="6"  rx="3" fill="url(#pv-empty-g)" opacity=".35"/>
            <line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
            <rect x="2"  y="29" width="6"  height="6"  rx="3" fill="url(#pv-empty-g)" opacity=".15"/>
            <rect x="11" y="29" width="6"  height="13" rx="3" fill="url(#pv-empty-g)" opacity=".27"/>
            <rect x="20" y="29" width="8"  height="20" rx="4" fill="url(#pv-empty-g)" opacity=".33"/>
            <rect x="31" y="29" width="6"  height="13" rx="3" fill="url(#pv-empty-g)" opacity=".27"/>
            <rect x="40" y="29" width="6"  height="6"  rx="3" fill="url(#pv-empty-g)" opacity=".15"/>
          </svg>
        </div>
        <h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px", color: "#f0eeff" }}>
          Your mirror is waiting
        </h2>
        <p style={{ fontSize: 14, color: "#8b89aa", margin: "0 0 24px", lineHeight: 1.6 }}>
          Record your first Google Meet call to start building your profile.
        </p>
        <a href="https://meet.google.com/" target="_blank" rel="noreferrer"
          style={{ display: "inline-block", padding: "12px 28px", background: G,
            color: "white", border: "none", borderRadius: 8, fontSize: 14,
            fontWeight: 600, textDecoration: "none",
            boxShadow: "0 0 24px rgba(29,78,216,0.25)" }}>
          Open Google Meet β†’
        </a>

        <div style={{ maxWidth: 320, margin: "48px auto 0", textAlign: "left" }}>
          <p style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1.2,
            textTransform: "uppercase", color: "#4a4865", margin: "0 0 12px" }}>
            What you'll see here
          </p>
          {[
            "Patterns steady enough to call your own β€” hedging, directness, how you build on others",
            "How you shift across different kinds of calls",
            "Growth areas, framed as possibility, not a grade",
          ].map(line => (
            <div key={line} style={{ display: "flex", gap: 10, marginBottom: 10 }}>
              <span style={{ color: "#1d4ed8", flexShrink: 0 }}>Β·</span>
              <p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.55 }}>
                {line}
              </p>
            </div>
          ))}
        </div>
      </div>
    )
  }

  const { profile_strength, portrait, how_you_shift_by_context,
          indicators, portrait_paragraph, portrait_tags, coaching } = profile

  const steady = portrait?.steady || []
  const stillForming = portrait?.still_forming || []

  const chartData = trends.map((point, i) => ({
    ...point,
    label: `S${i + 1}`,
    date: new Date(point.date).toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
  }))

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 28 }}>

      {/* Header β€” confidence-only now: how much the app knows about you.
          Facts about you (session count, etc.) live in the indicators
          strip below instead. No page title here β€” the "You" nav tab
          already says which page this is. */}
      <Reveal>
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        {profile_strength?.label && (
          <>
            <span style={{ fontSize: 12, color: "#4a4865" }}>{profile_strength.label}</span>
            <div style={{ width: 60, height: 3, background: "#1e2438",
              borderRadius: 2, overflow: "hidden" }}>
              <div style={{ height: "100%", borderRadius: 2,
                width: `${profile_strength.pct}%`, background: G,
                transition: "width 0.6s ease" }} />
            </div>
          </>
        )}
      </div>
      </Reveal>

      {/* Portrait paragraph + tags β€” absent until >=1 signal is steady */}
      {portrait_paragraph && (
        <Reveal delay={40}>
          <div>
            <SectionLabel>Standing Portrait</SectionLabel>
            <h2 style={{ fontSize: 14, fontWeight: 700, margin: "0 0 14px", color: "#f0eeff" }}>
              Your Portrait
            </h2>
            <PortraitParagraph text={portrait_paragraph} tags={portrait_tags} />
          </div>
        </Reveal>
      )}

      {/* Global indicators strip β€” plain facts, visible from session 1 */}
      <Reveal delay={60}>
        <IndicatorsStrip indicators={indicators} />
      </Reveal>

      {/* Spectrum β€” fingerprint shape across your established signals */}
      {steady.length > 0 && (
        <Reveal delay={80}>
        <div>
          <SectionLabel>Your Spectrum</SectionLabel>
          <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
            Your Shape
          </h2>
          <p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 14px" }}>
            Each axis is a real, measured signal β€” positioned relative to your own history, never compared to anyone else
          </p>
          <SpectrumChart steady={steady} />
        </div>
        </Reveal>
      )}

      {/* Established patterns */}
      {steady.length > 0 && (
        <Reveal delay={100}>
        <div>
          <div style={{ marginBottom: 12 }}>
            <SectionLabel>Established</SectionLabel>
            <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
              Reflected Back
            </h2>
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {steady.map((item, i) => (
              <SteadySignalCard key={item.signal_key} item={item} i={i} />
            ))}
          </div>
        </div>
        </Reveal>
      )}

      {/* How you shift by context */}
      {how_you_shift_by_context?.length > 0 && (
        <Reveal delay={100}>
        <div>
          <div style={{ marginBottom: 12 }}>
            <SectionLabel>Context Breakdown</SectionLabel>
            <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
              How You Shift By Context
            </h2>
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {how_you_shift_by_context.map((item, i) => (
              <ContextShiftCard key={item.signal_key} item={item} i={i} />
            ))}
          </div>
        </div>
        </Reveal>
      )}

      {/* Where you might grow β€” coaching synthesis, deliberately after the
          descriptive sections above rather than near the top: observe first,
          reflect on growth after. Absent until >=3 sessions and >=1 recurring
          theme β€” not a repeat of a single session's own coaching card. */}
      {coaching?.length > 0 && (
        <Reveal delay={100}>
        <div>
          <div style={{ marginBottom: 12 }}>
            <SectionLabel>Growth</SectionLabel>
            <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
              Where You Might Grow
            </h2>
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {coaching.map((item, i) => (
              <CoachingCard key={item.dimension_key} item={item} i={i} />
            ))}
          </div>
        </div>
        </Reveal>
      )}

      {/* Still forming */}
      {stillForming.length > 0 && (
        <Reveal delay={100}>
        <div>
          <div style={{ marginBottom: 12 }}>
            <SectionLabel>Building Evidence</SectionLabel>
            <h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
              Still Forming
            </h2>
            <p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
              Not enough evidence yet to describe these as established β€” that's expected early on
            </p>
          </div>
          <div>
            {stillForming.map(item => (
              <StillFormingRow key={item.signal_key} item={item} />
            ))}
          </div>
        </div>
        </Reveal>
      )}

      {/* Signal History β€” collapsible, raw data */}
      {chartData.length >= 2 && (
        <Reveal>
          <SignalTrends chartData={chartData} />
        </Reveal>
      )}

    </div>
  )
}