File size: 31,985 Bytes
e4daa3b
 
 
 
 
 
ba32591
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
 
 
 
 
 
 
 
66404dc
 
 
 
e4daa3b
66404dc
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
 
66404dc
 
e4daa3b
 
 
 
66404dc
 
e4daa3b
 
 
 
66404dc
 
 
 
 
 
 
 
 
 
 
 
e4daa3b
66404dc
 
 
e4daa3b
 
66404dc
 
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0bb818
e4daa3b
c0bb818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4daa3b
 
 
c0bb818
 
e4daa3b
c0bb818
e4daa3b
c0bb818
e4daa3b
 
 
c0bb818
e4daa3b
 
 
 
 
 
c0bb818
 
 
e4daa3b
c0bb818
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0bb818
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
 
 
e4daa3b
66404dc
e4daa3b
66404dc
 
 
 
 
 
 
 
e4daa3b
 
66404dc
 
 
 
 
 
e4daa3b
66404dc
 
 
 
e4daa3b
 
66404dc
e4daa3b
 
 
 
 
 
 
66404dc
e4daa3b
 
 
 
 
 
 
66404dc
e4daa3b
 
66404dc
e4daa3b
 
 
 
 
 
 
 
1efb3e0
 
e4daa3b
 
 
 
 
 
 
 
66404dc
 
 
 
e4daa3b
 
 
 
66404dc
e4daa3b
 
 
66404dc
e4daa3b
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
 
 
 
 
66404dc
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
66404dc
e4daa3b
 
 
 
 
66404dc
 
e4daa3b
 
 
 
66404dc
 
e4daa3b
 
 
 
 
66404dc
 
e4daa3b
 
 
 
 
 
 
 
66404dc
 
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
 
 
 
66404dc
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
66404dc
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
e4daa3b
66404dc
 
e4daa3b
 
 
 
66404dc
 
 
 
 
e4daa3b
 
 
 
 
66404dc
e4daa3b
66404dc
 
e4daa3b
 
66404dc
e4daa3b
66404dc
 
 
e4daa3b
 
 
 
 
66404dc
e4daa3b
66404dc
e4daa3b
 
 
66404dc
 
e4daa3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66404dc
 
 
 
 
 
e4daa3b
 
 
 
 
66404dc
e4daa3b
 
66404dc
 
 
 
 
 
 
 
 
 
e4daa3b
 
66404dc
 
 
e4daa3b
66404dc
e4daa3b
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
# Spec 36: React Frontend + FastAPI Backend for HuggingFace Spaces

**Status**: APPROVED PLAN
**Date**: 2025-12-11
**Goal**: Replace Gradio with React frontend for NiiVue, FastAPI backend for DeepISLES

**UPDATE (2025-12-12):** See `NEXT-CONCERNS.md` for latest architecture fixes regarding config consolidation (BUG-009) and dependency reproducibility (BUG-012). The env var `FRONTEND_ORIGIN` is now `STROKE_DEMO_FRONTEND_ORIGINS`.

---

## Security Note: CVE-2025-55182 Does NOT Affect This App

**CVE-2025-55182 (React2Shell)** is a critical RCE vulnerability disclosed December 3, 2025.

| What | Status |
|------|--------|
| **React 19.x with RSC** | VULNERABLE if using Server Components |
| **React 19.x client-only** | SAFE - no Server Components = no vulnerability |
| **React 18.x** | NOT AFFECTED - no Server Components |

**We use React 19.2.0** which is **safe for our use case** because:
- CVE-2025-55182 only affects React Server Components (RSC)
- Our app is **client-only** (Static Space = no server-side rendering)
- We do not use React Server Components
- The vulnerability requires SSR/RSC to be exploitable

Sources:
- [React Security Advisory](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components)
- [Wiz Analysis](https://www.wiz.io/blog/critical-vulnerability-in-react-cve-2025-55182)

---

## The Stack

| Component | Technology | Version | Purpose |
|-----------|------------|---------|---------|
| **Frontend Framework** | React | 19.2.0 | UI components (client-only, see security note) |
| **Type Safety** | TypeScript | 5.9.3 | Type checking |
| **Build Tool** | Vite | 7.2.4 | Fast builds, HMR |
| **CSS Framework** | Tailwind CSS | 4.1.17 | Utility-first styling |
| **3D Viewer** | @niivue/niivue | 0.65.0 | WebGL2 NIfTI viewer |
| **Testing** | Vitest + Playwright | 4.0.15 / 1.57.0 | Unit, integration, E2E tests |
| **Backend Framework** | FastAPI | 0.124.2 | Python REST API |
| **ML Pipeline** | DeepISLES | existing | Stroke segmentation |

---

## Architecture: Two HuggingFace Spaces

You **need both** because:
- **Static Space** = JavaScript only (React, NiiVue) - cannot run Python
- **Docker Space** = Python runtime (FastAPI, DeepISLES, PyTorch)

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  HuggingFace Static Space           β”‚
β”‚  stroke-viewer-frontend             β”‚
β”‚                                     β”‚
β”‚  React 19 + TypeScript + Tailwind   β”‚
β”‚  @niivue/niivue for 3D viewing      β”‚
β”‚                                     β”‚
β”‚  Serves: index.html, JS, CSS        β”‚
β”‚  Always on, never sleeps            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚ HTTPS API calls
               β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  HuggingFace Docker Space           β”‚
β”‚  stroke-viewer-api                  β”‚
β”‚                                     β”‚
β”‚  FastAPI + DeepISLES + PyTorch      β”‚
β”‚                                     β”‚
β”‚  Endpoints:                         β”‚
β”‚  - GET  /api/cases                  β”‚
β”‚  - POST /api/segment                β”‚
β”‚  - GET  /files/{run_id}/{case}/...  β”‚
β”‚                                     β”‚
β”‚  Sleeps after 48h inactivity        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

---

## Project Structure

This is an **existing monorepo** (`stroke-deepisles-demo`), NOT a new project. The frontend is
added alongside the existing Python package. The "backend" is the existing `src/stroke_deepisles_demo/`
package with a new `api/` submodule.

```
stroke-deepisles-demo/           # EXISTING monorepo
β”œβ”€β”€ frontend/                    # NEW: React + NiiVue (Static Space)
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”‚   β”œβ”€β”€ NiiVueViewer.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ CaseSelector.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ MetricsPanel.tsx
β”‚   β”‚   β”‚   └── Layout.tsx
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   └── useSegmentation.ts
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   └── client.ts
β”‚   β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”‚   └── index.ts
β”‚   β”‚   β”œβ”€β”€ App.tsx
β”‚   β”‚   β”œβ”€β”€ main.tsx
β”‚   β”‚   └── index.css
β”‚   β”œβ”€β”€ e2e/                     # Playwright E2E tests
β”‚   β”œβ”€β”€ public/
β”‚   β”œβ”€β”€ index.html
β”‚   β”œβ”€β”€ vite.config.ts
β”‚   β”œβ”€β”€ vitest.config.ts
β”‚   β”œβ”€β”€ playwright.config.ts
β”‚   β”œβ”€β”€ tsconfig.json
β”‚   β”œβ”€β”€ package.json
β”‚   └── README.md                # HF Spaces YAML config
β”‚
β”œβ”€β”€ src/stroke_deepisles_demo/   # EXISTING Python package (Docker Space)
β”‚   β”œβ”€β”€ api/                     # NEW: FastAPI REST API submodule
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”œβ”€β”€ main.py              # FastAPI app
β”‚   β”‚   β”œβ”€β”€ routes.py            # API endpoints
β”‚   β”‚   └── schemas.py           # Pydantic models
β”‚   β”œβ”€β”€ core/                    # Config, logging (existing)
β”‚   β”œβ”€β”€ data/                    # Data adapters (existing)
β”‚   β”œβ”€β”€ inference/               # DeepISLES integration (existing)
β”‚   β”œβ”€β”€ ui/                      # Gradio UI (being replaced)
β”‚   β”œβ”€β”€ pipeline.py              # ML pipeline (existing)
β”‚   └── metrics.py               # Metrics computation (existing)
β”‚
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ api/                     # NEW: API endpoint tests
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   └── test_endpoints.py
β”‚   └── ...                      # Existing tests
β”‚
β”œβ”€β”€ Dockerfile                   # Docker for HF Spaces (existing)
β”œβ”€β”€ pyproject.toml               # Python package config (existing)
└── README.md
```

**Key difference from a greenfield project:** We're adding `frontend/` and `src/stroke_deepisles_demo/api/`
to an existing codebase, NOT creating separate `frontend/` and `backend/` directories.

---

## Frontend Implementation

### package.json

```json
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "lint": "eslint .",
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test"
  },
  "dependencies": {
    "@niivue/niivue": "^0.65.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@playwright/test": "^1.57.0",
    "@tailwindcss/vite": "^4.1.17",
    "@testing-library/jest-dom": "^6.6.3",
    "@testing-library/react": "^16.3.0",
    "@vitejs/plugin-react": "^5.1.1",
    "@vitest/coverage-v8": "^4.0.15",
    "eslint": "^9.39.1",
    "tailwindcss": "^4.1.17",
    "typescript": "~5.9.3",
    "vite": "^7.2.4",
    "vitest": "^4.0.15"
  }
}
```

**Why these versions:**
- `react` / `react-dom` **19.2.0**: Latest React 19 - client-only so CVE-2025-55182 doesn't apply
- `@niivue/niivue` **0.65.0**: Latest stable (Dec 2025)
- `vite` **7.2.4**: Latest stable v7
- `vitest` **4.0.15**: Fast unit testing with React Testing Library
- `@playwright/test` **1.57.0**: E2E browser testing
- `tailwindcss` **4.1.17**: Latest stable v4
- `typescript` **5.9.3**: Latest stable
- ESLint included for code quality in CI

### vite.config.ts

```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  build: {
    outDir: 'dist',
  },
})
```

### TypeScript Configuration (Vite 7 Project References Pattern)

Vite 7 uses a project references pattern for better separation of app, test, and build configs:

**tsconfig.json** (root - references only):
```json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" },
    { "path": "./tsconfig.test.json" }
  ]
}
```

**tsconfig.app.json** (application code):
```json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "types": ["vite/client"],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"],
  "exclude": ["src/test", "src/mocks", "src/**/*.test.tsx", "src/**/*.test.ts"]
}
```

### src/index.css

```css
@import "tailwindcss";
```

### src/main.tsx

```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
```

### src/App.tsx

```tsx
import { useState } from 'react'
import { Layout } from './components/Layout'
import { CaseSelector } from './components/CaseSelector'
import { NiiVueViewer } from './components/NiiVueViewer'
import { MetricsPanel } from './components/MetricsPanel'
import { useSegmentation } from './hooks/useSegmentation'

export default function App() {
  const [selectedCase, setSelectedCase] = useState<string | null>(null)
  const { result, isLoading, error, runSegmentation } = useSegmentation()

  const handleRunSegmentation = async () => {
    if (selectedCase) {
      await runSegmentation(selectedCase)
    }
  }

  return (
    <Layout>
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {/* Left Panel: Controls */}
        <div className="space-y-4">
          <CaseSelector
            selectedCase={selectedCase}
            onSelectCase={setSelectedCase}
          />
          <button
            onClick={handleRunSegmentation}
            disabled={!selectedCase || isLoading}
            className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400
                       text-white font-medium py-3 px-4 rounded-lg transition"
          >
            {isLoading ? 'Processing...' : 'Run Segmentation'}
          </button>
          {error && (
            <div className="bg-red-100 text-red-700 p-3 rounded-lg">
              {error}
            </div>
          )}
          {result && <MetricsPanel metrics={result.metrics} />}
        </div>

        {/* Right Panel: Viewer */}
        <div className="lg:col-span-2">
          {result ? (
            <NiiVueViewer
              backgroundUrl={result.dwiUrl}
              overlayUrl={result.predictionUrl}
            />
          ) : (
            <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
              <p className="text-gray-400">
                Select a case and run segmentation to view results
              </p>
            </div>
          )}
        </div>
      </div>
    </Layout>
  )
}
```

### src/components/Layout.tsx

```tsx
import { ReactNode } from 'react'

interface LayoutProps {
  children: ReactNode
}

export function Layout({ children }: LayoutProps) {
  return (
    <div className="min-h-screen bg-gray-950 text-white">
      <header className="border-b border-gray-800 py-4">
        <div className="container mx-auto px-4">
          <h1 className="text-2xl font-bold">Stroke Lesion Segmentation</h1>
          <p className="text-gray-400 text-sm mt-1">
            DeepISLES segmentation on ISLES24 dataset
          </p>
        </div>
      </header>
      <main className="container mx-auto px-4 py-6">
        {children}
      </main>
    </div>
  )
}
```

### src/components/NiiVueViewer.tsx

```tsx
import { useRef, useEffect } from 'react'
import { Niivue } from '@niivue/niivue'

interface NiiVueViewerProps {
  backgroundUrl: string
  overlayUrl?: string
}

export function NiiVueViewer({ backgroundUrl, overlayUrl }: NiiVueViewerProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const nvRef = useRef<Niivue | null>(null)

  useEffect(() => {
    if (!canvasRef.current) return

    // Only instantiate NiiVue once; reuse for volume reloads
    let nv = nvRef.current
    if (!nv) {
      nv = new Niivue({
        backColor: [0.05, 0.05, 0.05, 1],
        show3Dcrosshair: true,
        crosshairColor: [1, 0, 0, 0.5],
      })
      nv.attachToCanvas(canvasRef.current)
      nvRef.current = nv
    }

    // Build volumes array - always reload when URLs change
    const volumes: Array<{ url: string; colormap: string; opacity: number }> = [
      { url: backgroundUrl, colormap: 'gray', opacity: 1 },
    ]

    if (overlayUrl) {
      volumes.push({
        url: overlayUrl,
        colormap: 'red',
        opacity: 0.5,
      })
    }

    // Load volumes (async but we don't await - just fire off)
    void nv.loadVolumes(volumes)

    // Cleanup on unmount - CRITICAL: Release WebGL context
    // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup,
    // navigating between results will exhaust contexts and break the viewer.
    return () => {
      if (nvRef.current) {
        // Capture gl BEFORE cleanup (cleanup may null internal state)
        const gl = nvRef.current.gl
        try {
          // NiiVue's cleanup() releases event listeners and observers
          // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup
          nvRef.current.cleanup()
          // Force WebGL context loss to free GPU memory immediately
          if (gl) {
            const ext = gl.getExtension('WEBGL_lose_context')
            ext?.loseContext()
          }
        } catch {
          // Ignore cleanup errors
        }
        nvRef.current = null
      }
    }
  }, [backgroundUrl, overlayUrl])

  return (
    <div className="bg-gray-900 rounded-lg p-2">
      <canvas
        ref={canvasRef}
        className="w-full h-[500px] rounded"
      />
      <div className="flex gap-4 mt-2 text-xs text-gray-400">
        <span>Scroll: Navigate slices</span>
        <span>Drag: Adjust contrast</span>
        <span>Right-click: Pan</span>
      </div>
    </div>
  )
}
```

### src/components/CaseSelector.tsx

```tsx
import { useEffect, useState } from 'react'
import { apiClient } from '../api/client'

interface CaseSelectorProps {
  selectedCase: string | null
  onSelectCase: (caseId: string) => void
}

export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) {
  const [cases, setCases] = useState<string[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const fetchCases = async () => {
      try {
        const data = await apiClient.getCases()
        setCases(data.cases)
      } catch (err) {
        setError('Failed to load cases')
        console.error(err)
      } finally {
        setLoading(false)
      }
    }
    fetchCases()
  }, [])

  if (loading) {
    return (
      <div className="bg-gray-800 rounded-lg p-4">
        <p className="text-gray-400">Loading cases...</p>
      </div>
    )
  }

  if (error) {
    return (
      <div className="bg-red-900 rounded-lg p-4">
        <p className="text-red-300">{error}</p>
      </div>
    )
  }

  return (
    <div className="bg-gray-800 rounded-lg p-4">
      <label className="block text-sm font-medium mb-2">
        Select Case
      </label>
      <select
        value={selectedCase || ''}
        onChange={(e) => onSelectCase(e.target.value)}
        className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2
                   text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
      >
        <option value="">Choose a case...</option>
        {cases.map((caseId) => (
          <option key={caseId} value={caseId}>
            {caseId}
          </option>
        ))}
      </select>
    </div>
  )
}
```

### src/components/MetricsPanel.tsx

```tsx
interface Metrics {
  caseId: string
  diceScore: number | null
  volumeMl: number | null
  elapsedSeconds: number
}

interface MetricsPanelProps {
  metrics: Metrics
}

export function MetricsPanel({ metrics }: MetricsPanelProps) {
  return (
    <div className="bg-gray-800 rounded-lg p-4 space-y-3">
      <h3 className="font-medium text-lg">Results</h3>

      <div className="grid grid-cols-2 gap-3 text-sm">
        <div>
          <span className="text-gray-400">Case:</span>
          <span className="ml-2 font-mono">{metrics.caseId}</span>
        </div>

        {metrics.diceScore !== null && (
          <div>
            <span className="text-gray-400">Dice Score:</span>
            <span className="ml-2 font-mono text-green-400">
              {metrics.diceScore.toFixed(3)}
            </span>
          </div>
        )}

        {metrics.volumeMl !== null && (
          <div>
            <span className="text-gray-400">Volume:</span>
            <span className="ml-2 font-mono">{metrics.volumeMl.toFixed(2)} mL</span>
          </div>
        )}

        <div>
          <span className="text-gray-400">Time:</span>
          <span className="ml-2 font-mono">{metrics.elapsedSeconds.toFixed(1)}s</span>
        </div>
      </div>
    </div>
  )
}
```

### src/api/client.ts

```typescript
// API base URL - configure via environment variable
const API_BASE = import.meta.env.VITE_API_URL || 'https://your-backend.hf.space'

interface CasesResponse {
  cases: string[]
}

interface SegmentResponse {
  caseId: string
  diceScore: number | null
  volumeMl: number | null
  elapsedSeconds: number
  dwiUrl: string
  predictionUrl: string
}

class ApiClient {
  private baseUrl: string

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  async getCases(): Promise<CasesResponse> {
    const response = await fetch(`${this.baseUrl}/api/cases`)
    if (!response.ok) {
      throw new Error(`Failed to fetch cases: ${response.statusText}`)
    }
    return response.json()
  }

  async runSegmentation(caseId: string, fastMode = true): Promise<SegmentResponse> {
    const response = await fetch(`${this.baseUrl}/api/segment`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        case_id: caseId,
        fast_mode: fastMode,
      }),
    })
    if (!response.ok) {
      throw new Error(`Segmentation failed: ${response.statusText}`)
    }
    return response.json()
  }
}

export const apiClient = new ApiClient(API_BASE)
```

### src/hooks/useSegmentation.ts

```typescript
import { useState, useCallback } from 'react'
import { apiClient } from '../api/client'

interface SegmentationResult {
  dwiUrl: string
  predictionUrl: string
  metrics: {
    caseId: string
    diceScore: number | null
    volumeMl: number | null
    elapsedSeconds: number
  }
}

export function useSegmentation() {
  const [result, setResult] = useState<SegmentationResult | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const runSegmentation = useCallback(async (caseId: string) => {
    setIsLoading(true)
    setError(null)

    try {
      const data = await apiClient.runSegmentation(caseId)
      setResult({
        dwiUrl: data.dwiUrl,
        predictionUrl: data.predictionUrl,
        metrics: {
          caseId: data.caseId,
          diceScore: data.diceScore,
          volumeMl: data.volumeMl,
          elapsedSeconds: data.elapsedSeconds,
        },
      })
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
      setResult(null)
    } finally {
      setIsLoading(false)
    }
  }, [])

  return { result, isLoading, error, runSegmentation }
}
```

### src/types/index.ts

```typescript
export interface Case {
  id: string
  name: string
}

export interface Metrics {
  caseId: string
  diceScore: number | null
  volumeMl: number | null
  elapsedSeconds: number
}

export interface SegmentationResult {
  dwiUrl: string
  predictionUrl: string
  metrics: Metrics
}
```

### index.html

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Stroke Lesion Segmentation</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

### frontend/README.md (HuggingFace Spaces Config)

```markdown
---
title: Stroke Lesion Viewer
emoji: 🧠
colorFrom: blue
colorTo: purple
sdk: static
app_file: dist/index.html
app_build_command: npm run build
# CRITICAL: Vite 7 requires Node.js >= 20. HF Spaces defaults to Node 18.
# Without this, the build will fail or produce warnings.
nodejs_version: "20"
pinned: false
---

# Stroke Lesion Segmentation Viewer

Interactive 3D viewer for stroke lesion segmentation results using NiiVue.

Built with React, TypeScript, Tailwind CSS, and Vite.
```

---

## Backend Implementation

The backend is the **existing** `src/stroke_deepisles_demo/` Python package. We add a new
`api/` submodule for FastAPI endpoints. This keeps all Python code in one package with
proper imports (e.g., `from stroke_deepisles_demo.api.routes import router`).

### pyproject.toml (additions)

Add these dependencies to the existing `pyproject.toml`:

```toml
[project.optional-dependencies]
api = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.32.0",
]
```

### src/stroke_deepisles_demo/api/__init__.py

```python
"""FastAPI REST API for stroke segmentation."""

from stroke_deepisles_demo.api.main import app

__all__ = ["app"]
```

### src/stroke_deepisles_demo/api/main.py

```python
"""FastAPI application for stroke segmentation API."""

import os

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from stroke_deepisles_demo.api.routes import router

app = FastAPI(
    title="Stroke Segmentation API",
    description="DeepISLES stroke lesion segmentation",
    version="1.0.0",
)

# CORS configuration
FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "")
CORS_ORIGINS = [
    "http://localhost:5173",  # Vite dev server
    "http://localhost:3000",  # Alternative local port
]
if FRONTEND_ORIGIN:
    CORS_ORIGINS.append(FRONTEND_ORIGIN)

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    # Match HF Spaces URLs in both formats (direct and proxy)
    allow_origin_regex=r"https://.*stroke-viewer-frontend.*\\.hf\\.space",
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# API routes
app.include_router(router, prefix="/api")

# Static files for NIfTI results (only mount if directory exists)
RESULTS_DIR = "/tmp/stroke-results"
if os.path.exists(RESULTS_DIR):
    app.mount("/files", StaticFiles(directory=RESULTS_DIR), name="files")


@app.get("/")
async def root():
    """Health check endpoint."""
    return {"status": "healthy", "service": "stroke-segmentation-api"}
```

### src/stroke_deepisles_demo/api/routes.py

```python
"""API route handlers."""

import os
import uuid
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request

from stroke_deepisles_demo.api.schemas import CasesResponse, SegmentRequest, SegmentResponse
from stroke_deepisles_demo.data import list_case_ids
from stroke_deepisles_demo.pipeline import run_pipeline_on_case
from stroke_deepisles_demo.metrics import compute_volume_ml

router = APIRouter()

# Base directory for results
RESULTS_BASE = Path("/tmp/stroke-results")


def get_backend_base_url(request: Request) -> str:
    """Get the backend's public URL for building absolute file URLs.

    Priority:
    1. BACKEND_PUBLIC_URL env var (for production HF Spaces)
    2. Request's base URL (for local development)
    """
    env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
    if env_url:
        return env_url
    return str(request.base_url).rstrip("/")


@router.get("/cases", response_model=CasesResponse)
async def get_cases():
    """List available cases from dataset."""
    try:
        cases = list_case_ids()
        return CasesResponse(cases=cases)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@router.post("/segment", response_model=SegmentResponse)
async def run_segmentation(request: Request, body: SegmentRequest):
    """Run DeepISLES segmentation on a case."""
    try:
        # Generate unique run ID to avoid conflicts
        run_id = str(uuid.uuid4())[:8]
        output_dir = RESULTS_BASE / run_id

        result = run_pipeline_on_case(
            body.case_id,
            output_dir=output_dir,
            fast=body.fast_mode,
            compute_dice=True,
            cleanup_staging=True,
        )

        # Compute volume
        volume_ml = None
        try:
            volume_ml = round(compute_volume_ml(result.prediction_mask, threshold=0.5), 2)
        except Exception:
            pass

        # Build absolute file URLs
        backend_url = get_backend_base_url(request)
        dwi_filename = result.input_files["dwi"].name
        pred_filename = result.prediction_mask.name

        file_path_prefix = f"/files/{run_id}/{result.case_id}"

        return SegmentResponse(
            caseId=result.case_id,
            diceScore=result.dice_score,
            volumeMl=volume_ml,
            elapsedSeconds=round(result.elapsed_seconds, 2),
            dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}",
            predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}",
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
```

### src/stroke_deepisles_demo/api/schemas.py

```python
"""Pydantic schemas for API requests and responses."""

from pydantic import BaseModel


class CasesResponse(BaseModel):
    """Response for GET /api/cases."""

    cases: list[str]


class SegmentRequest(BaseModel):
    """Request body for POST /api/segment."""

    case_id: str
    fast_mode: bool = True


class SegmentResponse(BaseModel):
    """Response for POST /api/segment."""

    caseId: str
    diceScore: float | None
    volumeMl: float | None
    elapsedSeconds: float
    dwiUrl: str
    predictionUrl: str
```

### Dockerfile (update existing)

The existing `Dockerfile` at project root needs to be updated for the API:

```dockerfile
# CRITICAL: Must use isleschallenge/deepisles base image
# This image contains:
# - PyTorch with CUDA support
# - Pre-installed DeepISLES model weights (~18GB)
# - All medical imaging dependencies (nibabel, nnunet, etc.)
FROM isleschallenge/deepisles:latest

WORKDIR /app

# Copy the project
COPY pyproject.toml .
COPY src/ src/
COPY README.md .

# Install the package with API dependencies
RUN pip install --no-cache-dir -e ".[api]"

# Create results directory (used by StaticFiles mount)
RUN mkdir -p /tmp/stroke-results

# Environment variables for HuggingFace Spaces
ENV HF_SPACES=1
ENV DEEPISLES_DIRECT_INVOCATION=1

# Expose port (HF Spaces expects 7860)
EXPOSE 7860

# Run FastAPI (note: module path is stroke_deepisles_demo.api.main:app)
CMD ["uvicorn", "stroke_deepisles_demo.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
```

**CRITICAL: GPU Required**

DeepISLES requires GPU acceleration. HuggingFace Spaces FREE tier (`cpu-basic`) will NOT work.

| Tier | GPU | Will Work? |
|------|-----|------------|
| `cpu-basic` (free) | None | ❌ No |
| `t4-small` | NVIDIA T4 (16GB) | βœ… Yes |
| `t4-medium` | NVIDIA T4 (16GB) | βœ… Yes |
| `a10g-small` | NVIDIA A10G (24GB) | βœ… Yes |

When creating the HF Space, select **T4-small** or higher.

**Note:** The Dockerfile copies the full project because `requirements.txt` has:
```
stroke-deepisles-demo @ file:.
```
This PEP 508 local path reference requires the package source to be present.

### backend/README.md (HuggingFace Spaces Config)

```markdown
---
title: Stroke Segmentation API
emoji: 🧠
colorFrom: blue
colorTo: purple
sdk: docker
app_port: 7860
pinned: false
---

# Stroke Segmentation API

FastAPI backend running DeepISLES stroke lesion segmentation.

## Endpoints

- `GET /api/cases` - List available cases
- `POST /api/segment` - Run segmentation
- `GET /files/{filename}` - Download result files
```

---

## Setup Commands

### Frontend (Local Development)

```bash
cd frontend

# Install dependencies (already configured in package.json)
npm install

# Run dev server
npm run dev
# Opens http://localhost:5173

# Run tests
npm test                  # Unit tests with Vitest
npm run test:e2e          # E2E tests with Playwright
npm run test:coverage     # Coverage report
```

### Backend (Local Development)

```bash
# From project root (stroke-deepisles-demo/)

# Install with API dependencies
pip install -e ".[api]"

# Run server
uvicorn stroke_deepisles_demo.api.main:app --reload --port 7860
# Opens http://localhost:7860

# Run API tests
pytest tests/api/ -v
```

### Deploy to HuggingFace

```bash
# Frontend (Static Space) - deploy from frontend/ directory
cd frontend
npm run build
huggingface-cli repo create stroke-viewer-frontend --type space --space-sdk static
huggingface-cli upload stroke-viewer-frontend ./dist . --repo-type space

# Backend (Docker Space) - deploy from project root
# The Dockerfile at project root builds the full package including API
huggingface-cli repo create stroke-viewer-api --type space --space-sdk docker
huggingface-cli upload stroke-viewer-api . . --repo-type space
```

---

## Environment Variables

### Frontend (.env)

```env
VITE_API_URL=https://your-username-stroke-viewer-api.hf.space
```

### Backend

No additional env vars needed - uses existing stroke-deepisles-demo configuration.

---

## Key Differences from Gradio

| What | Gradio (broken) | This Stack |
|------|-----------------|------------|
| NiiVue JavaScript | Blocked by innerHTML | Full execution βœ“ |
| WebGL2 context | Frozen during hydration | Works normally βœ“ |
| Bundle size | ~2MB Gradio overhead | ~200KB total |
| Cold start | Python + Gradio init | Instant (static) |
| Customization | Limited to Gradio components | Full React control |

---

## Next Steps

1. βœ… Create `frontend/` directory with React + NiiVue (DONE - PR #32 merged)
2. βœ… Create `src/stroke_deepisles_demo/api/` submodule with FastAPI (DONE)
3. βœ… Create `tests/api/` with endpoint tests (DONE - 8 tests passing)
4. Test locally: `npm run dev` + `uvicorn stroke_deepisles_demo.api.main:app`
5. Create HuggingFace Spaces (one Static, one Docker)
6. Deploy and test

---

## Dependencies Summary (Verified Dec 11, 2025)

**Frontend (npm) - ACTUAL VERSIONS (from package.json):**
| Package | Version | Notes |
|---------|---------|-------|
| react | ^19.2.0 | React 19 client-only (safe from CVE-2025-55182) |
| react-dom | ^19.2.0 | Must match react version |
| @niivue/niivue | ^0.65.0 | Latest stable |
| typescript | ~5.9.3 | Latest 5.9.x |
| vite | ^7.2.4 | Latest v7 |
| tailwindcss | ^4.1.17 | Latest v4 |
| @tailwindcss/vite | ^4.1.17 | Must match tailwindcss |
| @vitejs/plugin-react | ^5.1.1 | Latest stable |

**Backend (pip) - VERSIONS (from pyproject.toml):**
| Package | Version | Notes |
|---------|---------|-------|
| fastapi | >=0.115.0 | Latest compatible |
| uvicorn[standard] | >=0.32.0 | Latest stable |
| pydantic | (bundled) | Included with FastAPI |

**Node.js:** >= 20.0.0 (required for Vite 7)
**Python:** >= 3.11 (recommended for FastAPI)