-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsourcing-compass.html
More file actions
2113 lines (1989 loc) · 140 KB
/
Copy pathsourcing-compass.html
File metadata and controls
2113 lines (1989 loc) · 140 KB
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
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sourcing Compass — Talent Intelligence</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
/* Phase 3 — token adoption: register brand + category color utilities backed by the
shared CSS tokens, so category colors flow from --cat-* (decision 3). Must run after
the Tailwind CDN and before the app renders. Structural gray-* utilities are left
unchanged in this pass, pending sign-off on the neutral-palette migration. */
tailwind.config = {
theme: { extend: { colors: {
"cat-companies": "var(--cat-companies)", "cat-companies-soft": "var(--cat-companies-bg)", "cat-companies-ink": "var(--cat-companies-ink)",
"cat-adjacent": "var(--cat-adjacent)", "cat-adjacent-soft": "var(--cat-adjacent-bg)", "cat-adjacent-ink": "var(--cat-adjacent-ink)",
"cat-wildcards": "var(--cat-wildcards)", "cat-wildcards-soft": "var(--cat-wildcards-bg)", "cat-wildcards-ink": "var(--cat-wildcards-ink)",
"cat-titles": "var(--cat-titles)", "cat-titles-soft": "var(--cat-titles-bg)", "cat-titles-ink": "var(--cat-titles-ink)",
"brand-accent": "var(--color-accent)", "brand-surface": "var(--color-surface)",
"brand-line": "var(--color-border)", "brand-ink": "var(--color-text)",
/* Neutral migration: remap Tailwind gray to the Marvell warm-neutral ramp.
Reversible — delete this `gray` block to restore Tailwind default gray. */
gray: {
50: "var(--mv-g50)", 100: "var(--mv-g100)", 200: "var(--mv-g200)", 300: "var(--mv-g300)",
400: "var(--mv-g400)", 500: "var(--mv-g500)", 600: "var(--mv-g600)", 700: "var(--mv-g700)",
800: "#2a2c2b", 900: "var(--mv-black)", 950: "#161817"
}
} } }
};
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<style>
/* ============================================================
TALENT INTELLIGENCE OS — SHARED DESIGN TOKENS (v1, inlined)
Identical block across all modules. Theme via [data-theme].
Phase 0: inert — declared but not yet consumed by this module.
============================================================ */
:root{
/* Brand core */
--mv-blue:#0072CE; --mv-cyan:#00B5E2; --mv-teal:#00C7B1;
/* Brand on-dark variants */
--mv-blue-bright:#448CF4; --mv-cyan-bright:#00C8F2; --mv-teal-bright:#00D4B2;
/* Neutral ramp */
--mv-black:#212322; --mv-g700:#333535; --mv-g600:#4a4c4d; --mv-g500:#6d6f70;
--mv-g400:#9a9c9d; --mv-g300:#c5c7c8; --mv-g200:#dfe1e2; --mv-g150:#e8eaeb;
--mv-g100:#f0f2f3; --mv-g50:#f8fafb; --mv-g25:#fcfdfd; --mv-white:#FFFFFF;
/* Dark neutral ramp (Talent Signal OS) */
--mv-d-bg:#080b14; --mv-d-bg2:#0a0e1a; --mv-d-panel:#0f1422; --mv-d-panel2:#121a2e;
--mv-d-panel3:#161f38; --mv-d-line:#1d2740; --mv-d-line2:#26314f;
--mv-d-txt:#e6edf7; --mv-d-txt2:#8e9bb8; --mv-d-txt3:#5d6885;
/* Status hues (light-bg tuned) */
--mv-red:#dc2626; --mv-amber:#d97706; --mv-green:#059669; --mv-sky:#0284c7; --mv-purple:#7c3aed;
/* Status hues (dark-bg lifted) */
--mv-red-lift:#f87171; --mv-amber-lift:#fbbf24; --mv-green-lift:#34d399; --mv-violet-lift:#a78bfa;
/* Type */
--font-sans:'Inter',system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
--font-mono:'Consolas','Menlo',ui-monospace,monospace;
--text-2xs:9px; --text-xs:10.5px; --text-sm:12px; --text-base:13px; --text-md:14px;
--text-lg:16px; --text-xl:18px; --text-2xl:21px; --text-3xl:26px;
--weight-regular:400; --weight-medium:500; --weight-semibold:600; --weight-bold:700; --weight-heavy:800;
--tracking-tight:-.5px; --tracking-normal:0; --tracking-wide:.5px; --tracking-wider:1px; --tracking-widest:2.5px;
/* Spacing (4px base) */
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px; --space-6:24px; --space-8:32px;
/* Radius */
--radius-xs:5px; --radius-sm:7px; --radius-md:9px; --radius-lg:11px; --radius-xl:14px; --radius-2xl:18px; --radius-pill:999px;
--border-signal:4px;
}
/* LIGHT THEME (Sourcing Compass, Market Signals) */
[data-theme="light"]{
--color-bg:var(--mv-g50); --color-bg-raised:var(--mv-white);
--color-surface:var(--mv-white); --color-surface-2:var(--mv-g25); --color-surface-3:var(--mv-g100);
--color-border:var(--mv-g200); --color-border-strong:var(--mv-g300);
--color-text:var(--mv-g700); --color-text-muted:var(--mv-g500); --color-text-subtle:var(--mv-g400);
--color-accent:var(--mv-blue); --color-accent-hover:#005fb0; --color-accent-2:var(--mv-teal); --color-accent-contrast:var(--mv-white);
--status-critical:var(--mv-red); --status-critical-bg:#fef2f2;
--status-warning:var(--mv-amber); --status-warning-bg:#fffbeb;
--status-positive:var(--mv-green); --status-positive-bg:#ecfdf5;
--status-info:var(--mv-sky); --status-info-bg:#e0f2fe;
--status-neutral:var(--mv-g500); --status-neutral-bg:var(--mv-g100);
--signal-sev-critical:var(--status-critical); --signal-sev-watch:var(--status-warning);
--signal-sev-opportunity:var(--status-positive); --signal-sev-info:var(--status-info); --signal-sev-neutral:var(--status-neutral);
--cat-companies:var(--mv-blue); --cat-companies-bg:#e0f2fe; --cat-companies-ink:#0369a1;
--cat-adjacent:var(--mv-purple); --cat-adjacent-bg:#f5f3ff; --cat-adjacent-ink:#6d28d9;
--cat-wildcards:var(--mv-amber); --cat-wildcards-bg:#fffbeb; --cat-wildcards-ink:#b45309;
--cat-titles:var(--mv-teal); --cat-titles-bg:#ecfdf5; --cat-titles-ink:#0f766e;
--shadow-1:0 1px 3px rgba(0,0,0,.06);
--shadow-2:0 8px 25px -5px rgba(0,0,0,.08),0 4px 10px -6px rgba(0,0,0,.04);
--shadow-3:0 30px 80px rgba(0,0,0,.15);
}
/* DARK THEME (Talent Signal OS) */
[data-theme="dark"]{
--color-bg:var(--mv-d-bg); --color-bg-raised:var(--mv-d-bg2);
--color-surface:var(--mv-d-panel); --color-surface-2:var(--mv-d-panel2); --color-surface-3:var(--mv-d-panel3);
--color-border:var(--mv-d-line); --color-border-strong:var(--mv-d-line2);
--color-text:var(--mv-d-txt); --color-text-muted:var(--mv-d-txt2); --color-text-subtle:var(--mv-d-txt3);
--color-accent:var(--mv-cyan-bright); --color-accent-hover:var(--mv-teal-bright); --color-accent-2:var(--mv-teal-bright); --color-accent-contrast:#04121a;
--status-critical:var(--mv-red-lift); --status-critical-bg:rgba(248,113,113,.12);
--status-warning:var(--mv-amber-lift); --status-warning-bg:rgba(251,191,36,.12);
--status-positive:var(--mv-green-lift); --status-positive-bg:rgba(52,211,153,.12);
--status-info:var(--mv-blue-bright); --status-info-bg:rgba(68,140,244,.14);
--status-neutral:var(--mv-d-txt3); --status-neutral-bg:rgba(141,155,184,.10);
--signal-sev-critical:var(--status-critical); --signal-sev-watch:var(--status-warning);
--signal-sev-opportunity:var(--status-positive); --signal-sev-info:var(--status-info); --signal-sev-neutral:var(--status-neutral);
--cat-companies:var(--mv-blue-bright); --cat-companies-bg:rgba(68,140,244,.14); --cat-companies-ink:var(--mv-blue-bright);
--cat-adjacent:var(--mv-violet-lift); --cat-adjacent-bg:rgba(167,139,250,.14); --cat-adjacent-ink:var(--mv-violet-lift);
--cat-wildcards:var(--mv-amber-lift); --cat-wildcards-bg:rgba(251,191,36,.12); --cat-wildcards-ink:var(--mv-amber-lift);
--cat-titles:var(--mv-teal-bright); --cat-titles-bg:rgba(0,212,178,.12); --cat-titles-ink:var(--mv-teal-bright);
--shadow-1:0 1px 3px rgba(0,0,0,.4);
--shadow-2:0 10px 26px rgba(0,0,0,.5);
--shadow-3:0 30px 80px rgba(0,0,0,.6);
--glow-accent:0 0 24px rgba(0,200,242,.35);
--glow-positive:0 0 10px rgba(52,211,153,.6);
--glow-critical:0 0 10px rgba(248,113,113,.5);
}
/* Shared interaction primitive — one consistent, theme-aware keyboard focus ring across the suite. */
:focus-visible{ outline:2px solid var(--color-accent); outline-offset:2px; }
/* ============================================================
END SHARED TOKENS (Sourcing Compass keeps its own :root below
so its font/behavior are unchanged in Phase 0)
============================================================ */
:root { --font-sans: 'Inter', system-ui, -apple-system, sans-serif; }
body { font-family: var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; margin: 0; }
.sidebar-scroll::-webkit-scrollbar { width: 4px; }
.sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.sidebar-scroll::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.map-scroll::-webkit-scrollbar { width: 6px; }
.map-scroll::-webkit-scrollbar-track { background: #f8fafc; }
.map-scroll::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 6px; }
@keyframes score-fill { from { width: 0%; } }
.score-bar-fill { animation: score-fill 0.8s ease-out; }
.card-lift { transition: transform 0.2s ease, box-shadow 0.2s ease; }
.card-lift:hover { transform: translateY(-2px); box-shadow: 0 8px 25px -5px rgba(0,0,0,0.08), 0 4px 10px -6px rgba(0,0,0,0.04); }
@media print {
.print\:hidden { display: none !important; }
body { background: white; }
.map-scroll { overflow: visible; }
.report-view { max-width: 100%; padding: 0; }
}
input[type="range"] { -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px; background: #e5e7eb; outline: none; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #f59e0b; cursor: pointer; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.border-3 { border-width: 3px; }
/* Focus ring aligned to the shared suite token (was hardcoded #2563eb). */
button:focus-visible, [role="button"]:focus-visible, select:focus-visible, input:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style>
</head>
<body data-theme="light">
<div id="root"></div>
<script type="text/babel" data-presets="react">
/* Audit Pass: 2026-05-31 — 1 critical, 5 major, 4 minor (keyboard access, contrast, focus rings, responsive header, ARIA)
Audit Pass 2: 2026-05-31 — provider-toggle aria-pressed, keyboard-operable history/collection rows */
const { useState, useRef, useEffect } = React;
// ════════════════════════════════════════════════════════════════
// CONSTANTS (ported from src/lib/constants.js)
// ════════════════════════════════════════════════════════════════
/* Priority 2 — unified AI backend. Anthropic is the default provider across the
Talent Intelligence OS; Groq remains an optional fallback here. Model strings and
endpoints are single-sourced in this AI config. */
const AI = {
defaultProvider: "anthropic",
anthropic: { url: "https://api.anthropic.com/v1/messages", model: "claude-sonnet-4-20250514", version: "2023-06-01", maxTokens: 8000 },
groq: { url: "https://api.groq.com/openai/v1/chat/completions", model: "llama-3.3-70b-versatile", maxTokens: 8000 },
};
/* Phase 3: category colors now flow from shared --cat-* tokens (decision 3).
color/barColor are inline-style values (var()); bg/border/text/badge/dot are
token-backed Tailwind utilities registered in tailwind.config above. */
const CATEGORY = {
companies: { label: "Target Companies", short: "Companies", color: "var(--cat-companies)", bg: "bg-cat-companies-soft", border: "border-cat-companies-soft", text: "text-cat-companies-ink", badge: "bg-cat-companies-soft text-cat-companies-ink", dot: "bg-cat-companies", barColor: "var(--cat-companies)" },
adjacent: { label: "Adjacent Talent Pools", short: "Adjacent", color: "var(--cat-adjacent)", bg: "bg-cat-adjacent-soft", border: "border-cat-adjacent-soft", text: "text-cat-adjacent-ink", badge: "bg-cat-adjacent-soft text-cat-adjacent-ink", dot: "bg-cat-adjacent", barColor: "var(--cat-adjacent)" },
wildcards: { label: "Wildcard Bets", short: "Wildcards", color: "var(--cat-wildcards)", bg: "bg-cat-wildcards-soft", border: "border-cat-wildcards-soft", text: "text-cat-wildcards-ink", badge: "bg-cat-wildcards-soft text-cat-wildcards-ink", dot: "bg-cat-wildcards", barColor: "var(--cat-wildcards)" },
titles: { label: "Target Titles", short: "Titles", color: "var(--cat-titles)", bg: "bg-cat-titles-soft", border: "border-cat-titles-soft", text: "text-cat-titles-ink", badge: "bg-cat-titles-soft text-cat-titles-ink", dot: "bg-cat-titles", barColor: "var(--cat-titles)" },
};
const STAGE_STYLES = {
"Public": "bg-sky-100 text-sky-700 border-sky-200",
"Enterprise": "bg-sky-100 text-sky-700 border-sky-200",
"Late Stage": "bg-violet-100 text-violet-700 border-violet-200",
"Series C+": "bg-violet-100 text-violet-700 border-violet-200",
"Series B": "bg-amber-100 text-amber-700 border-amber-200",
"Series A": "bg-orange-100 text-orange-700 border-orange-200",
"Seed": "bg-rose-100 text-rose-700 border-rose-200",
};
const SENIORITY_OPTIONS = ["Junior", "Mid", "Senior", "Staff", "Principal", "Director", "VP"];
const MOMENTUM = {
"Expanding": { label: "Expanding", chip: "bg-emerald-100 text-emerald-700 border-emerald-200", dot: "bg-emerald-500" },
"Stable": { label: "Stable", chip: "bg-gray-100 text-gray-600 border-gray-200", dot: "bg-gray-400" },
"Contracting": { label: "Contracting", chip: "bg-amber-100 text-amber-700 border-amber-200", dot: "bg-amber-500" },
"Unknown": { label: "Signal unclear", chip: "bg-gray-50 text-gray-500 border-gray-200", dot: "bg-gray-300" },
};
// Manual signal layer — the sourcer's own CONFIRMED intel (not AI-estimated)
const SIGNAL_TYPES = {
"Layoffs / RIF": { dot: "bg-red-500", chip: "bg-red-100 text-red-700 border-red-200", icon: "text-red-600" },
"Hiring freeze": { dot: "bg-orange-500", chip: "bg-orange-100 text-orange-700 border-orange-200", icon: "text-orange-600" },
"Attrition window": { dot: "bg-amber-500", chip: "bg-amber-100 text-amber-700 border-amber-200", icon: "text-amber-600" },
"Acquisition / M&A": { dot: "bg-violet-500", chip: "bg-violet-100 text-violet-700 border-violet-200", icon: "text-violet-600" },
"Restructuring": { dot: "bg-rose-500", chip: "bg-rose-100 text-rose-700 border-rose-200", icon: "text-rose-600" },
"Hiring surge": { dot: "bg-emerald-500", chip: "bg-emerald-100 text-emerald-700 border-emerald-200", icon: "text-emerald-600" },
"Watch": { dot: "bg-blue-500", chip: "bg-blue-100 text-blue-700 border-blue-200", icon: "text-blue-600" },
};
const LOCATION_OPTIONS = [
"North America", "Europe", "Asia Pacific", "LATAM", "MENA", "Global / Remote",
"United States", "Canada", "United Kingdom", "Germany", "India", "Singapore", "Japan", "Australia", "Israel", "Netherlands",
"San Francisco Bay Area", "New York Metro", "Seattle / Pacific NW", "Austin / Texas", "Boston / New England", "Los Angeles", "San Diego",
"Bangalore", "London", "Berlin", "Toronto", "Tel Aviv",
];
const PRESETS = [
{ label: "Principal ASIC", form: { role: "Principal ASIC Design Engineer", seniority: "Principal", skills: ["ASIC", "RTL Design", "Verilog", "SoC", "Timing Closure"], industries: ["Semiconductor", "AI Accelerators"], company: "Marvell Technology", location: "San Diego", exclusions: [] } },
{ label: "Sr SerDes", form: { role: "Senior SerDes Design Engineer", seniority: "Senior", skills: ["SerDes", "PLL", "High-Speed IO", "Analog Mixed Signal"], industries: ["Semiconductor", "Data Center"], company: "", location: "San Francisco Bay Area", exclusions: [] } },
{ label: "Staff Backend Eng", form: { role: "Staff Backend Engineer", seniority: "Staff", skills: ["Go", "Kubernetes", "PostgreSQL", "Distributed Systems"], industries: ["Cloud Infrastructure", "Developer Tools"], company: "", location: "", exclusions: [] } },
{ label: "Staff ML Eng", form: { role: "Staff Machine Learning Engineer", seniority: "Staff", skills: ["PyTorch", "LLMs", "CUDA", "Distributed Training"], industries: ["AI Infrastructure", "Foundation Models"], company: "", location: "San Francisco Bay Area", exclusions: [] } },
];
const EMPTY_MAP = { companies: [], adjacent: [], wildcards: [], titles: [] };
const INITIAL_FORM = { role: "", company: "", location: "", seniority: "", skills: [], industries: [], exclusions: [] };
const KEY_STORAGE = "sourcing-compass-groq-key";
const ANTHROPIC_KEY_STORAGE = "sourcing-compass-anthropic-key";
const PROVIDER_STORAGE = "sourcing-compass-provider";
const HISTORY_KEY = "sourcing-compass-history";
const COLLECTIONS_KEY = "sourcing-compass-collections";
const MAX_HISTORY = 10;
// ════════════════════════════════════════════════════════════════
// PROMPT (ported from src/lib/prompt.js)
// ════════════════════════════════════════════════════════════════
function buildPrompt(form) {
return `You are a talent intelligence strategist helping a recruiter build a defensible sourcing map. Return structured JSON only — no markdown, no explanation outside the JSON, no backticks.
Role: ${form.role}
Hiring Company: ${form.company || "Not provided"}
Location: ${form.location || "Not provided"}
Seniority: ${form.seniority || "Not provided"}
Skills: ${form.skills.join(", ") || "Not provided"}
Preferred Industries: ${form.industries.join(", ") || "Any"}
Exclusions (do NOT include these): ${form.exclusions.join(", ") || "None"}
Return this exact JSON structure:
{
"companies": [{
"id": "c1",
"label": "Company Name",
"sub": "Industry · Size or market context",
"tags": ["skill signal", "industry signal"],
"connections": ["a1", "w1", "t1"],
"confidence": 85,
"stage": "Public",
"talentDensity": 78,
"poachability": 65,
"momentum": "Expanding | Stable | Contracting | Unknown — your directional ESTIMATE of this company's engineering hiring trajectory based on general market knowledge as of training. Use Unknown if you are not reasonably sure.",
"signalNote": "A short research prompt phrased as something to VERIFY, e.g. 'Likely scaling AI-silicon teams — confirm against current job postings.' NEVER state specific recent layoff numbers, percentages, or dates as fact.",
"likelyProfile": "One sentence describing the typical relevant candidate background.",
"poachabilitySignals": ["[Signal] Reason this talent may be reachable", "[Confirmed] Stable company or market fact if broadly known"],
"confidenceRationale": "One sentence explaining what drives the relevance score up or down — name the single strongest supporting factor and the single biggest uncertainty.",
"whyRelevant": "One sentence explaining why this company belongs in the map.",
"verifyNext": "One practical validation step a sourcer should take before using this recommendation."
}],
"adjacent": [{
"id": "a1",
"label": "Company Name",
"sub": "Why their talent is transferable",
"tags": ["transferable skill"],
"connections": ["c1", "t1"],
"whyRelevant": "One sentence explaining the adjacency."
}],
"wildcards": [{
"id": "w1",
"label": "Real Company Name",
"sub": "Specific reason why their talent is a surprising but valid match",
"tags": ["overlap signal"],
"connections": ["c1", "a1", "t1"],
"whyRelevant": "One sentence explaining the non-obvious overlap."
}],
"titles": [{
"id": "t1",
"label": "Job Title",
"sub": "Where this title or variant commonly appears",
"tags": ["variant", "search term"],
"connections": ["c1", "a1"],
"confidence": 90,
"confidenceRationale": "One sentence on why this title is a strong or weak match — e.g. exact-match vs. a broad variant that will also pull irrelevant profiles.",
"whyRelevant": "One sentence explaining when to use this title in sourcing."
}]
}
Rules:
- 6-8 target companies total, mixing established companies and 3-4 notable startups when relevant
- CRITICAL: Only include real companies that actually exist. Do NOT invent or combine company names.
- NEVER include the hiring company in target companies if one was provided
- adjacent = 4-5 specific companies, labs, consultancies, or talent-producing organizations with transferable skills
- wildcards = 3-4 unconventional but defensible real companies
- titles = 5-7 target job titles, including exact titles and useful variants
- confidence = relevance to the search, 0-100
- talentDensity = estimated concentration of relevant talent, 0-100
- poachability = estimated likelihood talent may be reachable, 0-100
- poachabilitySignals = exactly 2-3 strings, prefixed [Signal] or [Confirmed]
- confidenceRationale = 1 sentence. State the main factor pushing the score up AND the main thing holding it back or still to verify. Be honest about uncertainty — a sourcer should be able to defend or challenge the number from this line alone.
- likelyProfile = 1 sentence max
- stage = one of: Public / Late Stage / Series C+ / Series B / Series A / Seed / Enterprise
- momentum = one of Expanding / Stable / Contracting / Unknown. This is a directional ESTIMATE from training-data knowledge, NOT live data. Prefer Unknown over guessing.
- signalNote = 1 sentence, ALWAYS framed as a hypothesis to verify. Do NOT assert recent layoff figures, dates, or specific events as confirmed fact — the recruiter verifies against live sources.
- Prefer specificity over generic recommendations
- Make connections meaningful. Do not connect every node to every other node.
- Use cautious language for assumptions. Do not imply live market verification.
- Return ONLY raw valid JSON. No markdown. No backticks.`;
}
// ════════════════════════════════════════════════════════════════
// GROQ CALL (ported from src/lib/groq.js)
// ════════════════════════════════════════════════════════════════
// Shared JSON extraction/repair — provider-agnostic so Anthropic and Groq parse identically.
function parseMapJSON(raw, truncated) {
let clean = String(raw || "{}").replace(/```json|```/g, "").trim();
const start = clean.indexOf("{");
const end = clean.lastIndexOf("}");
if (start !== -1) clean = clean.slice(start, end + 1);
try {
return JSON.parse(clean);
} catch (e) {
// Light repair pass — most common offender is a trailing comma before } or ]
try {
return JSON.parse(clean.replace(/,\s*([}\]])/g, "$1"));
} catch (e2) {
if (truncated) {
throw new Error("The response was cut off before it finished (token limit). Regenerate, or trim the role/skills slightly to shorten the map.");
}
throw new Error("Couldn't parse the model's response. This is usually transient — try regenerating.");
}
}
}
// Anthropic (default provider). Direct browser call mirrors the rest of the OS.
async function callAnthropic(apiKey, prompt) {
const res = await fetch(AI.anthropic.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": AI.anthropic.version,
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model: AI.anthropic.model,
max_tokens: AI.anthropic.maxTokens,
messages: [{ role: "user", content: prompt + "\n\nRespond with ONLY the JSON object — no prose, no markdown fences." }],
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error?.message || `API error ${res.status}`);
const raw = (data.content || []).filter((b) => b.type === "text").map((b) => b.text).join("");
return parseMapJSON(raw, data.stop_reason === "max_tokens");
}
// Groq (optional fallback). OpenAI-compatible chat completions.
async function callGroq(apiKey, prompt) {
const res = await fetch(AI.groq.url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: AI.groq.model,
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
max_tokens: AI.groq.maxTokens,
response_format: { type: "json_object" },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error?.message || `API error ${res.status}`);
const choice = data.choices?.[0];
return parseMapJSON(choice?.message?.content || "{}", choice?.finish_reason === "length");
}
// Provider dispatcher — routes to the selected backend.
async function callAI(provider, key, prompt) {
return provider === "groq" ? callGroq(key, prompt) : callAnthropic(key, prompt);
}
// ════════════════════════════════════════════════════════════════
// STORAGE (ported from src/lib/storage.js)
// ════════════════════════════════════════════════════════════════
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch { return []; }
}
function saveToHistory(form, mapData) {
const history = loadHistory();
const entry = { id: Date.now(), form, mapData, timestamp: new Date().toISOString(), label: `${form.role}${form.company ? ` @ ${form.company}` : ""}` };
const updated = [entry, ...history.filter((h) => h.label !== entry.label)].slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
return updated;
}
function removeFromHistory(id) {
const history = loadHistory().filter((h) => h.id !== id);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
return history;
}
function clearHistory() { localStorage.removeItem(HISTORY_KEY); return []; }
// ════════════════════════════════════════════════════════════════
// COLLECTIONS (grouped, deliberate saves — survive history eviction)
// A collection = { id, name, note, createdAt, maps: [{ id, form, mapData, savedAt, label }] }
// Map snapshots are self-contained (form + mapData), so comparison mode
// can later pick ANY two maps from ANY collection with no rework.
// ════════════════════════════════════════════════════════════════
function mapLabel(form) { return `${form.role || "Untitled role"}${form.company ? ` @ ${form.company}` : ""}`; }
function loadCollections() { try { return JSON.parse(localStorage.getItem(COLLECTIONS_KEY)) || []; } catch { return []; } }
function persistCollections(list) { try { localStorage.setItem(COLLECTIONS_KEY, JSON.stringify(list)); } catch {} return list; }
function createCollection(name, note) {
const col = { id: `col_${Date.now()}`, name: (name || "").trim() || "Untitled collection", note: (note || "").trim(), createdAt: new Date().toISOString(), maps: [] };
return { list: persistCollections([col, ...loadCollections()]), collection: col };
}
function deleteCollection(id) { return persistCollections(loadCollections().filter((c) => c.id !== id)); }
function updateCollectionMeta(id, name, note) {
return persistCollections(loadCollections().map((c) => c.id === id ? { ...c, name: (name ?? c.name).trim() || c.name, note: note != null ? note.trim() : c.note } : c));
}
function addMapToCollection(id, form, mapData) {
const label = mapLabel(form);
const entry = { id: `m_${Date.now()}`, form, mapData, savedAt: new Date().toISOString(), label };
return persistCollections(loadCollections().map((c) => {
if (c.id !== id) return c;
const without = c.maps.filter((m) => m.label !== label); // replace-by-label keeps a collection = current best map per role
return { ...c, maps: [entry, ...without] };
}));
}
function removeMapFromCollection(colId, mapId) {
return persistCollections(loadCollections().map((c) => c.id === colId ? { ...c, maps: c.maps.filter((m) => m.id !== mapId) } : c));
}
// ════════════════════════════════════════════════════════════════
// MANUAL SIGNALS (sourcer-owned intel — keyed by company, not by map)
// Tag a company once; the signal follows it across every map you build.
// normLabel() is defined later but hoisted, so it's safe to call here.
// ════════════════════════════════════════════════════════════════
const SIGNALS_KEY = "sourcing-compass-signals";
function loadSignals() { try { return JSON.parse(localStorage.getItem(SIGNALS_KEY)) || {}; } catch { return {}; } }
function persistSignals(obj) { try { localStorage.setItem(SIGNALS_KEY, JSON.stringify(obj)); } catch {} return obj; }
function getSignal(signals, label) { return signals?.[normLabel(label)] || null; }
function setSignal(label, rec) { const all = loadSignals(); all[normLabel(label)] = { ...rec, label, updatedAt: new Date().toISOString() }; return persistSignals(all); }
function clearSignal(label) { const all = loadSignals(); delete all[normLabel(label)]; return persistSignals(all); }
/* Signal interop — import canonical signals (e.g. from Market Signals) into the
confirmed-intel layer. Mirrors signal-schema.js canonicalToScSignal; inlined to
keep this a standalone file. */
const CANON_TO_SC_TYPE = { LAYOFF: "Layoffs / RIF", HIRING_FREEZE: "Hiring freeze", ATTRITION_WINDOW: "Attrition window", ACQUISITION: "Acquisition / M&A", RESTRUCTURING: "Restructuring", HIRING_SURGE: "Hiring surge" };
function canonicalToScSignal(sig) {
return {
type: CANON_TO_SC_TYPE[sig.type] || "Watch",
note: (sig.summary || sig.detail || "") + (sig.source && sig.source.url ? " [" + sig.source.url + "]" : "") + (sig.origin === "ai" ? " (imported from Market Signals)" : ""),
};
}
function parseCanonicalSignals(text) {
let data;
try { data = JSON.parse(text); } catch (e) { throw new Error("That isn't valid JSON. Use the 'Export signals' button in Market Signals and paste the result here."); }
const arr = Array.isArray(data) ? data : (data && Array.isArray(data.signals) ? data.signals : null);
if (!arr) throw new Error("Expected a JSON array of signals (or an object with a 'signals' array).");
return arr.filter((s) => s && s.entity && s.type);
}
function signalCrossRefs(normKey, currentForm, currentMap, collections) {
const refs = [];
const inMap = (md) => ["companies", "adjacent", "wildcards", "titles"].some((c) => (md?.[c] || []).some((n) => normLabel(n.label) === normKey));
if (currentMap && inMap(currentMap)) refs.push(`${mapLabel(currentForm)} · current`);
for (const col of (collections || [])) for (const m of col.maps) if (inMap(m.mapData)) refs.push(`${m.label} · ${col.name}`);
return refs;
}
function toSignalsMarkdown(signals, currentForm, currentMap, collections) {
const entries = Object.entries(signals || {}).sort((a, b) => (b[1].updatedAt || "").localeCompare(a[1].updatedAt || ""));
const lines = [];
lines.push(`# Sourcing Compass — Signal Watchlist`); lines.push("");
lines.push(`**Generated:** ${prettyDate()} · ${entries.length} compan${entries.length === 1 ? "y" : "ies"} flagged`); lines.push("");
if (!entries.length) { lines.push("_No signals yet._"); return lines.join("\n"); }
for (const [key, rec] of entries) {
lines.push(`### ${rec.label || key} — ${rec.type}${rec.date ? ` (${rec.date})` : ""}`);
if (rec.note) lines.push(rec.note);
const refs = signalCrossRefs(key, currentForm, currentMap, collections);
if (refs.length) lines.push(`_Appears in: ${refs.join("; ")}_`);
lines.push("");
}
lines.push("---");
lines.push("*Your confirmed intel · Sourcing Compass · Talent Intelligence OS.*");
return lines.join("\n");
}
// ════════════════════════════════════════════════════════════════
// SHARE (ported from src/lib/share.js)
// ════════════════════════════════════════════════════════════════
function encodeFormToHash(form) {
const p = new URLSearchParams();
if (form.role) p.set("role", form.role);
if (form.company) p.set("company", form.company);
if (form.location) p.set("location", form.location);
if (form.seniority) p.set("seniority", form.seniority);
if (form.skills.length) p.set("skills", form.skills.join(","));
if (form.industries.length) p.set("industries", form.industries.join(","));
if (form.exclusions.length) p.set("exclusions", form.exclusions.join(","));
return `#${p.toString()}`;
}
function decodeHashToForm() {
const hash = window.location.hash.slice(1);
if (!hash) return null;
const p = new URLSearchParams(hash);
const role = p.get("role");
if (!role) return null;
return {
role,
company: p.get("company") || "",
location: p.get("location") || "",
seniority: p.get("seniority") || "",
skills: p.get("skills")?.split(",").filter(Boolean) || [],
industries: p.get("industries")?.split(",").filter(Boolean) || [],
exclusions: p.get("exclusions")?.split(",").filter(Boolean) || [],
};
}
// ════════════════════════════════════════════════════════════════
// EXPORTS (ported from src/lib/exports.js — Tier 1 pack)
// ════════════════════════════════════════════════════════════════
const SECTION_LABELS = { companies: "Target Companies", adjacent: "Adjacent Talent Pools", wildcards: "Wildcard Bets", titles: "Target Titles" };
function todayStamp() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }
function prettyDate() { return new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); }
function slug(s) { return (s||"sourcing-map").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,60); }
function stripPrefix(s) { return String(s||"").replace(/^\[(Signal|Confirmed)\]\s*/i,""); }
function csvCell(v) { const s = v==null?"":String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s; }
function quoteIfMultiword(t) { const s=String(t||"").trim(); if(!s) return ""; return /\s/.test(s)?`"${s}"`:s; }
function toCSV(form, mapData, signals = {}) {
const headers = ["Category","Name","Stage","Context","Confidence","ConfidenceRationale","TalentDensity","Poachability","Momentum","SignalNote","ManualSignal","ManualSignalDetail","LikelyProfile","WhyRelevant","VerifyNext","PoachabilitySignals","Tags"];
const rows = [headers.map(csvCell).join(",")];
for (const key of ["companies","adjacent","wildcards","titles"]) {
for (const n of (mapData[key]||[])) {
const sig = getSignal(signals, n.label);
rows.push([SECTION_LABELS[key], n.label, n.stage||"", n.sub||"", n.confidence??"", n.confidenceRationale||"", n.talentDensity??"", n.poachability??"", n.momentum||"", n.signalNote||"", sig?.type||"", sig ? [sig.date, sig.note].filter(Boolean).join(" — ") : "", n.likelyProfile||"", n.whyRelevant||"", n.verifyNext||"", (n.poachabilitySignals||[]).map(stripPrefix).join(" | "), (n.tags||[]).join(" | ")].map(csvCell).join(","));
}
}
return rows.join("\n");
}
function toBoolean(form, mapData) {
const titleTerms = (mapData.titles||[]).map(t=>t.label).filter(Boolean).map(quoteIfMultiword);
const skillTerms = (form.skills||[]).map(quoteIfMultiword).filter(Boolean);
const companyTerms = [...(mapData.companies||[]),...(mapData.adjacent||[]),...(mapData.wildcards||[])].map(c=>c.label).filter(Boolean).map(quoteIfMultiword);
const lines = [];
lines.push(`// Boolean pack — ${form.role}${form.location?` · ${form.location}`:""}`);
lines.push(`// Generated ${prettyDate()} by Sourcing Compass`);
lines.push("");
if (titleTerms.length) { lines.push("// TITLES (paste into a title field, or combine below)"); lines.push(`(${titleTerms.join(" OR ")})`); lines.push(""); }
if (skillTerms.length) { lines.push("// SKILLS"); lines.push(`(${skillTerms.join(" OR ")})`); lines.push(""); }
if (titleTerms.length || skillTerms.length) {
const parts = [];
if (titleTerms.length) parts.push(`(${titleTerms.join(" OR ")})`);
if (skillTerms.length) parts.push(`(${skillTerms.join(" OR ")})`);
lines.push("// COMBINED (titles AND skills)"); lines.push(parts.join(" AND ")); lines.push("");
}
if (companyTerms.length) { lines.push("// CURRENT-COMPANY TARGETING (use in a company filter, OR-joined)"); lines.push(`(${companyTerms.join(" OR ")})`); lines.push(""); }
lines.push("// Note: keyword field caps vary by platform (LI Talent Insights ~40 words).");
lines.push("// Trim the OR groups to fit. Verify titles against live results before relying on counts.");
return lines.join("\n");
}
function toTitlePack(form, mapData) {
const titles = [...(mapData.titles||[])].sort((a,b)=>(b.confidence??0)-(a.confidence??0));
const lines = [];
lines.push(`Title Variant Pack — ${form.role}`);
lines.push(`Generated ${prettyDate()} · Sourcing Compass`);
lines.push("");
if (!titles.length) { lines.push("(No titles in this map.)"); return lines.join("\n"); }
lines.push("RANKED BY MATCH CONFIDENCE"); lines.push("");
for (const t of titles) {
lines.push(`- ${t.label}${t.confidence!=null?` (${t.confidence}%)`:""}`);
if (t.sub) lines.push(` where: ${t.sub}`);
if (t.whyRelevant) lines.push(` use when: ${t.whyRelevant}`);
}
lines.push(""); lines.push("PLAIN LIST (copy into a search-term field)"); lines.push(titles.map(t=>t.label).join(", "));
lines.push(""); lines.push("OR-JOINED"); lines.push(`(${titles.map(t=>quoteIfMultiword(t.label)).join(" OR ")})`);
return lines.join("\n");
}
function toMarkdown(form, mapData, signals = {}) {
const lines = [];
lines.push(`# Sourcing Compass — Talent Landscape Brief`); lines.push("");
lines.push(`**Role:** ${form.role}${form.seniority?` (${form.seniority})`:""}`);
if (form.company) lines.push(`**Hiring Company:** ${form.company}`);
if (form.location) lines.push(`**Location:** ${form.location}`);
if (form.skills?.length) lines.push(`**Skills:** ${form.skills.join(", ")}`);
if (form.industries?.length) lines.push(`**Industries:** ${form.industries.join(", ")}`);
lines.push(`**Generated:** ${prettyDate()}`); lines.push("");
const counts = ["companies","adjacent","wildcards","titles"].map(k=>`${(mapData[k]||[]).length} ${SECTION_LABELS[k].toLowerCase()}`).join(" · ");
lines.push(`> ${counts}`); lines.push("");
for (const key of ["companies","adjacent","wildcards","titles"]) {
const nodes = mapData[key];
if (!nodes?.length) continue;
lines.push(`## ${SECTION_LABELS[key]}`); lines.push("");
for (const n of nodes) {
lines.push(`### ${n.label}${n.stage?` _(${n.stage})_`:""}`);
if (n.sub) lines.push(`${n.sub}`);
lines.push("");
const scores = [];
if (n.confidence!=null) scores.push(`Confidence ${n.confidence}%`);
if (n.talentDensity!=null) scores.push(`Talent Density ${n.talentDensity}%`);
if (n.poachability!=null) scores.push(`Poachability ${n.poachability}%`);
if (scores.length) lines.push(`**Scores:** ${scores.join(" · ")}`);
if (n.confidenceRationale) lines.push(`**Why this score:** ${n.confidenceRationale}`);
if (n.momentum) lines.push(`**Momentum (estimated, verify):** ${n.momentum}`);
if (n.signalNote) lines.push(`**Signal to verify:** ${n.signalNote}`);
if (n.likelyProfile) lines.push(`**Likely profile:** ${n.likelyProfile}`);
if (n.whyRelevant) lines.push(`**Why relevant:** ${n.whyRelevant}`);
if (n.poachabilitySignals?.length) { lines.push(`**Signals:**`); for (const s of n.poachabilitySignals) lines.push(`- ${s}`); }
const msig = getSignal(signals, n.label);
if (msig) lines.push(`**Your signal (confirmed):** ${msig.type}${msig.date ? ` (${msig.date})` : ""}${msig.note ? ` — ${msig.note}` : ""}`);
if (n.verifyNext) lines.push(`**Verify next:** ${n.verifyNext}`);
lines.push("");
}
}
lines.push("---");
lines.push(`*Generated by Sourcing Compass · talent mapping · Talent Intelligence OS — ${prettyDate()}*`);
lines.push("*AI-generated intelligence. Verify company details and hiring signals before outreach or reporting.*");
return lines.join("\n");
}
function downloadText(filename, text, mime) {
const blob = new Blob([text], { type: `${mime||"text/plain"};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch { return false; } }
function exportFilename(form, ext) { return `sourcing-compass_${slug(form.role)}_${todayStamp()}.${ext}`; }
// ════════════════════════════════════════════════════════════════
// COMPONENTS
// ════════════════════════════════════════════════════════════════
function ScoreBar({ label, value, color }) {
return (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] text-gray-500 font-medium">{label}</span>
<span className="text-[11px] font-semibold" style={{ color }}>{value}%</span>
</div>
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full score-bar-fill" style={{ width: `${value}%`, background: color }} />
</div>
</div>
);
}
function CompanyScores({ node }) {
return (
<div className="mt-3 space-y-1">
{node.talentDensity != null && <ScoreBar label="Talent Density" value={node.talentDensity} color="#0ea5e9" />}
{node.confidence != null && <ScoreBar label="Relevance" value={node.confidence} color="#10b981" />}
{node.poachability != null && <ScoreBar label="Poachability" value={node.poachability} color="#f59e0b" />}
{node.confidenceRationale && (
<div className="mt-3 pt-3 border-t border-emerald-100">
<div className="text-[10px] text-emerald-600 font-semibold tracking-wider uppercase mb-1">Why This Score</div>
<div className="text-xs text-gray-600 leading-relaxed">{node.confidenceRationale}</div>
</div>
)}
{node.likelyProfile && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] text-gray-500 font-semibold tracking-wider uppercase mb-1">Likely Talent Profile</div>
<div className="text-xs text-gray-600 leading-relaxed">{node.likelyProfile}</div>
</div>
)}
{node.poachabilitySignals?.length > 0 && (
<div className="mt-3 pt-3 border-t border-amber-100">
<div className="text-[10px] text-amber-600 font-semibold tracking-wider uppercase mb-1">Poachability Signals</div>
{node.poachabilitySignals.map((s, i) => (
<div key={i} className="flex gap-1.5 mt-1">
<span className="text-amber-500 text-xs mt-0.5 flex-shrink-0">•</span>
<span className="text-xs text-gray-600 leading-relaxed">{s}</span>
</div>
))}
</div>
)}
{node.signalNote && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] text-gray-500 font-semibold tracking-wider uppercase mb-1">Directional Signal · estimated, verify</div>
<div className="text-xs text-gray-600 leading-relaxed">{node.signalNote}</div>
</div>
)}
{node.whyRelevant && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] text-gray-500 font-semibold tracking-wider uppercase mb-1">Why Relevant</div>
<div className="text-xs text-gray-600 leading-relaxed">{node.whyRelevant}</div>
</div>
)}
{node.verifyNext && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] text-gray-500 font-semibold tracking-wider uppercase mb-1">Verify Next</div>
<div className="text-xs text-gray-500 leading-relaxed italic">{node.verifyNext}</div>
</div>
)}
</div>
);
}
function SignalBadge({ signal, onClick }) {
const s = SIGNAL_TYPES[signal.type] || { chip: "bg-gray-100 text-gray-700 border-gray-200" };
return (
<button onClick={(e) => { e.stopPropagation(); onClick(); }} title={signal.note || "Your confirmed signal — click to edit"} className={`inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full border font-semibold ${s.chip}`}>
<svg className="w-2.5 h-2.5" viewBox="0 0 24 24" fill="currentColor"><path d="M4 2a1 1 0 00-1 1v18a1 1 0 102 0v-6h11.382a1 1 0 00.894-1.447L15 9l1.276-2.553A1 1 0 0015.382 5H5V3a1 1 0 00-1-1z" /></svg>
{signal.type}{signal.date ? ` · ${signal.date}` : ""}
</button>
);
}
function SignalEditor({ signal, onSave, onClear, onCancel }) {
const [type, setType] = useState(signal?.type || "Watch");
const [note, setNote] = useState(signal?.note || "");
const [date, setDate] = useState(signal?.date || "");
return (
<div className="mt-3 pt-3 border-t border-gray-100" onClick={(e) => e.stopPropagation()}>
<div className="text-[10px] text-gray-500 font-semibold tracking-wider uppercase mb-2">Your signal · confirmed intel</div>
<div className="space-y-2">
<select value={type} onChange={(e) => setType(e.target.value)} className="w-full bg-gray-50 border border-gray-300 rounded-md px-2 py-1.5 text-xs text-gray-800 focus:outline-none focus:border-blue-500">
{Object.keys(SIGNAL_TYPES).map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex gap-2">
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="flex-1 min-w-0 bg-gray-50 border border-gray-300 rounded-md px-2 py-1.5 text-xs text-gray-800 focus:outline-none focus:border-blue-500" />
<input value={date} onChange={(e) => setDate(e.target.value)} placeholder="2026-02" className="w-24 bg-gray-50 border border-gray-300 rounded-md px-2 py-1.5 text-xs text-gray-800 focus:outline-none focus:border-blue-500" />
</div>
<div className="flex items-center gap-2">
<button onClick={() => onSave({ type, note: note.trim(), date: date.trim() })} className="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">Save</button>
{signal && <button onClick={onClear} className="px-3 py-1 text-xs font-medium text-red-600 hover:text-red-700">Clear</button>}
<button onClick={onCancel} className="px-3 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 ml-auto">Cancel</button>
</div>
</div>
</div>
);
}
function NodeCard({ node, category, onHover, isActive, signal, onSaveSignal, onClearSignal }) {
const [expanded, setExpanded] = useState(false);
const [signalOpen, setSignalOpen] = useState(false);
const s = CATEGORY[category];
const hasDetails = category === "companies" && (node.talentDensity != null || node.likelyProfile || node.poachabilitySignals?.length);
function handleCopy(e) {
e.stopPropagation();
const text = `${node.label} — ${node.sub || ""}${node.stage ? ` · ${node.stage}` : ""}${node.confidence != null ? ` · Confidence: ${node.confidence}%` : ""}`;
navigator.clipboard.writeText(text);
}
return (
<div
id={`node-${node.id}`}
onMouseEnter={() => onHover(node)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(node)}
onBlur={() => onHover(null)}
onClick={() => hasDetails && setExpanded((v) => !v)}
onKeyDown={hasDetails ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded((v) => !v); } } : undefined}
role={hasDetails ? "button" : undefined}
tabIndex={hasDetails ? 0 : undefined}
aria-expanded={hasDetails ? expanded : undefined}
className={`relative bg-white rounded-xl border p-4 select-none card-lift group ${hasDetails ? "cursor-pointer" : "cursor-default"} ${isActive ? `border-2 shadow-lg ${s.border}` : "border-gray-200 shadow-sm hover:border-gray-300"}`}
>
<div className="absolute top-0 left-4 right-4 h-0.5 rounded-b" style={{ background: s.color }} />
<button onClick={handleCopy} title="Copy to clipboard" aria-label={`Copy ${node.label} to clipboard`} className="absolute top-2.5 right-2.5 opacity-0 group-hover:opacity-100 focus:opacity-100 group-focus-within:opacity-100 transition-opacity text-gray-500 hover:text-gray-600 p-1 rounded-md hover:bg-gray-100">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" /><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" strokeWidth="2" /></svg>
</button>
<div className="flex items-start justify-between gap-2 mb-1.5 pr-6">
<div className="text-sm font-semibold text-gray-900 leading-tight">{node.label}</div>
{node.stage && <span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium whitespace-nowrap flex-shrink-0 ${STAGE_STYLES[node.stage] || "bg-gray-100 text-gray-600 border-gray-200"}`}>{node.stage}</span>}
</div>
{category === "companies" && signal && (
<div className="mb-2"><SignalBadge signal={signal} onClick={() => setSignalOpen(true)} /></div>
)}
{node.sub && <div className="text-xs text-gray-500 mb-2">{node.sub}</div>}
{category === "companies" && node.momentum && MOMENTUM[node.momentum] && (
<div className="flex items-center gap-1.5 mb-2">
<span className={`inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full border font-medium ${MOMENTUM[node.momentum].chip}`}>
<span className={`w-1.5 h-1.5 rounded-full ${MOMENTUM[node.momentum].dot}`} />
{MOMENTUM[node.momentum].label}
</span>
<span className="text-[10px] text-gray-500 uppercase tracking-wider" title="Directional estimate from training data — verify against live sources">est.</span>
</div>
)}
{node.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{node.tags.map((t) => <span key={t} className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${s.badge}`}>{t}</span>)}
</div>
)}
{category === "companies" && node.confidence != null && !expanded && (
<div className="mt-3"><ScoreBar label="Relevance" value={node.confidence} color="#10b981" /></div>
)}
{category === "companies" && expanded && <CompanyScores node={node} />}
{category === "titles" && node.confidence != null && (
<div className="mt-3"><ScoreBar label="Match Confidence" value={node.confidence} color={node.confidence >= 80 ? "#10b981" : node.confidence >= 60 ? "#f59e0b" : "#ef4444"} /></div>
)}
{category === "titles" && node.confidenceRationale && (
<div className="mt-2 text-[11px] text-gray-500 italic leading-relaxed">{node.confidenceRationale}</div>
)}
{(category === "adjacent" || category === "wildcards") && node.whyRelevant && (
<div className="mt-2 text-[11px] text-gray-500 italic leading-relaxed">{node.whyRelevant}</div>
)}
<div className="flex items-center justify-between mt-2">
{hasDetails && <span className="text-[10px] text-gray-500">{expanded ? "Click to collapse" : "Click for details"}</span>}
{category === "companies" && !signal && !signalOpen && (
<button onClick={(e) => { e.stopPropagation(); setSignalOpen(true); }} className="text-[10px] text-gray-500 hover:text-blue-600 font-medium flex items-center gap-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 4v16m8-8H4" strokeWidth="2" strokeLinecap="round" /></svg>
Signal
</button>
)}
{node.connections?.length > 0 && <span className="text-[10px] text-gray-500 font-medium ml-auto">{node.connections.length} link{node.connections.length !== 1 ? "s" : ""}</span>}
</div>
{category === "companies" && signalOpen && (
<SignalEditor signal={signal} onSave={(rec) => { onSaveSignal(node.label, rec); setSignalOpen(false); }} onClear={() => { onClearSignal(node.label); setSignalOpen(false); }} onCancel={() => setSignalOpen(false)} />
)}
</div>
);
}
function Section({ title, category, nodes, onHover, activeNode, signals, onSaveSignal, onClearSignal }) {
const s = CATEGORY[category];
if (!nodes?.length) return null;
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className={`w-2.5 h-2.5 rounded-full ${s.dot}`} />
<h2 className={`text-sm font-semibold tracking-wide uppercase ${s.text}`}>{title}</h2>
<div className="flex-1 border-t border-gray-200" />
<span className="text-xs text-gray-500 font-medium">{nodes.length} nodes</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{nodes.map((n) => <NodeCard key={n.id} node={n} category={category} onHover={onHover} isActive={activeNode?.id === n.id} signal={getSignal(signals, n.label)} onSaveSignal={onSaveSignal} onClearSignal={onClearSignal} />)}
</div>
</div>
);
}
function ConnectionLines({ nodes, activeNode, containerRef }) {
const [lines, setLines] = useState([]);
const [svgSize, setSvgSize] = useState({ w: 0, h: 0 });
useEffect(() => {
if (!activeNode || !containerRef.current) { setLines([]); return; }
const ctr = containerRef.current;
const rect = ctr.getBoundingClientRect();
setSvgSize({ w: ctr.scrollWidth, h: ctr.scrollHeight });
const sourceEl = document.getElementById(`node-${activeNode.id}`);
if (!sourceEl) return;
const src = sourceEl.getBoundingClientRect();
const sx = src.left - rect.left + ctr.scrollLeft + src.width / 2;
const sy = src.top - rect.top + ctr.scrollTop + src.height / 2;
const newLines = nodes
.filter((n) => n.id !== activeNode.id && activeNode.connections?.includes(n.id))
.map((n) => {
const el = document.getElementById(`node-${n.id}`);
if (!el) return null;
const r = el.getBoundingClientRect();
return { x1: sx, y1: sy, x2: r.left - rect.left + ctr.scrollLeft + r.width / 2, y2: r.top - rect.top + ctr.scrollTop + r.height / 2, id: n.id };
})
.filter(Boolean);
setLines(newLines);
}, [activeNode, nodes, containerRef]);
if (!lines.length) return null;
return (
<svg className="absolute top-0 left-0 pointer-events-none" style={{ zIndex: 30, width: svgSize.w, height: svgSize.h }}>
{lines.map((l) => (
<g key={l.id}>
<line x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} stroke="#3b82f6" strokeWidth="2" strokeDasharray="6 4" opacity="0.6">
<animate attributeName="stroke-dashoffset" from="0" to="-20" dur="0.6s" repeatCount="indefinite" />
</line>
<circle cx={l.x2} cy={l.y2} r="5" fill="#3b82f6" opacity="0.7" />
</g>
))}
</svg>
);
}
function TagInput({ placeholder, tags, onChange }) {
const [input, setInput] = useState("");
function handleKey(e) {
if ((e.key === "," || e.key === "Enter") && input.trim()) {
e.preventDefault();
onChange([...tags, input.trim().replace(/,$/, "")]);
setInput("");
} else if (e.key === "Backspace" && !input && tags.length) {
onChange(tags.slice(0, -1));
}
}
return (
<div className="w-full min-h-[40px] bg-white border border-gray-300 rounded-lg px-3 py-2 flex flex-wrap gap-1.5 focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-100 transition-all cursor-text" onClick={(e) => e.currentTarget.querySelector("input").focus()}>
{tags.map((t, i) => (
<span key={i} className="flex items-center gap-1 bg-blue-50 border border-blue-200 text-blue-700 text-xs px-2 py-0.5 rounded-md font-medium">
{t}
<button onClick={() => onChange(tags.filter((_, idx) => idx !== i))} className="text-blue-400 hover:text-blue-700 leading-none ml-0.5">×</button>
</span>
))}
<input className="bg-transparent text-sm text-gray-800 placeholder-gray-400 outline-none flex-1 min-w-[100px]" placeholder={tags.length ? "" : placeholder} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKey} />
</div>
);
}
function LocationInput({ value, onChange }) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const ref = useRef(null);
useEffect(() => {
function handleClick(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const filtered = LOCATION_OPTIONS.filter((loc) => loc.toLowerCase().includes((filter || value).toLowerCase()));
return (
<div ref={ref} className="relative">
<input className="w-full bg-gray-50 border border-gray-300 rounded-lg px-3 py-2.5 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all" placeholder="e.g. North America" value={value} onChange={(e) => { onChange(e.target.value); setFilter(e.target.value); if (!open) setOpen(true); }} onFocus={() => setOpen(true)} />
<button type="button" onClick={() => setOpen((v) => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600 p-0.5">
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
{filtered.map((loc) => (
<button key={loc} onClick={() => { onChange(loc); setFilter(""); setOpen(false); }} className={`w-full text-left px-3 py-2 text-sm hover:bg-blue-50 transition-colors ${value === loc ? "text-blue-600 font-medium bg-blue-50/50" : "text-gray-700"}`}>{loc}</button>
))}
</div>
)}
</div>
);
}
function ViewToggle({ viewMode, onViewChange }) {
const VIEWS = [
{ id: "map", label: "Map", icon: <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg> },
{ id: "table", label: "Table", icon: <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /><path d="M9 3v18" /></svg> },
{ id: "report", label: "Report", icon: <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5"><path d="M9 12h6M9 16h6M17 21H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> },
{ id: "compare", label: "Compare", icon: <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5"><rect x="3" y="4" width="7" height="16" rx="1" /><rect x="14" y="4" width="7" height="16" rx="1" /></svg> },
{ id: "signals", label: "Signals", icon: <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5"><path d="M4 21V4m0 0h13l-2 4 2 4H4" strokeLinecap="round" strokeLinejoin="round" /></svg> },
];
return (
<div className="inline-flex bg-gray-100 rounded-lg p-0.5">
{VIEWS.map((v) => (
<button key={v.id} onClick={() => onViewChange(v.id)} aria-label={v.label} aria-pressed={viewMode === v.id} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${viewMode === v.id ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>
{v.icon}<span className="hidden sm:inline">{v.label}</span>
</button>
))}
</div>
);
}
function FilterBar({ sortKey, onSortChange, minPoachability, onMinPoachabilityChange, visibleCategories, onToggleCategory, totalCount, filteredCount, momentumFilter, onMomentumChange, signalFilter, onSignalChange }) {
return (
<div className="flex flex-wrap items-center gap-3 mb-6 pb-4 border-b border-gray-200">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500 font-medium">Sort:</span>
<select value={sortKey} onChange={(e) => onSortChange(e.target.value)} className="text-xs bg-white border border-gray-200 rounded-md px-2 py-1 text-gray-700 focus:outline-none focus:border-blue-400">
<option value="confidence">Confidence</option>
<option value="poachability">Poachability</option>
<option value="talentDensity">Talent Density</option>
<option value="label">Alphabetical</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-gray-500 font-medium">Min Poach:</span>
<input type="range" min="0" max="100" step="5" value={minPoachability} onChange={(e) => onMinPoachabilityChange(Number(e.target.value))} className="w-20 h-1 accent-amber-500" />
<span className="text-[11px] text-gray-600 font-medium tabular-nums w-6">{minPoachability}%</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500 font-medium" title="Filters Target Companies by estimated hiring trajectory">Momentum:</span>
<select value={momentumFilter} onChange={(e) => onMomentumChange(e.target.value)} className="text-xs bg-white border border-gray-200 rounded-md px-2 py-1 text-gray-700 focus:outline-none focus:border-blue-400">
<option value="all">All</option>
<option value="Expanding">Expanding</option>