Kexin-251202 commited on
Commit
aff150a
·
verified ·
1 Parent(s): 0616f67

Simplify UI integration

Browse files
Files changed (5) hide show
  1. src/App.css +2044 -2031
  2. src/App.jsx +125 -91
  3. src/components/FocusPageLocal.jsx +1040 -1048
  4. src/components/Home.jsx +22 -18
  5. src/components/Records.jsx +757 -645
src/App.css CHANGED
@@ -1,2031 +1,2044 @@
1
- /* =========================================
2
- 1. REACT layout setting
3
- ========================================= */
4
- html, body, #root {
5
- width: 100%;
6
- height: 100%;
7
- margin: 0;
8
- padding: 0;
9
- }
10
-
11
- .app-container {
12
- width: 100%;
13
- min-height: 100vh; /* screen height */
14
- display: flex;
15
- flex-direction: column;
16
- background-color: #f9f9f9;
17
- }
18
-
19
- /* =========================================
20
- 2. original layout
21
- ========================================= */
22
-
23
- /* GLOBAL STYLES */
24
- body {
25
- font-family: 'Nunito', sans-serif;
26
- background-color: #f9f9f9;
27
- overflow-x: hidden;
28
- overflow-y: auto;
29
- }
30
-
31
- /* dynamic class name */
32
- .hidden {
33
- display: none !important;
34
- }
35
-
36
- /* TOP MENU */
37
- #top-menu {
38
- height: 60px;
39
- background-color: white;
40
- display: flex;
41
- align-items: center;
42
- justify-content: flex-start;
43
- gap: 0;
44
- padding: 0 16px 0 20px;
45
- box-sizing: border-box;
46
- box-shadow: 0 2px 5px rgba(0,0,0,0.05);
47
- position: fixed;
48
- top: 0;
49
- left: 0;
50
- right: 0;
51
- width: 100%;
52
- z-index: 1000;
53
- overflow-x: auto;
54
- overflow-y: hidden;
55
- white-space: nowrap;
56
- }
57
-
58
- .top-menu-links {
59
- flex: 1;
60
- display: flex;
61
- align-items: center;
62
- justify-content: center;
63
- flex-wrap: wrap;
64
- gap: 0;
65
- min-width: 0;
66
- }
67
-
68
- .menu-btn {
69
- background: none;
70
- border: none;
71
- font-family: 'Nunito', sans-serif;
72
- font-size: 16px;
73
- color: #333;
74
- padding: 10px 20px;
75
- cursor: pointer;
76
- transition: background-color 0.2s;
77
- }
78
-
79
- .menu-btn:hover {
80
- background-color: #f0f0f0;
81
- border-radius: 4px;
82
- }
83
-
84
- /* active for React */
85
- .menu-btn.active {
86
- font-weight: bold;
87
- color: #007BFF;
88
- background-color: #eef7ff;
89
- border-radius: 4px;
90
- }
91
-
92
- .separator {
93
- width: 1px;
94
- height: 20px;
95
- background-color: #555; /* Dark gray separator */
96
- margin: 0 5px;
97
- }
98
-
99
- /* PAGE CONTAINER */
100
- .page {
101
- /* content under menu */
102
- min-height: calc(100vh - 60px);
103
- width: 100%;
104
- padding-top: 60px; /* Space for fixed menu */
105
- padding-bottom: 40px; /* Space at bottom for scrolling */
106
- box-sizing: border-box;
107
- display: flex;
108
- flex-direction: column;
109
- align-items: center;
110
- overflow-y: auto;
111
- }
112
-
113
- /* Ensure page titles are black */
114
- .page h1 {
115
- color: #000 !important;
116
- background: transparent !important;
117
- }
118
-
119
- .page-title {
120
- color: #000 !important;
121
- background: transparent !important;
122
- }
123
-
124
- /* PAGE A SPECIFIC */
125
- #page-a {
126
- justify-content: center; /* Center vertically */
127
- /* Fine-tune this margin if the Home screen sits slightly too low. */
128
- margin-top: -40px;
129
- flex: 1; /* Fill the remaining height so vertical centering still works. */
130
- }
131
-
132
- #page-a h1 {
133
- font-size: 80px;
134
- margin: 0 0 10px 0;
135
- color: #000;
136
- text-align: center; /* Keep the heading centered. */
137
- }
138
-
139
- #page-a p {
140
- color: #666;
141
- font-size: 20px;
142
- margin-bottom: 40px;
143
- text-align: center;
144
- }
145
-
146
- .btn-main {
147
- background-color: #007BFF; /* Blue */
148
- color: white;
149
- border: none;
150
- padding: 15px 50px;
151
- font-size: 20px;
152
- font-family: 'Nunito', sans-serif;
153
- border-radius: 30px; /* Fully rounded corners */
154
- cursor: pointer;
155
- transition: transform 0.2s ease;
156
- }
157
-
158
- .btn-main:hover {
159
- transform: scale(1.1); /* Zoom effect */
160
- }
161
-
162
- /* PAGE B SPECIFIC */
163
- #page-b {
164
- justify-content: space-evenly; /* Distribute vertical space */
165
- padding-bottom: 20px;
166
- min-height: calc(100vh - 60px); /* Ensure the page still fills the viewport. */
167
- }
168
-
169
- /* 1. Display Area */
170
- #display-area {
171
- width: 60%;
172
- height: 50vh; /* Use viewport height to scale more consistently across screens. */
173
- min-height: 300px;
174
- border: 2px solid #ddd;
175
- border-radius: 12px;
176
- background-color: #fff;
177
- display: flex;
178
- align-items: center;
179
- justify-content: center;
180
- color: #555;
181
- font-size: 24px;
182
- position: relative;
183
- /* Keep video content centered without overflowing the frame. */
184
- overflow: hidden;
185
- }
186
-
187
- .focus-display-shell {
188
- background: #101010;
189
- }
190
-
191
- .focus-flow-overlay {
192
- position: fixed;
193
- top: 76px;
194
- right: 20px;
195
- bottom: 20px;
196
- left: 20px;
197
- display: flex;
198
- align-items: center;
199
- justify-content: center;
200
- padding: 0;
201
- background: rgba(17, 31, 52, 0.18);
202
- backdrop-filter: blur(10px);
203
- z-index: 900;
204
- }
205
-
206
- .focus-flow-card {
207
- width: min(1040px, 100%);
208
- background: #fff;
209
- border-radius: 24px;
210
- padding: 30px 34px;
211
- box-shadow: 0 28px 80px rgba(14, 44, 88, 0.18);
212
- border: 1px solid rgba(0, 123, 255, 0.12);
213
- box-sizing: border-box;
214
- }
215
-
216
- .focus-flow-header {
217
- display: flex;
218
- align-items: center;
219
- justify-content: space-between;
220
- gap: 24px;
221
- margin-bottom: 18px;
222
- }
223
-
224
- .focus-flow-eyebrow {
225
- display: inline-block;
226
- padding: 6px 12px;
227
- border-radius: 999px;
228
- background: #e7f3ff;
229
- color: #007BFF;
230
- font-size: 0.82rem;
231
- font-weight: 800;
232
- letter-spacing: 0.04em;
233
- text-transform: uppercase;
234
- }
235
-
236
- .focus-flow-header h2 {
237
- margin: 14px 0 0;
238
- color: #333;
239
- font-size: clamp(1.8rem, 2.5vw, 2.5rem);
240
- line-height: 1.1;
241
- }
242
-
243
- .focus-flow-icon {
244
- flex: 0 0 auto;
245
- display: flex;
246
- align-items: center;
247
- justify-content: center;
248
- width: 116px;
249
- height: 116px;
250
- border-radius: 24px;
251
- background: linear-gradient(180deg, #f4f9ff 0%, #edf5ff 100%);
252
- border: 1px solid rgba(0, 123, 255, 0.12);
253
- }
254
-
255
- .focus-flow-lead {
256
- margin: 0 0 20px;
257
- color: #4a4a4a;
258
- font-size: 1rem;
259
- line-height: 1.6;
260
- }
261
-
262
- .focus-flow-grid {
263
- display: grid;
264
- grid-template-columns: repeat(3, minmax(0, 1fr));
265
- gap: 16px;
266
- }
267
-
268
- .focus-flow-panel {
269
- background: #f8fbff;
270
- border: 1px solid #d9eaff;
271
- border-radius: 14px;
272
- padding: 18px;
273
- }
274
-
275
- .focus-flow-panel h3,
276
- .focus-flow-step-copy h3 {
277
- margin: 0 0 8px;
278
- color: #333;
279
- font-size: 1rem;
280
- }
281
-
282
- .focus-flow-panel p,
283
- .focus-flow-step-copy p {
284
- margin: 0;
285
- color: #5e6670;
286
- font-size: 0.95rem;
287
- line-height: 1.6;
288
- }
289
-
290
- .focus-flow-steps {
291
- display: grid;
292
- grid-template-columns: repeat(3, minmax(0, 1fr));
293
- gap: 14px;
294
- }
295
-
296
- .focus-flow-step {
297
- display: flex;
298
- align-items: flex-start;
299
- gap: 14px;
300
- background: #f8fbff;
301
- border: 1px solid #d9eaff;
302
- border-radius: 14px;
303
- padding: 16px 18px;
304
- min-height: 100px;
305
- box-sizing: border-box;
306
- }
307
-
308
- .focus-flow-step-number {
309
- flex: 0 0 auto;
310
- display: inline-flex;
311
- align-items: center;
312
- justify-content: center;
313
- width: 34px;
314
- height: 34px;
315
- border-radius: 50%;
316
- background: #007BFF;
317
- color: #fff;
318
- font-size: 0.95rem;
319
- font-weight: 800;
320
- }
321
-
322
- .focus-flow-step-copy {
323
- min-width: 0;
324
- }
325
-
326
- .focus-flow-footer {
327
- display: flex;
328
- align-items: center;
329
- justify-content: space-between;
330
- gap: 16px;
331
- margin-top: 20px;
332
- }
333
-
334
- .focus-flow-note {
335
- color: #667281;
336
- font-size: 0.94rem;
337
- line-height: 1.6;
338
- }
339
-
340
- .focus-flow-glasses-note {
341
- background: #fffbea;
342
- border: 1px solid #f5c518;
343
- border-radius: 10px;
344
- padding: 12px 16px;
345
- font-size: 0.9rem;
346
- color: #5a4a00;
347
- line-height: 1.55;
348
- margin-top: 4px;
349
- margin-bottom: 4px;
350
- }
351
-
352
- .focus-flow-panel-warn {
353
- border-left: 3px solid #f5a623;
354
- background: #fff9f0;
355
- }
356
-
357
- .eye-gaze-modal-checkbox {
358
- display: flex;
359
- align-items: center;
360
- gap: 8px;
361
- margin-top: 16px;
362
- font-size: 0.9rem;
363
- color: #667281;
364
- cursor: pointer;
365
- user-select: none;
366
- }
367
-
368
- .focus-flow-button,
369
- .focus-flow-secondary {
370
- border: none;
371
- border-radius: 999px;
372
- padding: 13px 24px;
373
- font-family: 'Nunito', sans-serif;
374
- font-size: 0.98rem;
375
- font-weight: 800;
376
- cursor: pointer;
377
- transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
378
- }
379
-
380
- .focus-flow-button {
381
- background: #007BFF;
382
- color: #fff;
383
- box-shadow: 0 12px 24px rgba(0, 123, 255, 0.18);
384
- }
385
-
386
- .focus-flow-button:hover {
387
- background: #0069d9;
388
- border-color: transparent;
389
- transform: translateY(-1px);
390
- }
391
-
392
- .focus-flow-secondary {
393
- background: #eef3f8;
394
- color: #4b5a6b;
395
- }
396
-
397
- .focus-flow-secondary:hover {
398
- background: #e2eaf3;
399
- border-color: transparent;
400
- }
401
-
402
- .focus-state-pill {
403
- position: absolute;
404
- top: 18px;
405
- left: 18px;
406
- display: inline-flex;
407
- align-items: center;
408
- gap: 10px;
409
- padding: 10px 16px;
410
- border-radius: 999px;
411
- color: #fff;
412
- font-size: 0.88rem;
413
- font-weight: 800;
414
- letter-spacing: 0.04em;
415
- text-transform: uppercase;
416
- z-index: 2;
417
- box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
418
- }
419
-
420
- .focus-state-pill.pending {
421
- background: rgba(87, 96, 111, 0.92);
422
- }
423
-
424
- .focus-state-pill.focused {
425
- background: rgba(33, 163, 102, 0.94);
426
- }
427
-
428
- .focus-state-pill.not-focused {
429
- background: rgba(215, 68, 68, 0.94);
430
- }
431
-
432
- .focus-state-dot {
433
- width: 10px;
434
- height: 10px;
435
- border-radius: 50%;
436
- background: currentColor;
437
- box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.16);
438
- }
439
-
440
- .focus-idle-overlay {
441
- position: absolute;
442
- inset: 0;
443
- display: flex;
444
- flex-direction: column;
445
- align-items: center;
446
- justify-content: center;
447
- gap: 10px;
448
- background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.72));
449
- color: #fff;
450
- text-align: center;
451
- z-index: 1;
452
- }
453
-
454
- .focus-idle-overlay p {
455
- margin: 0;
456
- font-size: 1.6rem;
457
- font-weight: 800;
458
- }
459
-
460
- .focus-idle-overlay span {
461
- max-width: 420px;
462
- color: rgba(255, 255, 255, 0.82);
463
- font-size: 0.98rem;
464
- line-height: 1.5;
465
- }
466
-
467
- .focus-inline-error {
468
- margin-top: 18px;
469
- padding: 12px 16px;
470
- max-width: 620px;
471
- border-radius: 12px;
472
- background: #fff1ee;
473
- color: #b54028;
474
- font-size: 0.95rem;
475
- font-weight: 700;
476
- box-shadow: 0 10px 20px rgba(181, 64, 40, 0.08);
477
- }
478
-
479
- .focus-inline-error-standalone {
480
- width: 60%;
481
- box-sizing: border-box;
482
- }
483
-
484
- .focus-debug-panel {
485
- position: absolute;
486
- top: 10px;
487
- right: 10px;
488
- background: rgba(0,0,0,0.7);
489
- color: white;
490
- padding: 10px;
491
- border-radius: 5px;
492
- font-size: 12px;
493
- font-family: monospace;
494
- }
495
-
496
- .focus-model-strip {
497
- display: flex;
498
- align-items: center;
499
- justify-content: center;
500
- flex-wrap: wrap;
501
- gap: 8px;
502
- padding: 10px 16px;
503
- background: #fff;
504
- border: 1px solid #e0e0e0;
505
- border-radius: 12px;
506
- margin: 10px auto;
507
- max-width: 700px;
508
- box-shadow: 0 2px 8px rgba(0,0,0,0.06);
509
- }
510
-
511
- /* --- Model info card --- */
512
- .model-card {
513
- width: 60%;
514
- margin: 14px auto 0;
515
- background: #fff;
516
- border: 1px solid #e0e0e0;
517
- border-radius: 14px;
518
- padding: 18px 22px 14px;
519
- box-shadow: 0 2px 10px rgba(0,0,0,0.06);
520
- animation: cardFadeIn 0.25s ease;
521
- box-sizing: border-box;
522
- }
523
-
524
- .model-card-details {
525
- display: grid;
526
- grid-template-columns: repeat(3, 1fr);
527
- gap: 12px;
528
- }
529
-
530
- @keyframes cardFadeIn {
531
- from { opacity: 0; transform: translateY(4px); }
532
- to { opacity: 1; transform: translateY(0); }
533
- }
534
-
535
- .model-card-header {
536
- display: flex;
537
- align-items: center;
538
- gap: 10px;
539
- margin-bottom: 4px;
540
- }
541
-
542
- .model-card-title {
543
- margin: 0;
544
- font-size: 1.05rem;
545
- color: #1a1a2e;
546
- }
547
-
548
- .model-card-badge {
549
- padding: 3px 10px;
550
- border-radius: 999px;
551
- background: #e7f3ff;
552
- color: #007BFF;
553
- font-size: 0.7rem;
554
- font-weight: 800;
555
- letter-spacing: 0.04em;
556
- text-transform: uppercase;
557
- }
558
-
559
- .model-card-badge-baseline {
560
- padding: 3px 10px;
561
- border-radius: 999px;
562
- background: #fff3e0;
563
- color: #e67e22;
564
- font-size: 0.7rem;
565
- font-weight: 800;
566
- letter-spacing: 0.04em;
567
- text-transform: uppercase;
568
- }
569
-
570
- .model-card-tagline {
571
- margin: 0 0 12px;
572
- color: #667281;
573
- font-size: 0.85rem;
574
- line-height: 1.4;
575
- }
576
-
577
- .model-card-metrics {
578
- display: grid;
579
- grid-template-columns: repeat(4, 1fr);
580
- gap: 8px;
581
- margin-bottom: 14px;
582
- }
583
-
584
- .model-card-metric {
585
- text-align: center;
586
- padding: 8px 4px;
587
- background: #f8fbff;
588
- border: 1px solid #e8f0fe;
589
- border-radius: 10px;
590
- }
591
-
592
- .model-card-metric-value {
593
- display: block;
594
- font-size: 1.1rem;
595
- font-weight: 800;
596
- color: #007BFF;
597
- line-height: 1.2;
598
- }
599
-
600
- .model-card-metric-label {
601
- display: block;
602
- font-size: 0.65rem;
603
- color: #8899aa;
604
- font-weight: 700;
605
- text-transform: uppercase;
606
- letter-spacing: 0.04em;
607
- margin-top: 2px;
608
- }
609
-
610
- .model-card-section {
611
- margin-bottom: 8px;
612
- }
613
-
614
- .model-card-section h4 {
615
- margin: 0 0 2px;
616
- font-size: 0.78rem;
617
- color: #555;
618
- font-weight: 800;
619
- text-transform: uppercase;
620
- letter-spacing: 0.03em;
621
- }
622
-
623
- .model-card-section p {
624
- margin: 0;
625
- font-size: 0.82rem;
626
- color: #4a4a4a;
627
- line-height: 1.5;
628
- }
629
-
630
- .model-card-eval {
631
- margin-top: 10px;
632
- padding: 6px 10px;
633
- background: #f5f7fa;
634
- border-radius: 8px;
635
- font-size: 0.72rem;
636
- color: #7a8a9a;
637
- font-weight: 600;
638
- }
639
-
640
- @media (max-width: 768px) {
641
- .model-card {
642
- width: 90%;
643
- }
644
- .model-card-metrics {
645
- grid-template-columns: repeat(2, 1fr);
646
- }
647
- .model-card-details {
648
- grid-template-columns: 1fr;
649
- }
650
- }
651
-
652
- .focus-model-label {
653
- color: #666;
654
- font-size: 13px;
655
- font-weight: 700;
656
- margin-right: 4px;
657
- }
658
-
659
- .focus-model-button {
660
- padding: 6px 16px;
661
- border-radius: 16px;
662
- border: 1px solid #d0d0d0;
663
- background: #f5f5f5;
664
- color: #555;
665
- font-size: 12px;
666
- font-weight: 600;
667
- text-transform: uppercase;
668
- cursor: pointer;
669
- transition: all 0.2s;
670
- }
671
-
672
- .focus-model-button:hover {
673
- border-color: #007BFF;
674
- color: #007BFF;
675
- background: #f0f7ff;
676
- }
677
-
678
- .focus-model-button.active {
679
- border: 2px solid #007BFF;
680
- background: #007BFF;
681
- color: #fff;
682
- }
683
-
684
- .focus-model-sep {
685
- width: 1px;
686
- height: 24px;
687
- background: #d0d0d0;
688
- margin: 0 4px;
689
- }
690
-
691
- .eye-gaze-toggle {
692
- display: inline-flex;
693
- align-items: center;
694
- gap: 6px;
695
- padding: 6px 14px;
696
- border-radius: 16px;
697
- font-size: 12px;
698
- font-weight: 700;
699
- cursor: pointer;
700
- transition: all 0.25s ease;
701
- }
702
-
703
- .eye-gaze-toggle.off {
704
- border: 1px solid #d0d0d0;
705
- background: #f5f5f5;
706
- color: #888;
707
- }
708
-
709
- .eye-gaze-toggle.off:hover {
710
- border-color: #007BFF;
711
- color: #007BFF;
712
- background: #f0f7ff;
713
- }
714
-
715
- .eye-gaze-toggle.on {
716
- border: 2px solid #007BFF;
717
- background: #007BFF;
718
- color: #fff;
719
- box-shadow: 0 2px 8px rgba(0, 123, 255, 0.25);
720
- }
721
-
722
- .eye-gaze-toggle.on:hover {
723
- background: #0069d9;
724
- border-color: #0069d9;
725
- }
726
-
727
- .eye-gaze-icon {
728
- flex-shrink: 0;
729
- }
730
-
731
- .focus-model-button.recalibrate {
732
- border: 1px solid #007BFF;
733
- background: transparent;
734
- color: #007BFF;
735
- font-weight: 600;
736
- font-size: 11px;
737
- }
738
-
739
- .focus-model-button.recalibrate:hover {
740
- background: #f0f7ff;
741
- }
742
-
743
- .focus-system-stats {
744
- display: flex;
745
- align-items: center;
746
- justify-content: center;
747
- gap: 12px;
748
- padding: 4px 16px;
749
- margin: 4px auto;
750
- max-width: 400px;
751
- font-size: 12px;
752
- color: #888;
753
- }
754
-
755
- .focus-system-stats strong {
756
- color: #555;
757
- }
758
-
759
- .focus-system-stats-sep {
760
- width: 1px;
761
- height: 12px;
762
- background: #ccc;
763
- }
764
-
765
- #display-area video {
766
- width: 100%;
767
- height: 100%;
768
- object-fit: cover; /* Behaves similarly to background-size: cover. */
769
- }
770
-
771
- /* 2. Timeline Area */
772
- #timeline-area {
773
- width: 60%;
774
- height: 80px;
775
- position: relative;
776
- display: flex;
777
- flex-direction: column;
778
- justify-content: flex-end;
779
- align-self: center;
780
- margin: 0 auto;
781
- }
782
-
783
- #timeline-visuals {
784
- display: flex;
785
- justify-content: center;
786
- flex-wrap: wrap;
787
- align-items: flex-end;
788
- gap: 2px;
789
- width: 100%;
790
- }
791
-
792
- .timeline-label {
793
- position: absolute;
794
- top: 0;
795
- left: 0;
796
- color: #888;
797
- font-size: 14px;
798
- }
799
-
800
- #timeline-line {
801
- width: 100%;
802
- height: 2px;
803
- background-color: #87CEEB; /* Light blue */
804
- }
805
-
806
- /* 3. Control Panel */
807
- #control-panel {
808
- display: flex;
809
- gap: 20px;
810
- width: 60%;
811
- justify-content: space-between;
812
- }
813
-
814
- .action-btn {
815
- flex: 1; /* Evenly distributed width */
816
- padding: 12px 0;
817
- border: none;
818
- border-radius: 12px;
819
- font-size: 16px;
820
- font-family: 'Nunito', sans-serif;
821
- font-weight: 700;
822
- cursor: pointer;
823
- color: white;
824
- transition: opacity 0.2s;
825
- }
826
-
827
- .action-btn:hover {
828
- opacity: 0.9;
829
- }
830
-
831
- .action-btn.green { background-color: #28a745; }
832
- .action-btn.blue { background-color: #007BFF; }
833
- .action-btn.orange { background-color: #e67e22; }
834
- .action-btn.red { background-color: #dc3545; }
835
-
836
- /* 4. Frame Control */
837
- #frame-control {
838
- display: flex;
839
- align-items: center;
840
- gap: 15px;
841
- color: #333;
842
- font-weight: bold;
843
- }
844
-
845
- #frame-slider {
846
- width: 200px;
847
- cursor: pointer;
848
- }
849
-
850
- #frame-input {
851
- width: 50px;
852
- padding: 5px;
853
- border: 1px solid #ccc;
854
- border-radius: 5px;
855
- text-align: center;
856
- font-family: 'Nunito', sans-serif;
857
- }
858
-
859
- /* ================ ACHIEVEMENT PAGE ================ */
860
-
861
- .stats-grid {
862
- display: grid;
863
- grid-template-columns: repeat(4, 1fr);
864
- gap: 20px;
865
- width: 80%;
866
- margin: 40px auto;
867
- }
868
-
869
- .stat-card {
870
- background: white;
871
- padding: 30px;
872
- border-radius: 12px;
873
- text-align: center;
874
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
875
- }
876
-
877
- .stat-number {
878
- font-size: 48px;
879
- font-weight: bold;
880
- color: #007BFF;
881
- margin-bottom: 10px;
882
- }
883
-
884
- .stat-label {
885
- font-size: 16px;
886
- color: #666;
887
- }
888
-
889
- .achievements-section {
890
- width: 80%;
891
- margin: 0 auto;
892
- }
893
-
894
- .achievements-section h2 {
895
- color: #333;
896
- margin-bottom: 20px;
897
- }
898
-
899
- .badges-grid {
900
- display: grid;
901
- grid-template-columns: repeat(3, 1fr);
902
- gap: 20px;
903
- }
904
-
905
- .badge {
906
- background: white;
907
- padding: 30px 20px;
908
- border-radius: 12px;
909
- text-align: center;
910
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
911
- transition: transform 0.2s;
912
- }
913
-
914
- .badge:hover {
915
- transform: translateY(-5px);
916
- }
917
-
918
- .badge.locked {
919
- opacity: 0.4;
920
- filter: grayscale(100%);
921
- }
922
-
923
- .badge-icon {
924
- font-size: 64px;
925
- margin-bottom: 15px;
926
- }
927
-
928
- .badge-name {
929
- font-size: 16px;
930
- font-weight: bold;
931
- color: #333;
932
- }
933
-
934
- /* ================ RECORDS PAGE ================ */
935
-
936
- .records-controls {
937
- display: flex;
938
- gap: 10px;
939
- margin: 20px auto;
940
- width: 80%;
941
- justify-content: center;
942
- }
943
-
944
- .filter-btn {
945
- padding: 10px 20px;
946
- border: 2px solid #007BFF;
947
- background: white;
948
- color: #007BFF;
949
- border-radius: 8px;
950
- cursor: pointer;
951
- font-family: 'Nunito', sans-serif;
952
- font-weight: 600;
953
- transition: all 0.2s;
954
- }
955
-
956
- .filter-btn:hover {
957
- background: #e7f3ff;
958
- }
959
-
960
- .filter-btn.active {
961
- background: #007BFF;
962
- color: white;
963
- }
964
-
965
- .chart-container {
966
- width: 80%;
967
- background: white;
968
- padding: 30px;
969
- border-radius: 12px;
970
- margin: 20px auto;
971
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
972
- }
973
-
974
- #focus-chart {
975
- display: block;
976
- margin: 0 auto;
977
- /* Make sure the chart scales within its container. */
978
- max-width: 100%;
979
- }
980
-
981
- .sessions-list {
982
- width: 80%;
983
- margin: 20px auto;
984
- }
985
-
986
- .sessions-list h2 {
987
- color: #333;
988
- margin-bottom: 15px;
989
- }
990
-
991
- #sessions-table {
992
- width: 100%;
993
- background: white;
994
- border-collapse: collapse;
995
- border-radius: 12px;
996
- overflow: hidden;
997
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
998
- }
999
-
1000
- #sessions-table th {
1001
- background: #007BFF;
1002
- color: white;
1003
- padding: 15px;
1004
- text-align: left;
1005
- font-weight: 600;
1006
- }
1007
-
1008
- #sessions-table td {
1009
- padding: 12px 15px;
1010
- border-bottom: 1px solid #eee;
1011
- }
1012
-
1013
- #sessions-table tr:last-child td {
1014
- border-bottom: none;
1015
- }
1016
-
1017
- #sessions-table tbody tr:hover {
1018
- background: #f8f9fa;
1019
- }
1020
-
1021
- .btn-view {
1022
- padding: 6px 18px;
1023
- background: #007BFF;
1024
- color: white;
1025
- border: none;
1026
- border-radius: 999px;
1027
- cursor: pointer;
1028
- font-family: 'Nunito', sans-serif;
1029
- font-size: 12px;
1030
- font-weight: 700;
1031
- transition: background 0.2s;
1032
- }
1033
-
1034
- .btn-view:hover {
1035
- background: #0056b3;
1036
- }
1037
-
1038
- .records-detail-modal {
1039
- width: min(960px, 92vw);
1040
- max-width: 960px;
1041
- max-height: 86vh;
1042
- overflow-y: auto;
1043
- padding: 30px;
1044
- box-sizing: border-box;
1045
- }
1046
-
1047
- .records-detail-header {
1048
- display: flex;
1049
- justify-content: space-between;
1050
- align-items: flex-start;
1051
- gap: 20px;
1052
- margin-bottom: 24px;
1053
- }
1054
-
1055
- .records-detail-kicker {
1056
- color: #007BFF;
1057
- font-size: 12px;
1058
- font-weight: 800;
1059
- letter-spacing: 0.08em;
1060
- text-transform: uppercase;
1061
- }
1062
-
1063
- .records-detail-header h2 {
1064
- margin: 10px 0 8px;
1065
- color: #333;
1066
- text-align: left;
1067
- }
1068
-
1069
- .records-detail-subtitle {
1070
- margin: 0;
1071
- color: #667281;
1072
- line-height: 1.6;
1073
- }
1074
-
1075
- .records-detail-close {
1076
- border: 1px solid #d6e6fa;
1077
- background: #f4f9ff;
1078
- color: #3569a8;
1079
- border-radius: 999px;
1080
- padding: 10px 18px;
1081
- font-family: 'Nunito', sans-serif;
1082
- font-weight: 700;
1083
- cursor: pointer;
1084
- }
1085
-
1086
- .records-detail-close:hover {
1087
- border-color: #bfd9f7;
1088
- background: #e9f4ff;
1089
- }
1090
-
1091
- .records-detail-feedback {
1092
- padding: 18px 20px;
1093
- border-radius: 14px;
1094
- background: #f7f9fc;
1095
- color: #516173;
1096
- font-weight: 700;
1097
- }
1098
-
1099
- .records-detail-feedback-error {
1100
- background: #fff1ee;
1101
- color: #b54028;
1102
- }
1103
-
1104
- .records-detail-summary {
1105
- display: grid;
1106
- grid-template-columns: repeat(4, minmax(0, 1fr));
1107
- gap: 14px;
1108
- margin-bottom: 18px;
1109
- }
1110
-
1111
- .records-detail-stat {
1112
- padding: 18px;
1113
- border-radius: 14px;
1114
- background: #f8fbff;
1115
- border: 1px solid #d9eaff;
1116
- }
1117
-
1118
- .records-detail-stat.excellent {
1119
- background: #eef9f0;
1120
- border-color: #cdebd3;
1121
- }
1122
-
1123
- .records-detail-stat.good {
1124
- background: #fff9eb;
1125
- border-color: #f8e3a8;
1126
- }
1127
-
1128
- .records-detail-stat.fair {
1129
- background: #fff4eb;
1130
- border-color: #ffd6af;
1131
- }
1132
-
1133
- .records-detail-stat.low {
1134
- background: #fff0f0;
1135
- border-color: #f3c7c7;
1136
- }
1137
-
1138
- .records-detail-stat-label {
1139
- display: block;
1140
- margin-bottom: 8px;
1141
- color: #667281;
1142
- font-size: 13px;
1143
- font-weight: 700;
1144
- text-transform: uppercase;
1145
- letter-spacing: 0.04em;
1146
- }
1147
-
1148
- .records-detail-stat-value {
1149
- display: block;
1150
- color: #1f2d3d;
1151
- font-size: 28px;
1152
- line-height: 1.1;
1153
- }
1154
-
1155
- .records-detail-grid {
1156
- display: grid;
1157
- grid-template-columns: repeat(2, minmax(0, 1fr));
1158
- gap: 16px;
1159
- margin-bottom: 16px;
1160
- }
1161
-
1162
- .records-detail-card {
1163
- background: white;
1164
- border: 1px solid #e8eef5;
1165
- border-radius: 16px;
1166
- padding: 20px;
1167
- box-shadow: 0 8px 24px rgba(20, 44, 74, 0.06);
1168
- margin-bottom: 16px;
1169
- }
1170
-
1171
- .records-detail-card:last-child {
1172
- margin-bottom: 0;
1173
- }
1174
-
1175
- .records-detail-card h3 {
1176
- margin: 0 0 16px;
1177
- color: #333;
1178
- font-size: 18px;
1179
- }
1180
-
1181
- .records-detail-list {
1182
- display: grid;
1183
- grid-template-columns: repeat(2, minmax(0, 1fr));
1184
- gap: 14px 18px;
1185
- }
1186
-
1187
- .records-detail-item {
1188
- display: flex;
1189
- flex-direction: column;
1190
- gap: 6px;
1191
- }
1192
-
1193
- .records-detail-item-label {
1194
- color: #7a8795;
1195
- font-size: 12px;
1196
- font-weight: 700;
1197
- text-transform: uppercase;
1198
- letter-spacing: 0.05em;
1199
- }
1200
-
1201
- .records-detail-item-value {
1202
- color: #263445;
1203
- font-size: 15px;
1204
- font-weight: 700;
1205
- line-height: 1.5;
1206
- }
1207
-
1208
- .records-detail-section-head {
1209
- display: flex;
1210
- align-items: center;
1211
- justify-content: space-between;
1212
- gap: 12px;
1213
- margin-bottom: 16px;
1214
- }
1215
-
1216
- .records-detail-section-head span {
1217
- color: #7a8795;
1218
- font-size: 13px;
1219
- font-weight: 700;
1220
- }
1221
-
1222
- .records-detail-timeline {
1223
- display: grid;
1224
- grid-template-columns: repeat(auto-fit, minmax(10px, 1fr));
1225
- gap: 5px;
1226
- }
1227
-
1228
- .records-detail-segment {
1229
- height: 48px;
1230
- border-radius: 999px;
1231
- }
1232
-
1233
- .records-detail-segment.focused {
1234
- background: linear-gradient(180deg, #3ab86a 0%, #23a057 100%);
1235
- }
1236
-
1237
- .records-detail-segment.mixed {
1238
- background: linear-gradient(180deg, #f1b447 0%, #df9a1e 100%);
1239
- }
1240
-
1241
- .records-detail-segment.distracted {
1242
- background: linear-gradient(180deg, #ec7d7d 0%, #d9534f 100%);
1243
- }
1244
-
1245
- .records-detail-legend {
1246
- display: flex;
1247
- flex-wrap: wrap;
1248
- gap: 16px;
1249
- margin-top: 14px;
1250
- color: #667281;
1251
- font-size: 13px;
1252
- font-weight: 700;
1253
- }
1254
-
1255
- .records-detail-legend span {
1256
- display: inline-flex;
1257
- align-items: center;
1258
- gap: 8px;
1259
- }
1260
-
1261
- .records-detail-dot {
1262
- width: 10px;
1263
- height: 10px;
1264
- border-radius: 50%;
1265
- display: inline-block;
1266
- }
1267
-
1268
- .records-detail-dot.focused {
1269
- background: #23a057;
1270
- }
1271
-
1272
- .records-detail-dot.mixed {
1273
- background: #df9a1e;
1274
- }
1275
-
1276
- .records-detail-dot.distracted {
1277
- background: #d9534f;
1278
- }
1279
-
1280
- .records-detail-events {
1281
- display: grid;
1282
- gap: 10px;
1283
- max-height: 280px;
1284
- overflow-y: auto;
1285
- }
1286
-
1287
- .records-detail-event {
1288
- display: grid;
1289
- grid-template-columns: auto 1fr auto;
1290
- align-items: center;
1291
- gap: 12px;
1292
- padding: 12px 14px;
1293
- background: #f8fbff;
1294
- border: 1px solid #e1edf9;
1295
- border-radius: 14px;
1296
- }
1297
-
1298
- .records-detail-event-time {
1299
- min-width: 52px;
1300
- color: #3569a8;
1301
- font-size: 13px;
1302
- font-weight: 800;
1303
- }
1304
-
1305
- .records-detail-event-copy {
1306
- min-width: 0;
1307
- }
1308
-
1309
- .records-detail-event-status {
1310
- color: #243345;
1311
- font-size: 14px;
1312
- font-weight: 800;
1313
- }
1314
-
1315
- .records-detail-event-meta {
1316
- margin-top: 4px;
1317
- color: #6f7d8c;
1318
- font-size: 12px;
1319
- line-height: 1.5;
1320
- }
1321
-
1322
- .records-detail-event-badge {
1323
- padding: 7px 12px;
1324
- border-radius: 999px;
1325
- font-size: 11px;
1326
- font-weight: 800;
1327
- letter-spacing: 0.04em;
1328
- text-transform: uppercase;
1329
- }
1330
-
1331
- .records-detail-event-badge.focused {
1332
- background: #eaf8ef;
1333
- color: #1f8a4c;
1334
- }
1335
-
1336
- .records-detail-event-badge.distracted {
1337
- background: #fff1f1;
1338
- color: #c24c49;
1339
- }
1340
-
1341
- .records-detail-empty {
1342
- padding: 16px 18px;
1343
- border-radius: 14px;
1344
- background: #f7f9fc;
1345
- color: #708090;
1346
- font-weight: 700;
1347
- }
1348
-
1349
- /* ================ SETTINGS PAGE ================ */
1350
-
1351
- .settings-container {
1352
- width: 60%;
1353
- max-width: 800px;
1354
- margin: 20px auto;
1355
- }
1356
-
1357
- .setting-group {
1358
- background: white;
1359
- padding: 30px;
1360
- border-radius: 12px;
1361
- margin-bottom: 20px;
1362
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1363
- }
1364
-
1365
- .setting-group h2 {
1366
- margin-top: 0;
1367
- color: #333;
1368
- font-size: 20px;
1369
- margin-bottom: 20px;
1370
- border-bottom: 2px solid #007BFF;
1371
- padding-bottom: 10px;
1372
- }
1373
-
1374
- .setting-item {
1375
- margin-bottom: 25px;
1376
- }
1377
-
1378
- .setting-item:last-child {
1379
- margin-bottom: 0;
1380
- }
1381
-
1382
- .setting-item label {
1383
- display: block;
1384
- margin-bottom: 8px;
1385
- color: #333;
1386
- font-weight: 600;
1387
- }
1388
-
1389
- .slider-group {
1390
- display: flex;
1391
- align-items: center;
1392
- gap: 15px;
1393
- }
1394
-
1395
- .slider-group input[type="range"] {
1396
- flex: 1;
1397
- }
1398
-
1399
- .slider-group span {
1400
- min-width: 40px;
1401
- text-align: center;
1402
- font-weight: bold;
1403
- color: #007BFF;
1404
- font-size: 18px;
1405
- }
1406
-
1407
- .setting-description {
1408
- font-size: 14px;
1409
- color: #666;
1410
- margin-top: 5px;
1411
- font-style: italic;
1412
- }
1413
-
1414
- input[type="checkbox"] {
1415
- margin-right: 10px;
1416
- cursor: pointer;
1417
- }
1418
-
1419
- input[type="number"] {
1420
- width: 100px;
1421
- padding: 8px;
1422
- border: 1px solid #ccc;
1423
- border-radius: 5px;
1424
- font-family: 'Nunito', sans-serif;
1425
- }
1426
-
1427
- /* Center the settings buttons and give them more width. */
1428
- .setting-group .action-btn {
1429
- display: inline-block; /* Allow buttons to sit side by side. */
1430
- width: 48%; /* Roughly half-width each, with a small gutter. */
1431
- margin: 15px 1%; /* Vertical spacing plus horizontal separation. */
1432
- text-align: center; /* Center the label text. */
1433
- box-sizing: border-box; /* Prevent borders from forcing an early wrap. */
1434
- }
1435
-
1436
- #save-settings {
1437
- display: block;
1438
- margin: 20px auto;
1439
- }
1440
-
1441
- /* ================ HELP PAGE ================ */
1442
-
1443
- .help-container {
1444
- width: 70%;
1445
- max-width: 900px;
1446
- margin: 20px auto;
1447
- }
1448
-
1449
- /* Fake ad block (Help page) */
1450
- .fake-ad {
1451
- position: relative;
1452
- display: block;
1453
- width: min(600px, 90%);
1454
- margin: 10px auto 30px auto;
1455
- border: 1px solid #e5e5e5;
1456
- border-radius: 12px;
1457
- overflow: hidden;
1458
- background: #fff;
1459
- text-decoration: none;
1460
- box-shadow: 0 8px 24px rgba(0,0,0,0.12);
1461
- transition: transform 0.2s ease, box-shadow 0.2s ease;
1462
- }
1463
-
1464
- .fake-ad:hover {
1465
- transform: translateY(-2px);
1466
- box-shadow: 0 12px 30px rgba(0,0,0,0.16);
1467
- }
1468
-
1469
- .fake-ad img {
1470
- display: block;
1471
- width: 100%;
1472
- height: auto;
1473
- }
1474
-
1475
- .fake-ad-badge {
1476
- position: absolute;
1477
- top: 12px;
1478
- left: 12px;
1479
- background: rgba(0,0,0,0.75);
1480
- color: #fff;
1481
- font-size: 12px;
1482
- padding: 4px 8px;
1483
- border-radius: 6px;
1484
- letter-spacing: 0.5px;
1485
- }
1486
-
1487
- .fake-ad-cta {
1488
- position: absolute;
1489
- right: 12px;
1490
- bottom: 12px;
1491
- background: #111;
1492
- color: #fff;
1493
- font-size: 14px;
1494
- padding: 8px 12px;
1495
- border-radius: 8px;
1496
- }
1497
-
1498
- .help-section {
1499
- background: white;
1500
- padding: 30px;
1501
- border-radius: 12px;
1502
- margin-bottom: 20px;
1503
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1504
- }
1505
-
1506
- .help-section h2 {
1507
- color: #007BFF;
1508
- margin-top: 0;
1509
- margin-bottom: 15px;
1510
- }
1511
-
1512
- .help-section ol,
1513
- .help-section ul {
1514
- line-height: 1.8;
1515
- color: #333;
1516
- }
1517
-
1518
- .help-section p {
1519
- line-height: 1.6;
1520
- color: #333;
1521
- }
1522
-
1523
- details {
1524
- margin: 15px 0;
1525
- cursor: pointer;
1526
- padding: 10px;
1527
- background: #f8f9fa;
1528
- border-radius: 5px;
1529
- }
1530
-
1531
- summary {
1532
- font-weight: bold;
1533
- padding: 5px;
1534
- color: #007BFF;
1535
- }
1536
-
1537
- details[open] summary {
1538
- margin-bottom: 10px;
1539
- border-bottom: 1px solid #ddd;
1540
- padding-bottom: 10px;
1541
- }
1542
-
1543
- details p {
1544
- margin: 10px 0 0 0;
1545
- }
1546
-
1547
- /* ================ SESSION SUMMARY MODAL ================ */
1548
- /* These modal styles can be reused for future overlays. */
1549
- .modal-overlay {
1550
- position: fixed;
1551
- top: 0;
1552
- left: 0;
1553
- width: 100%;
1554
- height: 100%;
1555
- background: rgba(0, 0, 0, 0.7);
1556
- display: flex;
1557
- align-items: center;
1558
- justify-content: center;
1559
- z-index: 2000;
1560
- }
1561
-
1562
- .modal-content {
1563
- background: white;
1564
- padding: 40px;
1565
- border-radius: 16px;
1566
- box-shadow: 0 10px 40px rgba(0,0,0,0.3);
1567
- max-width: 500px;
1568
- width: 90%;
1569
- }
1570
-
1571
- .modal-content h2 {
1572
- margin-top: 0;
1573
- color: #333;
1574
- text-align: center;
1575
- margin-bottom: 30px;
1576
- }
1577
-
1578
- .summary-stats {
1579
- margin-bottom: 30px;
1580
- }
1581
-
1582
- .summary-item {
1583
- display: flex;
1584
- justify-content: space-between;
1585
- padding: 15px 0;
1586
- border-bottom: 1px solid #eee;
1587
- }
1588
-
1589
- .summary-item:last-child {
1590
- border-bottom: none;
1591
- }
1592
-
1593
- .summary-label {
1594
- font-weight: 600;
1595
- color: #666;
1596
- }
1597
-
1598
- .summary-value {
1599
- font-weight: bold;
1600
- color: #007BFF;
1601
- font-size: 18px;
1602
- }
1603
-
1604
- .modal-content .btn-main {
1605
- display: block;
1606
- margin: 0 auto;
1607
- padding: 12px 40px;
1608
- }
1609
-
1610
- /* ================ TIMELINE BLOCKS ================ */
1611
-
1612
- .timeline-block {
1613
- transition: opacity 0.2s;
1614
- border-radius: 2px;
1615
- }
1616
-
1617
- .timeline-block:hover {
1618
- opacity: 0.7;
1619
- }
1620
-
1621
- /* ================ RESPONSIVE DESIGN ================ */
1622
-
1623
- @media (max-width: 1200px) {
1624
- .stats-grid {
1625
- grid-template-columns: repeat(2, 1fr);
1626
- }
1627
-
1628
- .badges-grid {
1629
- grid-template-columns: repeat(2, 1fr);
1630
- }
1631
- }
1632
-
1633
- @media (max-width: 768px) {
1634
- .stats-grid,
1635
- .badges-grid {
1636
- grid-template-columns: 1fr;
1637
- width: 90%;
1638
- }
1639
-
1640
- .settings-container,
1641
- .help-container,
1642
- .chart-container,
1643
- .sessions-list,
1644
- .records-controls {
1645
- width: 90%;
1646
- }
1647
-
1648
- #control-panel {
1649
- width: 90%;
1650
- flex-wrap: wrap;
1651
- }
1652
-
1653
- #display-area {
1654
- width: 90%;
1655
- }
1656
-
1657
- #timeline-area {
1658
- width: 90%;
1659
- }
1660
-
1661
- #frame-control {
1662
- width: 90%;
1663
- flex-direction: column;
1664
- }
1665
-
1666
- .focus-inline-error-standalone {
1667
- width: 90%;
1668
- }
1669
-
1670
- .focus-flow-overlay {
1671
- top: 70px;
1672
- right: 10px;
1673
- bottom: 10px;
1674
- left: 10px;
1675
- }
1676
-
1677
- .focus-flow-card {
1678
- padding: 22px 20px;
1679
- }
1680
-
1681
- .focus-flow-header {
1682
- flex-direction: column;
1683
- align-items: flex-start;
1684
- }
1685
-
1686
- .focus-flow-icon {
1687
- width: 92px;
1688
- height: 92px;
1689
- }
1690
-
1691
- .focus-flow-grid {
1692
- grid-template-columns: 1fr;
1693
- }
1694
-
1695
- .focus-flow-steps {
1696
- grid-template-columns: 1fr;
1697
- }
1698
-
1699
- .focus-flow-footer {
1700
- flex-direction: column;
1701
- align-items: stretch;
1702
- }
1703
-
1704
- .focus-flow-button,
1705
- .focus-flow-secondary {
1706
- width: 100%;
1707
- }
1708
-
1709
- .records-detail-modal {
1710
- width: 94vw;
1711
- padding: 22px 18px;
1712
- }
1713
-
1714
- .records-detail-header,
1715
- .records-detail-section-head {
1716
- flex-direction: column;
1717
- align-items: flex-start;
1718
- }
1719
-
1720
- .records-detail-summary,
1721
- .records-detail-grid,
1722
- .records-detail-list {
1723
- grid-template-columns: 1fr;
1724
- }
1725
-
1726
- .records-detail-event {
1727
- grid-template-columns: 1fr;
1728
- align-items: flex-start;
1729
- }
1730
- }
1731
- /* =========================================
1732
- SESSION RESULT OVERLAY
1733
- ========================================= */
1734
-
1735
- .session-result-overlay {
1736
- position: absolute;
1737
- top: 0;
1738
- left: 0;
1739
- width: 100%;
1740
- height: 100%;
1741
- background-color: rgba(0, 0, 0, 0.85); /* Dark semi-transparent backdrop. */
1742
- display: flex;
1743
- flex-direction: column;
1744
- justify-content: center;
1745
- align-items: center;
1746
- color: white;
1747
- z-index: 10;
1748
- animation: fadeIn 0.5s ease;
1749
- backdrop-filter: blur(5px); /* Optional background blur. */
1750
- }
1751
-
1752
- .session-result-overlay h3 {
1753
- font-size: 32px;
1754
- margin-bottom: 30px;
1755
- color: #4cd137; /* Green title accent. */
1756
- text-transform: uppercase;
1757
- letter-spacing: 2px;
1758
- }
1759
-
1760
- .session-result-overlay .result-item {
1761
- display: flex;
1762
- justify-content: space-between;
1763
- width: 200px; /* Keep the stat row compact. */
1764
- margin-bottom: 15px;
1765
- font-size: 20px;
1766
- border-bottom: 1px solid rgba(255,255,255,0.2);
1767
- padding-bottom: 5px;
1768
- }
1769
-
1770
- .session-result-overlay .label {
1771
- color: #ccc;
1772
- font-weight: normal;
1773
- }
1774
-
1775
- .session-result-overlay .value {
1776
- color: #fff;
1777
- font-weight: bold;
1778
- font-family: 'Courier New', monospace; /* Give the values a data-like look. */
1779
- }
1780
-
1781
- @keyframes fadeIn {
1782
- from { opacity: 0; transform: scale(0.95); }
1783
- to { opacity: 1; transform: scale(1); }
1784
- }
1785
-
1786
- /* ================= Welcome modal styles ================= */
1787
- .welcome-modal-overlay {
1788
- position: fixed;
1789
- top: 0; left: 0; right: 0; bottom: 0;
1790
- background-color: rgba(0, 0, 0, 0.7);
1791
- display: flex;
1792
- justify-content: center;
1793
- align-items: center;
1794
- z-index: 9999;
1795
- }
1796
-
1797
- .welcome-modal {
1798
- background-color: #1e1e24;
1799
- padding: 40px;
1800
- border-radius: 15px;
1801
- text-align: center;
1802
- box-shadow: 0 10px 30px rgba(0,0,0,0.5);
1803
- border: 1px solid #333;
1804
- }
1805
-
1806
- .welcome-modal h2 { margin-top: 0; color: #fff; }
1807
- .welcome-modal p { margin-bottom: 30px; color: #ccc; }
1808
- .welcome-buttons { display: flex; gap: 20px; justify-content: center; }
1809
-
1810
- /* ================= Top-left avatar styles ================= */
1811
- .avatar-container {
1812
- position: absolute;
1813
- left: 20px;
1814
- cursor: pointer;
1815
- z-index: 1;
1816
- }
1817
-
1818
- .avatar-circle {
1819
- width: 40px;
1820
- height: 40px;
1821
- border-radius: 50%;
1822
- display: flex;
1823
- justify-content: center;
1824
- align-items: center;
1825
- font-weight: bold;
1826
- font-size: 1.2rem;
1827
- color: white;
1828
- transition: all 0.3s ease;
1829
- border: 2px solid transparent;
1830
- }
1831
-
1832
- .avatar-circle.user { background-color: #555; }
1833
- .avatar-circle.admin { background-color: #ffaa00; border-color: #fff; box-shadow: 0 0 10px rgba(255, 170, 0, 0.5); }
1834
-
1835
- /* ================ CALIBRATION OVERLAY ================ */
1836
-
1837
- .cal-overlay {
1838
- position: fixed;
1839
- top: 0;
1840
- left: 0;
1841
- width: 100vw;
1842
- height: 100vh;
1843
- background: rgba(8, 15, 28, 0.94);
1844
- backdrop-filter: blur(6px);
1845
- z-index: 10000;
1846
- display: flex;
1847
- align-items: center;
1848
- justify-content: center;
1849
- font-family: 'Nunito', sans-serif;
1850
- }
1851
-
1852
- /* ---- header / instructions ---- */
1853
- .cal-header {
1854
- position: absolute;
1855
- top: 36px;
1856
- left: 50%;
1857
- transform: translateX(-50%);
1858
- text-align: center;
1859
- pointer-events: none;
1860
- }
1861
-
1862
- .cal-eyebrow {
1863
- display: inline-block;
1864
- padding: 6px 14px;
1865
- border-radius: 999px;
1866
- font-size: 0.82rem;
1867
- font-weight: 800;
1868
- letter-spacing: 0.04em;
1869
- text-transform: uppercase;
1870
- }
1871
-
1872
- .cal-eyebrow-collect {
1873
- background: rgba(40, 167, 69, 0.18);
1874
- color: #5ee882;
1875
- }
1876
-
1877
- .cal-eyebrow-verify {
1878
- background: rgba(0, 123, 255, 0.18);
1879
- color: #6bb8ff;
1880
- }
1881
-
1882
- .cal-instruction {
1883
- margin: 10px 0 0;
1884
- color: rgba(255, 255, 255, 0.7);
1885
- font-size: 0.95rem;
1886
- line-height: 1.5;
1887
- }
1888
-
1889
- /* ---- target dot + ring ---- */
1890
- .cal-target {
1891
- position: absolute;
1892
- transform: translate(-50%, -50%);
1893
- }
1894
-
1895
- .cal-ring {
1896
- position: absolute;
1897
- left: -30px;
1898
- top: -30px;
1899
- }
1900
-
1901
- .cal-dot {
1902
- width: 20px;
1903
- height: 20px;
1904
- border-radius: 50%;
1905
- transition: box-shadow 0.3s ease;
1906
- }
1907
-
1908
- /* ---- cancel button (matches focus-flow-secondary) ---- */
1909
- .cal-cancel {
1910
- position: absolute;
1911
- bottom: 40px;
1912
- left: 50%;
1913
- transform: translateX(-50%);
1914
- border: 1px solid rgba(255, 255, 255, 0.25);
1915
- border-radius: 999px;
1916
- padding: 12px 28px;
1917
- background: rgba(255, 255, 255, 0.08);
1918
- color: rgba(255, 255, 255, 0.85);
1919
- font-family: 'Nunito', sans-serif;
1920
- font-size: 0.95rem;
1921
- font-weight: 700;
1922
- cursor: pointer;
1923
- transition: background 0.2s ease, border-color 0.2s ease;
1924
- }
1925
-
1926
- .cal-cancel:hover {
1927
- background: rgba(255, 255, 255, 0.14);
1928
- border-color: rgba(255, 255, 255, 0.4);
1929
- }
1930
-
1931
- /* ---- done card (matches focus-flow-card style) ---- */
1932
- .cal-done-card {
1933
- text-align: center;
1934
- padding: 36px 44px;
1935
- border-radius: 20px;
1936
- border: 1px solid rgba(255, 255, 255, 0.08);
1937
- box-shadow: 0 28px 80px rgba(0, 0, 0, 0.4);
1938
- animation: fadeIn 0.4s ease;
1939
- }
1940
-
1941
- .cal-done-success {
1942
- background: linear-gradient(168deg, rgba(40, 167, 69, 0.15) 0%, rgba(20, 30, 48, 0.95) 60%);
1943
- border-color: rgba(40, 167, 69, 0.3);
1944
- }
1945
-
1946
- .cal-done-fail {
1947
- background: linear-gradient(168deg, rgba(220, 53, 69, 0.15) 0%, rgba(20, 30, 48, 0.95) 60%);
1948
- border-color: rgba(220, 53, 69, 0.3);
1949
- }
1950
-
1951
- .cal-done-eyebrow {
1952
- display: inline-block;
1953
- padding: 6px 14px;
1954
- border-radius: 999px;
1955
- font-size: 0.78rem;
1956
- font-weight: 800;
1957
- letter-spacing: 0.06em;
1958
- text-transform: uppercase;
1959
- margin-bottom: 14px;
1960
- }
1961
-
1962
- .cal-done-success .cal-done-eyebrow {
1963
- background: rgba(40, 167, 69, 0.2);
1964
- color: #5ee882;
1965
- }
1966
-
1967
- .cal-done-fail .cal-done-eyebrow {
1968
- background: rgba(220, 53, 69, 0.2);
1969
- color: #f87171;
1970
- }
1971
-
1972
- .cal-done-title {
1973
- margin: 0 0 8px;
1974
- font-size: 1.6rem;
1975
- color: #fff;
1976
- }
1977
-
1978
- .cal-done-subtitle {
1979
- margin: 0;
1980
- color: rgba(255, 255, 255, 0.6);
1981
- font-size: 0.95rem;
1982
- line-height: 1.5;
1983
- }
1984
- /* ================= Home page 2x2 responsive button grid ================= */
1985
- .home-button-grid {
1986
- display: flex;
1987
- justify-content: center;
1988
- width: 100%;
1989
- max-width: 360px;
1990
- margin: 40px auto 0;
1991
- }
1992
-
1993
- .home-button-grid .btn-main {
1994
- width: 100%;
1995
- height: 60px; /* Keep all tiles at the same height. */
1996
- margin: 0; /* Remove default outer spacing. */
1997
- padding: 10px;
1998
- font-size: 1rem;
1999
- display: flex;
2000
- justify-content: center;
2001
- align-items: center;
2002
- text-align: center;
2003
- box-sizing: border-box; /* Prevent padding and borders from breaking the grid. */
2004
- }
2005
-
2006
- /* Mobile-only scaling for screens below 600px. */
2007
- @media (max-width: 600px) {
2008
- #top-menu {
2009
- justify-content: flex-start;
2010
- padding: 0 12px 0 68px;
2011
- }
2012
-
2013
- .menu-btn {
2014
- padding: 10px 14px;
2015
- font-size: 0.92rem;
2016
- }
2017
-
2018
- .separator {
2019
- margin: 0 2px;
2020
- }
2021
-
2022
- .home-button-grid {
2023
- gap: 15px;
2024
- max-width: 90%;
2025
- }
2026
-
2027
- .home-button-grid .btn-main {
2028
- height: 50px;
2029
- font-size: 0.85rem;
2030
- }
2031
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================
2
+ 1. REACT layout setting
3
+ ========================================= */
4
+ html, body, #root {
5
+ width: 100%;
6
+ height: 100%;
7
+ margin: 0;
8
+ padding: 0;
9
+ }
10
+
11
+ .app-container {
12
+ width: 100%;
13
+ min-height: 100vh; /* screen height */
14
+ display: flex;
15
+ flex-direction: column;
16
+ background-color: #f9f9f9;
17
+ }
18
+
19
+ /* =========================================
20
+ 2. original layout
21
+ ========================================= */
22
+
23
+ /* GLOBAL STYLES */
24
+ body {
25
+ font-family: 'Nunito', sans-serif;
26
+ background-color: #f9f9f9;
27
+ overflow-x: hidden;
28
+ overflow-y: auto;
29
+ }
30
+
31
+ /* dynamic class name */
32
+ .hidden {
33
+ display: none !important;
34
+ }
35
+
36
+ /* TOP MENU */
37
+ #top-menu {
38
+ height: 60px;
39
+ background-color: white;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: flex-start;
43
+ gap: 0;
44
+ padding: 0 16px 0 20px;
45
+ box-sizing: border-box;
46
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
47
+ position: fixed;
48
+ top: 0;
49
+ left: 0;
50
+ right: 0;
51
+ width: 100%;
52
+ z-index: 1000;
53
+ overflow-x: auto;
54
+ overflow-y: hidden;
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .top-menu-links {
59
+ flex: 1;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ flex-wrap: wrap;
64
+ gap: 0;
65
+ min-width: 0;
66
+ }
67
+
68
+ .menu-btn {
69
+ background: none;
70
+ border: none;
71
+ font-family: 'Nunito', sans-serif;
72
+ font-size: 16px;
73
+ color: #333;
74
+ padding: 10px 20px;
75
+ cursor: pointer;
76
+ transition: background-color 0.2s;
77
+ }
78
+
79
+ .menu-btn:hover {
80
+ background-color: #f0f0f0;
81
+ border-radius: 4px;
82
+ }
83
+
84
+ /* active for React */
85
+ .menu-btn.active {
86
+ font-weight: bold;
87
+ color: #007BFF;
88
+ background-color: #eef7ff;
89
+ border-radius: 4px;
90
+ }
91
+
92
+ .separator {
93
+ width: 1px;
94
+ height: 20px;
95
+ background-color: #555; /* Dark gray separator */
96
+ margin: 0 5px;
97
+ }
98
+
99
+ /* PAGE CONTAINER */
100
+ .page {
101
+ /* content under menu */
102
+ min-height: calc(100vh - 60px);
103
+ width: 100%;
104
+ padding-top: 60px; /* Space for fixed menu */
105
+ padding-bottom: 40px; /* Space at bottom for scrolling */
106
+ box-sizing: border-box;
107
+ display: flex;
108
+ flex-direction: column;
109
+ align-items: center;
110
+ overflow-y: auto;
111
+ }
112
+
113
+ /* Ensure page titles are black */
114
+ .page h1 {
115
+ color: #000 !important;
116
+ background: transparent !important;
117
+ }
118
+
119
+ .page-title {
120
+ color: #000 !important;
121
+ background: transparent !important;
122
+ }
123
+
124
+ /* PAGE A SPECIFIC */
125
+ #page-a {
126
+ justify-content: center; /* Center vertically */
127
+ /* Fine-tune this margin if the Home screen sits slightly too low. */
128
+ margin-top: -40px;
129
+ flex: 1; /* Fill the remaining height so vertical centering still works. */
130
+ }
131
+
132
+ #page-a h1 {
133
+ font-size: 80px;
134
+ margin: 0 0 10px 0;
135
+ color: #000;
136
+ text-align: center; /* Keep the heading centered. */
137
+ }
138
+
139
+ #page-a p {
140
+ color: #666;
141
+ font-size: 20px;
142
+ margin-bottom: 40px;
143
+ text-align: center;
144
+ }
145
+
146
+ .btn-main {
147
+ background-color: #007BFF; /* Blue */
148
+ color: white;
149
+ border: none;
150
+ padding: 15px 50px;
151
+ font-size: 20px;
152
+ font-family: 'Nunito', sans-serif;
153
+ border-radius: 30px; /* Fully rounded corners */
154
+ cursor: pointer;
155
+ transition: transform 0.2s ease;
156
+ }
157
+
158
+ .btn-main:hover {
159
+ transform: scale(1.1); /* Zoom effect */
160
+ }
161
+
162
+ /* PAGE B SPECIFIC */
163
+ #page-b {
164
+ justify-content: space-evenly; /* Distribute vertical space */
165
+ padding-bottom: 20px;
166
+ min-height: calc(100vh - 60px); /* Ensure the page still fills the viewport. */
167
+ }
168
+
169
+ /* 1. Display Area */
170
+ #display-area {
171
+ width: 60%;
172
+ height: 50vh; /* Use viewport height to scale more consistently across screens. */
173
+ min-height: 300px;
174
+ border: 2px solid #ddd;
175
+ border-radius: 12px;
176
+ background-color: #fff;
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ color: #555;
181
+ font-size: 24px;
182
+ position: relative;
183
+ /* Keep video content centered without overflowing the frame. */
184
+ overflow: hidden;
185
+ }
186
+
187
+ .focus-display-shell {
188
+ background: #101010;
189
+ }
190
+
191
+ @keyframes fadeInOverlay {
192
+ from { opacity: 0; }
193
+ to { opacity: 1; }
194
+ }
195
+
196
+ @keyframes slideUpCard {
197
+ from { opacity: 0; transform: translateY(30px); }
198
+ to { opacity: 1; transform: translateY(0); }
199
+ }
200
+
201
+
202
+ .focus-flow-overlay {
203
+ position: fixed;
204
+ top: 76px;
205
+ right: 20px;
206
+ bottom: 20px;
207
+ left: 20px;
208
+ display: flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ padding: 0;
212
+ background: rgba(17, 31, 52, 0.18);
213
+ backdrop-filter: blur(10px);
214
+ z-index: 900;
215
+ animation: fadeInOverlay 0.3s ease-out forwards;
216
+ }
217
+
218
+ .focus-flow-card {
219
+ width: min(1040px, 100%);
220
+ background: #fff;
221
+ border-radius: 24px;
222
+ padding: 30px 34px;
223
+ box-shadow: 0 28px 80px rgba(14, 44, 88, 0.18);
224
+ border: 1px solid rgba(0, 123, 255, 0.12);
225
+ box-sizing: border-box;
226
+ animation: slideUpCard 0.4s ease-out forwards;
227
+ }
228
+
229
+ .focus-flow-header {
230
+ display: flex;
231
+ align-items: center;
232
+ justify-content: space-between;
233
+ gap: 24px;
234
+ margin-bottom: 18px;
235
+ }
236
+
237
+ .focus-flow-eyebrow {
238
+ display: inline-block;
239
+ padding: 6px 12px;
240
+ border-radius: 999px;
241
+ background: #e7f3ff;
242
+ color: #007BFF;
243
+ font-size: 0.82rem;
244
+ font-weight: 800;
245
+ letter-spacing: 0.04em;
246
+ text-transform: uppercase;
247
+ }
248
+
249
+ .focus-flow-header h2 {
250
+ margin: 14px 0 0;
251
+ color: #333;
252
+ font-size: clamp(1.8rem, 2.5vw, 2.5rem);
253
+ line-height: 1.1;
254
+ }
255
+
256
+ .focus-flow-icon {
257
+ flex: 0 0 auto;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ width: 116px;
262
+ height: 116px;
263
+ border-radius: 24px;
264
+ background: linear-gradient(180deg, #f4f9ff 0%, #edf5ff 100%);
265
+ border: 1px solid rgba(0, 123, 255, 0.12);
266
+ }
267
+
268
+ .focus-flow-lead {
269
+ margin: 0 0 20px;
270
+ color: #4a4a4a;
271
+ font-size: 1rem;
272
+ line-height: 1.6;
273
+ }
274
+
275
+ .focus-flow-grid {
276
+ display: grid;
277
+ grid-template-columns: repeat(3, minmax(0, 1fr));
278
+ gap: 16px;
279
+ }
280
+
281
+ .focus-flow-panel {
282
+ background: #f8fbff;
283
+ border: 1px solid #d9eaff;
284
+ border-radius: 14px;
285
+ padding: 18px;
286
+ }
287
+
288
+ .focus-flow-panel h3,
289
+ .focus-flow-step-copy h3 {
290
+ margin: 0 0 8px;
291
+ color: #333;
292
+ font-size: 1rem;
293
+ }
294
+
295
+ .focus-flow-panel p,
296
+ .focus-flow-step-copy p {
297
+ margin: 0;
298
+ color: #5e6670;
299
+ font-size: 0.95rem;
300
+ line-height: 1.6;
301
+ }
302
+
303
+ .focus-flow-steps {
304
+ display: grid;
305
+ grid-template-columns: repeat(3, minmax(0, 1fr));
306
+ gap: 14px;
307
+ }
308
+
309
+ .focus-flow-step {
310
+ display: flex;
311
+ align-items: flex-start;
312
+ gap: 14px;
313
+ background: #f8fbff;
314
+ border: 1px solid #d9eaff;
315
+ border-radius: 14px;
316
+ padding: 16px 18px;
317
+ min-height: 100px;
318
+ box-sizing: border-box;
319
+ }
320
+
321
+ .focus-flow-step-number {
322
+ flex: 0 0 auto;
323
+ display: inline-flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ width: 34px;
327
+ height: 34px;
328
+ border-radius: 50%;
329
+ background: #007BFF;
330
+ color: #fff;
331
+ font-size: 0.95rem;
332
+ font-weight: 800;
333
+ }
334
+
335
+ .focus-flow-step-copy {
336
+ min-width: 0;
337
+ }
338
+
339
+ .focus-flow-footer {
340
+ display: flex;
341
+ align-items: center;
342
+ justify-content: space-between;
343
+ gap: 16px;
344
+ margin-top: 20px;
345
+ }
346
+
347
+ .focus-flow-note {
348
+ color: #667281;
349
+ font-size: 0.94rem;
350
+ line-height: 1.6;
351
+ }
352
+
353
+ .focus-flow-glasses-note {
354
+ background: #fffbea;
355
+ border: 1px solid #f5c518;
356
+ border-radius: 10px;
357
+ padding: 12px 16px;
358
+ font-size: 0.9rem;
359
+ color: #5a4a00;
360
+ line-height: 1.55;
361
+ margin-top: 4px;
362
+ margin-bottom: 4px;
363
+ }
364
+
365
+ .focus-flow-panel-warn {
366
+ border-left: 3px solid #f5a623;
367
+ background: #fff9f0;
368
+ }
369
+
370
+ .eye-gaze-modal-checkbox {
371
+ display: flex;
372
+ align-items: center;
373
+ gap: 8px;
374
+ margin-top: 16px;
375
+ font-size: 0.9rem;
376
+ color: #667281;
377
+ cursor: pointer;
378
+ user-select: none;
379
+ }
380
+
381
+ .focus-flow-button,
382
+ .focus-flow-secondary {
383
+ border: none;
384
+ border-radius: 999px;
385
+ padding: 13px 24px;
386
+ font-family: 'Nunito', sans-serif;
387
+ font-size: 0.98rem;
388
+ font-weight: 800;
389
+ cursor: pointer;
390
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
391
+ }
392
+
393
+ .focus-flow-button {
394
+ background: #007BFF;
395
+ color: #fff;
396
+ box-shadow: 0 12px 24px rgba(0, 123, 255, 0.18);
397
+ }
398
+
399
+ .focus-flow-button:hover {
400
+ background: #0069d9;
401
+ border-color: transparent;
402
+ transform: translateY(-1px);
403
+ }
404
+
405
+ .focus-flow-secondary {
406
+ background: #eef3f8;
407
+ color: #4b5a6b;
408
+ }
409
+
410
+ .focus-flow-secondary:hover {
411
+ background: #e2eaf3;
412
+ border-color: transparent;
413
+ }
414
+
415
+ .focus-state-pill {
416
+ position: absolute;
417
+ top: 18px;
418
+ left: 18px;
419
+ display: inline-flex;
420
+ align-items: center;
421
+ gap: 10px;
422
+ padding: 10px 16px;
423
+ border-radius: 999px;
424
+ color: #fff;
425
+ font-size: 0.88rem;
426
+ font-weight: 800;
427
+ letter-spacing: 0.04em;
428
+ text-transform: uppercase;
429
+ z-index: 2;
430
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
431
+ }
432
+
433
+ .focus-state-pill.pending {
434
+ background: rgba(87, 96, 111, 0.92);
435
+ }
436
+
437
+ .focus-state-pill.focused {
438
+ background: rgba(33, 163, 102, 0.94);
439
+ }
440
+
441
+ .focus-state-pill.not-focused {
442
+ background: rgba(215, 68, 68, 0.94);
443
+ }
444
+
445
+ .focus-state-dot {
446
+ width: 10px;
447
+ height: 10px;
448
+ border-radius: 50%;
449
+ background: currentColor;
450
+ box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.16);
451
+ }
452
+
453
+ .focus-idle-overlay {
454
+ position: absolute;
455
+ inset: 0;
456
+ display: flex;
457
+ flex-direction: column;
458
+ align-items: center;
459
+ justify-content: center;
460
+ gap: 10px;
461
+ background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.72));
462
+ color: #fff;
463
+ text-align: center;
464
+ z-index: 1;
465
+ }
466
+
467
+ .focus-idle-overlay p {
468
+ margin: 0;
469
+ font-size: 1.6rem;
470
+ font-weight: 800;
471
+ }
472
+
473
+ .focus-idle-overlay span {
474
+ max-width: 420px;
475
+ color: rgba(255, 255, 255, 0.82);
476
+ font-size: 0.98rem;
477
+ line-height: 1.5;
478
+ }
479
+
480
+ .focus-inline-error {
481
+ margin-top: 18px;
482
+ padding: 12px 16px;
483
+ max-width: 620px;
484
+ border-radius: 12px;
485
+ background: #fff1ee;
486
+ color: #b54028;
487
+ font-size: 0.95rem;
488
+ font-weight: 700;
489
+ box-shadow: 0 10px 20px rgba(181, 64, 40, 0.08);
490
+ }
491
+
492
+ .focus-inline-error-standalone {
493
+ width: 60%;
494
+ box-sizing: border-box;
495
+ }
496
+
497
+ .focus-debug-panel {
498
+ position: absolute;
499
+ top: 10px;
500
+ right: 10px;
501
+ background: rgba(0,0,0,0.7);
502
+ color: white;
503
+ padding: 10px;
504
+ border-radius: 5px;
505
+ font-size: 12px;
506
+ font-family: monospace;
507
+ }
508
+
509
+ .focus-model-strip {
510
+ display: flex;
511
+ align-items: center;
512
+ justify-content: center;
513
+ flex-wrap: wrap;
514
+ gap: 8px;
515
+ padding: 10px 16px;
516
+ background: #fff;
517
+ border: 1px solid #e0e0e0;
518
+ border-radius: 12px;
519
+ margin: 10px auto;
520
+ max-width: 700px;
521
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
522
+ }
523
+
524
+ /* --- Model info card --- */
525
+ .model-card {
526
+ width: 60%;
527
+ margin: 14px auto 0;
528
+ background: #fff;
529
+ border: 1px solid #e0e0e0;
530
+ border-radius: 14px;
531
+ padding: 18px 22px 14px;
532
+ box-shadow: 0 2px 10px rgba(0,0,0,0.06);
533
+ animation: cardFadeIn 0.25s ease;
534
+ box-sizing: border-box;
535
+ }
536
+
537
+ .model-card-details {
538
+ display: grid;
539
+ grid-template-columns: repeat(3, 1fr);
540
+ gap: 12px;
541
+ }
542
+
543
+ @keyframes cardFadeIn {
544
+ from { opacity: 0; transform: translateY(4px); }
545
+ to { opacity: 1; transform: translateY(0); }
546
+ }
547
+
548
+ .model-card-header {
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 10px;
552
+ margin-bottom: 4px;
553
+ }
554
+
555
+ .model-card-title {
556
+ margin: 0;
557
+ font-size: 1.05rem;
558
+ color: #1a1a2e;
559
+ }
560
+
561
+ .model-card-badge {
562
+ padding: 3px 10px;
563
+ border-radius: 999px;
564
+ background: #e7f3ff;
565
+ color: #007BFF;
566
+ font-size: 0.7rem;
567
+ font-weight: 800;
568
+ letter-spacing: 0.04em;
569
+ text-transform: uppercase;
570
+ }
571
+
572
+ .model-card-badge-baseline {
573
+ padding: 3px 10px;
574
+ border-radius: 999px;
575
+ background: #fff3e0;
576
+ color: #e67e22;
577
+ font-size: 0.7rem;
578
+ font-weight: 800;
579
+ letter-spacing: 0.04em;
580
+ text-transform: uppercase;
581
+ }
582
+
583
+ .model-card-tagline {
584
+ margin: 0 0 12px;
585
+ color: #667281;
586
+ font-size: 0.85rem;
587
+ line-height: 1.4;
588
+ }
589
+
590
+ .model-card-metrics {
591
+ display: grid;
592
+ grid-template-columns: repeat(4, 1fr);
593
+ gap: 8px;
594
+ margin-bottom: 14px;
595
+ }
596
+
597
+ .model-card-metric {
598
+ text-align: center;
599
+ padding: 8px 4px;
600
+ background: #f8fbff;
601
+ border: 1px solid #e8f0fe;
602
+ border-radius: 10px;
603
+ }
604
+
605
+ .model-card-metric-value {
606
+ display: block;
607
+ font-size: 1.1rem;
608
+ font-weight: 800;
609
+ color: #007BFF;
610
+ line-height: 1.2;
611
+ }
612
+
613
+ .model-card-metric-label {
614
+ display: block;
615
+ font-size: 0.65rem;
616
+ color: #8899aa;
617
+ font-weight: 700;
618
+ text-transform: uppercase;
619
+ letter-spacing: 0.04em;
620
+ margin-top: 2px;
621
+ }
622
+
623
+ .model-card-section {
624
+ margin-bottom: 8px;
625
+ }
626
+
627
+ .model-card-section h4 {
628
+ margin: 0 0 2px;
629
+ font-size: 0.78rem;
630
+ color: #555;
631
+ font-weight: 800;
632
+ text-transform: uppercase;
633
+ letter-spacing: 0.03em;
634
+ }
635
+
636
+ .model-card-section p {
637
+ margin: 0;
638
+ font-size: 0.82rem;
639
+ color: #4a4a4a;
640
+ line-height: 1.5;
641
+ }
642
+
643
+ .model-card-eval {
644
+ margin-top: 10px;
645
+ padding: 6px 10px;
646
+ background: #f5f7fa;
647
+ border-radius: 8px;
648
+ font-size: 0.72rem;
649
+ color: #7a8a9a;
650
+ font-weight: 600;
651
+ }
652
+
653
+ @media (max-width: 768px) {
654
+ .model-card {
655
+ width: 90%;
656
+ }
657
+ .model-card-metrics {
658
+ grid-template-columns: repeat(2, 1fr);
659
+ }
660
+ .model-card-details {
661
+ grid-template-columns: 1fr;
662
+ }
663
+ }
664
+
665
+ .focus-model-label {
666
+ color: #666;
667
+ font-size: 13px;
668
+ font-weight: 700;
669
+ margin-right: 4px;
670
+ }
671
+
672
+ .focus-model-button {
673
+ padding: 6px 16px;
674
+ border-radius: 16px;
675
+ border: 1px solid #d0d0d0;
676
+ background: #f5f5f5;
677
+ color: #555;
678
+ font-size: 12px;
679
+ font-weight: 600;
680
+ text-transform: uppercase;
681
+ cursor: pointer;
682
+ transition: all 0.2s;
683
+ }
684
+
685
+ .focus-model-button:hover {
686
+ border-color: #007BFF;
687
+ color: #007BFF;
688
+ background: #f0f7ff;
689
+ }
690
+
691
+ .focus-model-button.active {
692
+ border: 2px solid #007BFF;
693
+ background: #007BFF;
694
+ color: #fff;
695
+ }
696
+
697
+ .focus-model-sep {
698
+ width: 1px;
699
+ height: 24px;
700
+ background: #d0d0d0;
701
+ margin: 0 4px;
702
+ }
703
+
704
+ .eye-gaze-toggle {
705
+ display: inline-flex;
706
+ align-items: center;
707
+ gap: 6px;
708
+ padding: 6px 14px;
709
+ border-radius: 16px;
710
+ font-size: 12px;
711
+ font-weight: 700;
712
+ cursor: pointer;
713
+ transition: all 0.25s ease;
714
+ }
715
+
716
+ .eye-gaze-toggle.off {
717
+ border: 1px solid #d0d0d0;
718
+ background: #f5f5f5;
719
+ color: #888;
720
+ }
721
+
722
+ .eye-gaze-toggle.off:hover {
723
+ border-color: #007BFF;
724
+ color: #007BFF;
725
+ background: #f0f7ff;
726
+ }
727
+
728
+ .eye-gaze-toggle.on {
729
+ border: 2px solid #007BFF;
730
+ background: #007BFF;
731
+ color: #fff;
732
+ box-shadow: 0 2px 8px rgba(0, 123, 255, 0.25);
733
+ }
734
+
735
+ .eye-gaze-toggle.on:hover {
736
+ background: #0069d9;
737
+ border-color: #0069d9;
738
+ }
739
+
740
+ .eye-gaze-icon {
741
+ flex-shrink: 0;
742
+ }
743
+
744
+ .focus-model-button.recalibrate {
745
+ border: 1px solid #007BFF;
746
+ background: transparent;
747
+ color: #007BFF;
748
+ font-weight: 600;
749
+ font-size: 11px;
750
+ }
751
+
752
+ .focus-model-button.recalibrate:hover {
753
+ background: #f0f7ff;
754
+ }
755
+
756
+ .focus-system-stats {
757
+ display: flex;
758
+ align-items: center;
759
+ justify-content: center;
760
+ gap: 12px;
761
+ padding: 4px 16px;
762
+ margin: 4px auto;
763
+ max-width: 400px;
764
+ font-size: 12px;
765
+ color: #888;
766
+ }
767
+
768
+ .focus-system-stats strong {
769
+ color: #555;
770
+ }
771
+
772
+ .focus-system-stats-sep {
773
+ width: 1px;
774
+ height: 12px;
775
+ background: #ccc;
776
+ }
777
+
778
+ #display-area video {
779
+ width: 100%;
780
+ height: 100%;
781
+ object-fit: cover; /* Behaves similarly to background-size: cover. */
782
+ }
783
+
784
+ /* 2. Timeline Area */
785
+ #timeline-area {
786
+ width: 60%;
787
+ height: 80px;
788
+ position: relative;
789
+ display: flex;
790
+ flex-direction: column;
791
+ justify-content: flex-end;
792
+ align-self: center;
793
+ margin: 0 auto;
794
+ }
795
+
796
+ #timeline-visuals {
797
+ display: flex;
798
+ justify-content: center;
799
+ flex-wrap: wrap;
800
+ align-items: flex-end;
801
+ gap: 2px;
802
+ width: 100%;
803
+ }
804
+
805
+ .timeline-label {
806
+ position: absolute;
807
+ top: 0;
808
+ left: 0;
809
+ color: #888;
810
+ font-size: 14px;
811
+ }
812
+
813
+ #timeline-line {
814
+ width: 100%;
815
+ height: 2px;
816
+ background-color: #87CEEB; /* Light blue */
817
+ }
818
+
819
+ /* 3. Control Panel */
820
+ #control-panel {
821
+ display: flex;
822
+ gap: 20px;
823
+ width: 60%;
824
+ justify-content: space-between;
825
+ }
826
+
827
+ .action-btn {
828
+ flex: 1; /* Evenly distributed width */
829
+ padding: 12px 0;
830
+ border: none;
831
+ border-radius: 12px;
832
+ font-size: 16px;
833
+ font-family: 'Nunito', sans-serif;
834
+ font-weight: 700;
835
+ cursor: pointer;
836
+ color: white;
837
+ transition: opacity 0.2s;
838
+ }
839
+
840
+ .action-btn:hover {
841
+ opacity: 0.9;
842
+ }
843
+
844
+ .action-btn.green { background-color: #28a745; }
845
+ .action-btn.blue { background-color: #007BFF; }
846
+ .action-btn.orange { background-color: #e67e22; }
847
+ .action-btn.red { background-color: #dc3545; }
848
+
849
+ /* 4. Frame Control */
850
+ #frame-control {
851
+ display: flex;
852
+ align-items: center;
853
+ gap: 15px;
854
+ color: #333;
855
+ font-weight: bold;
856
+ }
857
+
858
+ #frame-slider {
859
+ width: 200px;
860
+ cursor: pointer;
861
+ }
862
+
863
+ #frame-input {
864
+ width: 50px;
865
+ padding: 5px;
866
+ border: 1px solid #ccc;
867
+ border-radius: 5px;
868
+ text-align: center;
869
+ font-family: 'Nunito', sans-serif;
870
+ }
871
+
872
+ /* ================ ACHIEVEMENT PAGE ================ */
873
+
874
+ .stats-grid {
875
+ display: grid;
876
+ grid-template-columns: repeat(4, 1fr);
877
+ gap: 20px;
878
+ width: 80%;
879
+ margin: 40px auto;
880
+ }
881
+
882
+ .stat-card {
883
+ background: white;
884
+ padding: 30px;
885
+ border-radius: 12px;
886
+ text-align: center;
887
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
888
+ }
889
+
890
+ .stat-number {
891
+ font-size: 48px;
892
+ font-weight: bold;
893
+ color: #007BFF;
894
+ margin-bottom: 10px;
895
+ }
896
+
897
+ .stat-label {
898
+ font-size: 16px;
899
+ color: #666;
900
+ }
901
+
902
+ .achievements-section {
903
+ width: 80%;
904
+ margin: 0 auto;
905
+ }
906
+
907
+ .achievements-section h2 {
908
+ color: #333;
909
+ margin-bottom: 20px;
910
+ }
911
+
912
+ .badges-grid {
913
+ display: grid;
914
+ grid-template-columns: repeat(3, 1fr);
915
+ gap: 20px;
916
+ }
917
+
918
+ .badge {
919
+ background: white;
920
+ padding: 30px 20px;
921
+ border-radius: 12px;
922
+ text-align: center;
923
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
924
+ transition: transform 0.2s;
925
+ }
926
+
927
+ .badge:hover {
928
+ transform: translateY(-5px);
929
+ }
930
+
931
+ .badge.locked {
932
+ opacity: 0.4;
933
+ filter: grayscale(100%);
934
+ }
935
+
936
+ .badge-icon {
937
+ font-size: 64px;
938
+ margin-bottom: 15px;
939
+ }
940
+
941
+ .badge-name {
942
+ font-size: 16px;
943
+ font-weight: bold;
944
+ color: #333;
945
+ }
946
+
947
+ /* ================ RECORDS PAGE ================ */
948
+
949
+ .records-controls {
950
+ display: flex;
951
+ gap: 10px;
952
+ margin: 20px auto;
953
+ width: 80%;
954
+ justify-content: center;
955
+ }
956
+
957
+ .filter-btn {
958
+ padding: 10px 20px;
959
+ border: 2px solid #007BFF;
960
+ background: white;
961
+ color: #007BFF;
962
+ border-radius: 8px;
963
+ cursor: pointer;
964
+ font-family: 'Nunito', sans-serif;
965
+ font-weight: 600;
966
+ transition: all 0.2s;
967
+ }
968
+
969
+ .filter-btn:hover {
970
+ background: #e7f3ff;
971
+ }
972
+
973
+ .filter-btn.active {
974
+ background: #007BFF;
975
+ color: white;
976
+ }
977
+
978
+ .chart-container {
979
+ width: 80%;
980
+ background: white;
981
+ padding: 30px;
982
+ border-radius: 12px;
983
+ margin: 20px auto;
984
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
985
+ }
986
+
987
+ #focus-chart {
988
+ display: block;
989
+ margin: 0 auto;
990
+ /* Make sure the chart scales within its container. */
991
+ max-width: 100%;
992
+ }
993
+
994
+ .sessions-list {
995
+ width: 80%;
996
+ margin: 20px auto;
997
+ }
998
+
999
+ .sessions-list h2 {
1000
+ color: #333;
1001
+ margin-bottom: 15px;
1002
+ }
1003
+
1004
+ #sessions-table {
1005
+ width: 100%;
1006
+ background: white;
1007
+ border-collapse: collapse;
1008
+ border-radius: 12px;
1009
+ overflow: hidden;
1010
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1011
+ }
1012
+
1013
+ #sessions-table th {
1014
+ background: #007BFF;
1015
+ color: white;
1016
+ padding: 15px;
1017
+ text-align: left;
1018
+ font-weight: 600;
1019
+ }
1020
+
1021
+ #sessions-table td {
1022
+ padding: 12px 15px;
1023
+ border-bottom: 1px solid #eee;
1024
+ }
1025
+
1026
+ #sessions-table tr:last-child td {
1027
+ border-bottom: none;
1028
+ }
1029
+
1030
+ #sessions-table tbody tr:hover {
1031
+ background: #f8f9fa;
1032
+ }
1033
+
1034
+ .btn-view {
1035
+ padding: 6px 18px;
1036
+ background: #007BFF;
1037
+ color: white;
1038
+ border: none;
1039
+ border-radius: 999px;
1040
+ cursor: pointer;
1041
+ font-family: 'Nunito', sans-serif;
1042
+ font-size: 12px;
1043
+ font-weight: 700;
1044
+ transition: background 0.2s;
1045
+ }
1046
+
1047
+ .btn-view:hover {
1048
+ background: #0056b3;
1049
+ }
1050
+
1051
+ .records-detail-modal {
1052
+ width: min(960px, 92vw);
1053
+ max-width: 960px;
1054
+ max-height: 86vh;
1055
+ overflow-y: auto;
1056
+ padding: 30px;
1057
+ box-sizing: border-box;
1058
+ }
1059
+
1060
+ .records-detail-header {
1061
+ display: flex;
1062
+ justify-content: space-between;
1063
+ align-items: flex-start;
1064
+ gap: 20px;
1065
+ margin-bottom: 24px;
1066
+ }
1067
+
1068
+ .records-detail-kicker {
1069
+ color: #007BFF;
1070
+ font-size: 12px;
1071
+ font-weight: 800;
1072
+ letter-spacing: 0.08em;
1073
+ text-transform: uppercase;
1074
+ }
1075
+
1076
+ .records-detail-header h2 {
1077
+ margin: 10px 0 8px;
1078
+ color: #333;
1079
+ text-align: left;
1080
+ }
1081
+
1082
+ .records-detail-subtitle {
1083
+ margin: 0;
1084
+ color: #667281;
1085
+ line-height: 1.6;
1086
+ }
1087
+
1088
+ .records-detail-close {
1089
+ border: 1px solid #d6e6fa;
1090
+ background: #f4f9ff;
1091
+ color: #3569a8;
1092
+ border-radius: 999px;
1093
+ padding: 10px 18px;
1094
+ font-family: 'Nunito', sans-serif;
1095
+ font-weight: 700;
1096
+ cursor: pointer;
1097
+ }
1098
+
1099
+ .records-detail-close:hover {
1100
+ border-color: #bfd9f7;
1101
+ background: #e9f4ff;
1102
+ }
1103
+
1104
+ .records-detail-feedback {
1105
+ padding: 18px 20px;
1106
+ border-radius: 14px;
1107
+ background: #f7f9fc;
1108
+ color: #516173;
1109
+ font-weight: 700;
1110
+ }
1111
+
1112
+ .records-detail-feedback-error {
1113
+ background: #fff1ee;
1114
+ color: #b54028;
1115
+ }
1116
+
1117
+ .records-detail-summary {
1118
+ display: grid;
1119
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1120
+ gap: 14px;
1121
+ margin-bottom: 18px;
1122
+ }
1123
+
1124
+ .records-detail-stat {
1125
+ padding: 18px;
1126
+ border-radius: 14px;
1127
+ background: #f8fbff;
1128
+ border: 1px solid #d9eaff;
1129
+ }
1130
+
1131
+ .records-detail-stat.excellent {
1132
+ background: #eef9f0;
1133
+ border-color: #cdebd3;
1134
+ }
1135
+
1136
+ .records-detail-stat.good {
1137
+ background: #fff9eb;
1138
+ border-color: #f8e3a8;
1139
+ }
1140
+
1141
+ .records-detail-stat.fair {
1142
+ background: #fff4eb;
1143
+ border-color: #ffd6af;
1144
+ }
1145
+
1146
+ .records-detail-stat.low {
1147
+ background: #fff0f0;
1148
+ border-color: #f3c7c7;
1149
+ }
1150
+
1151
+ .records-detail-stat-label {
1152
+ display: block;
1153
+ margin-bottom: 8px;
1154
+ color: #667281;
1155
+ font-size: 13px;
1156
+ font-weight: 700;
1157
+ text-transform: uppercase;
1158
+ letter-spacing: 0.04em;
1159
+ }
1160
+
1161
+ .records-detail-stat-value {
1162
+ display: block;
1163
+ color: #1f2d3d;
1164
+ font-size: 28px;
1165
+ line-height: 1.1;
1166
+ }
1167
+
1168
+ .records-detail-grid {
1169
+ display: grid;
1170
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1171
+ gap: 16px;
1172
+ margin-bottom: 16px;
1173
+ }
1174
+
1175
+ .records-detail-card {
1176
+ background: white;
1177
+ border: 1px solid #e8eef5;
1178
+ border-radius: 16px;
1179
+ padding: 20px;
1180
+ box-shadow: 0 8px 24px rgba(20, 44, 74, 0.06);
1181
+ margin-bottom: 16px;
1182
+ }
1183
+
1184
+ .records-detail-card:last-child {
1185
+ margin-bottom: 0;
1186
+ }
1187
+
1188
+ .records-detail-card h3 {
1189
+ margin: 0 0 16px;
1190
+ color: #333;
1191
+ font-size: 18px;
1192
+ }
1193
+
1194
+ .records-detail-list {
1195
+ display: grid;
1196
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1197
+ gap: 14px 18px;
1198
+ }
1199
+
1200
+ .records-detail-item {
1201
+ display: flex;
1202
+ flex-direction: column;
1203
+ gap: 6px;
1204
+ }
1205
+
1206
+ .records-detail-item-label {
1207
+ color: #7a8795;
1208
+ font-size: 12px;
1209
+ font-weight: 700;
1210
+ text-transform: uppercase;
1211
+ letter-spacing: 0.05em;
1212
+ }
1213
+
1214
+ .records-detail-item-value {
1215
+ color: #263445;
1216
+ font-size: 15px;
1217
+ font-weight: 700;
1218
+ line-height: 1.5;
1219
+ }
1220
+
1221
+ .records-detail-section-head {
1222
+ display: flex;
1223
+ align-items: center;
1224
+ justify-content: space-between;
1225
+ gap: 12px;
1226
+ margin-bottom: 16px;
1227
+ }
1228
+
1229
+ .records-detail-section-head span {
1230
+ color: #7a8795;
1231
+ font-size: 13px;
1232
+ font-weight: 700;
1233
+ }
1234
+
1235
+ .records-detail-timeline {
1236
+ display: grid;
1237
+ grid-template-columns: repeat(auto-fit, minmax(10px, 1fr));
1238
+ gap: 5px;
1239
+ }
1240
+
1241
+ .records-detail-segment {
1242
+ height: 48px;
1243
+ border-radius: 999px;
1244
+ }
1245
+
1246
+ .records-detail-segment.focused {
1247
+ background: linear-gradient(180deg, #3ab86a 0%, #23a057 100%);
1248
+ }
1249
+
1250
+ .records-detail-segment.mixed {
1251
+ background: linear-gradient(180deg, #f1b447 0%, #df9a1e 100%);
1252
+ }
1253
+
1254
+ .records-detail-segment.distracted {
1255
+ background: linear-gradient(180deg, #ec7d7d 0%, #d9534f 100%);
1256
+ }
1257
+
1258
+ .records-detail-legend {
1259
+ display: flex;
1260
+ flex-wrap: wrap;
1261
+ gap: 16px;
1262
+ margin-top: 14px;
1263
+ color: #667281;
1264
+ font-size: 13px;
1265
+ font-weight: 700;
1266
+ }
1267
+
1268
+ .records-detail-legend span {
1269
+ display: inline-flex;
1270
+ align-items: center;
1271
+ gap: 8px;
1272
+ }
1273
+
1274
+ .records-detail-dot {
1275
+ width: 10px;
1276
+ height: 10px;
1277
+ border-radius: 50%;
1278
+ display: inline-block;
1279
+ }
1280
+
1281
+ .records-detail-dot.focused {
1282
+ background: #23a057;
1283
+ }
1284
+
1285
+ .records-detail-dot.mixed {
1286
+ background: #df9a1e;
1287
+ }
1288
+
1289
+ .records-detail-dot.distracted {
1290
+ background: #d9534f;
1291
+ }
1292
+
1293
+ .records-detail-events {
1294
+ display: grid;
1295
+ gap: 10px;
1296
+ max-height: 280px;
1297
+ overflow-y: auto;
1298
+ }
1299
+
1300
+ .records-detail-event {
1301
+ display: grid;
1302
+ grid-template-columns: auto 1fr auto;
1303
+ align-items: center;
1304
+ gap: 12px;
1305
+ padding: 12px 14px;
1306
+ background: #f8fbff;
1307
+ border: 1px solid #e1edf9;
1308
+ border-radius: 14px;
1309
+ }
1310
+
1311
+ .records-detail-event-time {
1312
+ min-width: 52px;
1313
+ color: #3569a8;
1314
+ font-size: 13px;
1315
+ font-weight: 800;
1316
+ }
1317
+
1318
+ .records-detail-event-copy {
1319
+ min-width: 0;
1320
+ }
1321
+
1322
+ .records-detail-event-status {
1323
+ color: #243345;
1324
+ font-size: 14px;
1325
+ font-weight: 800;
1326
+ }
1327
+
1328
+ .records-detail-event-meta {
1329
+ margin-top: 4px;
1330
+ color: #6f7d8c;
1331
+ font-size: 12px;
1332
+ line-height: 1.5;
1333
+ }
1334
+
1335
+ .records-detail-event-badge {
1336
+ padding: 7px 12px;
1337
+ border-radius: 999px;
1338
+ font-size: 11px;
1339
+ font-weight: 800;
1340
+ letter-spacing: 0.04em;
1341
+ text-transform: uppercase;
1342
+ }
1343
+
1344
+ .records-detail-event-badge.focused {
1345
+ background: #eaf8ef;
1346
+ color: #1f8a4c;
1347
+ }
1348
+
1349
+ .records-detail-event-badge.distracted {
1350
+ background: #fff1f1;
1351
+ color: #c24c49;
1352
+ }
1353
+
1354
+ .records-detail-empty {
1355
+ padding: 16px 18px;
1356
+ border-radius: 14px;
1357
+ background: #f7f9fc;
1358
+ color: #708090;
1359
+ font-weight: 700;
1360
+ }
1361
+
1362
+ /* ================ SETTINGS PAGE ================ */
1363
+
1364
+ .settings-container {
1365
+ width: 60%;
1366
+ max-width: 800px;
1367
+ margin: 20px auto;
1368
+ }
1369
+
1370
+ .setting-group {
1371
+ background: white;
1372
+ padding: 30px;
1373
+ border-radius: 12px;
1374
+ margin-bottom: 20px;
1375
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1376
+ }
1377
+
1378
+ .setting-group h2 {
1379
+ margin-top: 0;
1380
+ color: #333;
1381
+ font-size: 20px;
1382
+ margin-bottom: 20px;
1383
+ border-bottom: 2px solid #007BFF;
1384
+ padding-bottom: 10px;
1385
+ }
1386
+
1387
+ .setting-item {
1388
+ margin-bottom: 25px;
1389
+ }
1390
+
1391
+ .setting-item:last-child {
1392
+ margin-bottom: 0;
1393
+ }
1394
+
1395
+ .setting-item label {
1396
+ display: block;
1397
+ margin-bottom: 8px;
1398
+ color: #333;
1399
+ font-weight: 600;
1400
+ }
1401
+
1402
+ .slider-group {
1403
+ display: flex;
1404
+ align-items: center;
1405
+ gap: 15px;
1406
+ }
1407
+
1408
+ .slider-group input[type="range"] {
1409
+ flex: 1;
1410
+ }
1411
+
1412
+ .slider-group span {
1413
+ min-width: 40px;
1414
+ text-align: center;
1415
+ font-weight: bold;
1416
+ color: #007BFF;
1417
+ font-size: 18px;
1418
+ }
1419
+
1420
+ .setting-description {
1421
+ font-size: 14px;
1422
+ color: #666;
1423
+ margin-top: 5px;
1424
+ font-style: italic;
1425
+ }
1426
+
1427
+ input[type="checkbox"] {
1428
+ margin-right: 10px;
1429
+ cursor: pointer;
1430
+ }
1431
+
1432
+ input[type="number"] {
1433
+ width: 100px;
1434
+ padding: 8px;
1435
+ border: 1px solid #ccc;
1436
+ border-radius: 5px;
1437
+ font-family: 'Nunito', sans-serif;
1438
+ }
1439
+
1440
+ /* Center the settings buttons and give them more width. */
1441
+ .setting-group .action-btn {
1442
+ display: inline-block; /* Allow buttons to sit side by side. */
1443
+ width: 48%; /* Roughly half-width each, with a small gutter. */
1444
+ margin: 15px 1%; /* Vertical spacing plus horizontal separation. */
1445
+ text-align: center; /* Center the label text. */
1446
+ box-sizing: border-box; /* Prevent borders from forcing an early wrap. */
1447
+ }
1448
+
1449
+ #save-settings {
1450
+ display: block;
1451
+ margin: 20px auto;
1452
+ }
1453
+
1454
+ /* ================ HELP PAGE ================ */
1455
+
1456
+ .help-container {
1457
+ width: 70%;
1458
+ max-width: 900px;
1459
+ margin: 20px auto;
1460
+ }
1461
+
1462
+ /* Fake ad block (Help page) */
1463
+ .fake-ad {
1464
+ position: relative;
1465
+ display: block;
1466
+ width: min(600px, 90%);
1467
+ margin: 10px auto 30px auto;
1468
+ border: 1px solid #e5e5e5;
1469
+ border-radius: 12px;
1470
+ overflow: hidden;
1471
+ background: #fff;
1472
+ text-decoration: none;
1473
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
1474
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1475
+ }
1476
+
1477
+ .fake-ad:hover {
1478
+ transform: translateY(-2px);
1479
+ box-shadow: 0 12px 30px rgba(0,0,0,0.16);
1480
+ }
1481
+
1482
+ .fake-ad img {
1483
+ display: block;
1484
+ width: 100%;
1485
+ height: auto;
1486
+ }
1487
+
1488
+ .fake-ad-badge {
1489
+ position: absolute;
1490
+ top: 12px;
1491
+ left: 12px;
1492
+ background: rgba(0,0,0,0.75);
1493
+ color: #fff;
1494
+ font-size: 12px;
1495
+ padding: 4px 8px;
1496
+ border-radius: 6px;
1497
+ letter-spacing: 0.5px;
1498
+ }
1499
+
1500
+ .fake-ad-cta {
1501
+ position: absolute;
1502
+ right: 12px;
1503
+ bottom: 12px;
1504
+ background: #111;
1505
+ color: #fff;
1506
+ font-size: 14px;
1507
+ padding: 8px 12px;
1508
+ border-radius: 8px;
1509
+ }
1510
+
1511
+ .help-section {
1512
+ background: white;
1513
+ padding: 30px;
1514
+ border-radius: 12px;
1515
+ margin-bottom: 20px;
1516
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1517
+ }
1518
+
1519
+ .help-section h2 {
1520
+ color: #007BFF;
1521
+ margin-top: 0;
1522
+ margin-bottom: 15px;
1523
+ }
1524
+
1525
+ .help-section ol,
1526
+ .help-section ul {
1527
+ line-height: 1.8;
1528
+ color: #333;
1529
+ }
1530
+
1531
+ .help-section p {
1532
+ line-height: 1.6;
1533
+ color: #333;
1534
+ }
1535
+
1536
+ details {
1537
+ margin: 15px 0;
1538
+ cursor: pointer;
1539
+ padding: 10px;
1540
+ background: #f8f9fa;
1541
+ border-radius: 5px;
1542
+ }
1543
+
1544
+ summary {
1545
+ font-weight: bold;
1546
+ padding: 5px;
1547
+ color: #007BFF;
1548
+ }
1549
+
1550
+ details[open] summary {
1551
+ margin-bottom: 10px;
1552
+ border-bottom: 1px solid #ddd;
1553
+ padding-bottom: 10px;
1554
+ }
1555
+
1556
+ details p {
1557
+ margin: 10px 0 0 0;
1558
+ }
1559
+
1560
+ /* ================ SESSION SUMMARY MODAL ================ */
1561
+ /* These modal styles can be reused for future overlays. */
1562
+ .modal-overlay {
1563
+ position: fixed;
1564
+ top: 0;
1565
+ left: 0;
1566
+ width: 100%;
1567
+ height: 100%;
1568
+ background: rgba(0, 0, 0, 0.7);
1569
+ display: flex;
1570
+ align-items: center;
1571
+ justify-content: center;
1572
+ z-index: 2000;
1573
+ }
1574
+
1575
+ .modal-content {
1576
+ background: white;
1577
+ padding: 40px;
1578
+ border-radius: 16px;
1579
+ box-shadow: 0 10px 40px rgba(0,0,0,0.3);
1580
+ max-width: 500px;
1581
+ width: 90%;
1582
+ }
1583
+
1584
+ .modal-content h2 {
1585
+ margin-top: 0;
1586
+ color: #333;
1587
+ text-align: center;
1588
+ margin-bottom: 30px;
1589
+ }
1590
+
1591
+ .summary-stats {
1592
+ margin-bottom: 30px;
1593
+ }
1594
+
1595
+ .summary-item {
1596
+ display: flex;
1597
+ justify-content: space-between;
1598
+ padding: 15px 0;
1599
+ border-bottom: 1px solid #eee;
1600
+ }
1601
+
1602
+ .summary-item:last-child {
1603
+ border-bottom: none;
1604
+ }
1605
+
1606
+ .summary-label {
1607
+ font-weight: 600;
1608
+ color: #666;
1609
+ }
1610
+
1611
+ .summary-value {
1612
+ font-weight: bold;
1613
+ color: #007BFF;
1614
+ font-size: 18px;
1615
+ }
1616
+
1617
+ .modal-content .btn-main {
1618
+ display: block;
1619
+ margin: 0 auto;
1620
+ padding: 12px 40px;
1621
+ }
1622
+
1623
+ /* ================ TIMELINE BLOCKS ================ */
1624
+
1625
+ .timeline-block {
1626
+ transition: opacity 0.2s;
1627
+ border-radius: 2px;
1628
+ }
1629
+
1630
+ .timeline-block:hover {
1631
+ opacity: 0.7;
1632
+ }
1633
+
1634
+ /* ================ RESPONSIVE DESIGN ================ */
1635
+
1636
+ @media (max-width: 1200px) {
1637
+ .stats-grid {
1638
+ grid-template-columns: repeat(2, 1fr);
1639
+ }
1640
+
1641
+ .badges-grid {
1642
+ grid-template-columns: repeat(2, 1fr);
1643
+ }
1644
+ }
1645
+
1646
+ @media (max-width: 768px) {
1647
+ .stats-grid,
1648
+ .badges-grid {
1649
+ grid-template-columns: 1fr;
1650
+ width: 90%;
1651
+ }
1652
+
1653
+ .settings-container,
1654
+ .help-container,
1655
+ .chart-container,
1656
+ .sessions-list,
1657
+ .records-controls {
1658
+ width: 90%;
1659
+ }
1660
+
1661
+ #control-panel {
1662
+ width: 90%;
1663
+ flex-wrap: wrap;
1664
+ }
1665
+
1666
+ #display-area {
1667
+ width: 90%;
1668
+ }
1669
+
1670
+ #timeline-area {
1671
+ width: 90%;
1672
+ }
1673
+
1674
+ #frame-control {
1675
+ width: 90%;
1676
+ flex-direction: column;
1677
+ }
1678
+
1679
+ .focus-inline-error-standalone {
1680
+ width: 90%;
1681
+ }
1682
+
1683
+ .focus-flow-overlay {
1684
+ top: 70px;
1685
+ right: 10px;
1686
+ bottom: 10px;
1687
+ left: 10px;
1688
+ }
1689
+
1690
+ .focus-flow-card {
1691
+ padding: 22px 20px;
1692
+ }
1693
+
1694
+ .focus-flow-header {
1695
+ flex-direction: column;
1696
+ align-items: flex-start;
1697
+ }
1698
+
1699
+ .focus-flow-icon {
1700
+ width: 92px;
1701
+ height: 92px;
1702
+ }
1703
+
1704
+ .focus-flow-grid {
1705
+ grid-template-columns: 1fr;
1706
+ }
1707
+
1708
+ .focus-flow-steps {
1709
+ grid-template-columns: 1fr;
1710
+ }
1711
+
1712
+ .focus-flow-footer {
1713
+ flex-direction: column;
1714
+ align-items: stretch;
1715
+ }
1716
+
1717
+ .focus-flow-button,
1718
+ .focus-flow-secondary {
1719
+ width: 100%;
1720
+ }
1721
+
1722
+ .records-detail-modal {
1723
+ width: 94vw;
1724
+ padding: 22px 18px;
1725
+ }
1726
+
1727
+ .records-detail-header,
1728
+ .records-detail-section-head {
1729
+ flex-direction: column;
1730
+ align-items: flex-start;
1731
+ }
1732
+
1733
+ .records-detail-summary,
1734
+ .records-detail-grid,
1735
+ .records-detail-list {
1736
+ grid-template-columns: 1fr;
1737
+ }
1738
+
1739
+ .records-detail-event {
1740
+ grid-template-columns: 1fr;
1741
+ align-items: flex-start;
1742
+ }
1743
+ }
1744
+ /* =========================================
1745
+ SESSION RESULT OVERLAY
1746
+ ========================================= */
1747
+
1748
+ .session-result-overlay {
1749
+ position: absolute;
1750
+ top: 0;
1751
+ left: 0;
1752
+ width: 100%;
1753
+ height: 100%;
1754
+ background-color: rgba(0, 0, 0, 0.85); /* Dark semi-transparent backdrop. */
1755
+ display: flex;
1756
+ flex-direction: column;
1757
+ justify-content: center;
1758
+ align-items: center;
1759
+ color: white;
1760
+ z-index: 10;
1761
+ animation: fadeIn 0.5s ease;
1762
+ backdrop-filter: blur(5px); /* Optional background blur. */
1763
+ }
1764
+
1765
+ .session-result-overlay h3 {
1766
+ font-size: 32px;
1767
+ margin-bottom: 30px;
1768
+ color: #4cd137; /* Green title accent. */
1769
+ text-transform: uppercase;
1770
+ letter-spacing: 2px;
1771
+ }
1772
+
1773
+ .session-result-overlay .result-item {
1774
+ display: flex;
1775
+ justify-content: space-between;
1776
+ width: 200px; /* Keep the stat row compact. */
1777
+ margin-bottom: 15px;
1778
+ font-size: 20px;
1779
+ border-bottom: 1px solid rgba(255,255,255,0.2);
1780
+ padding-bottom: 5px;
1781
+ }
1782
+
1783
+ .session-result-overlay .label {
1784
+ color: #ccc;
1785
+ font-weight: normal;
1786
+ }
1787
+
1788
+ .session-result-overlay .value {
1789
+ color: #fff;
1790
+ font-weight: bold;
1791
+ font-family: 'Courier New', monospace; /* Give the values a data-like look. */
1792
+ }
1793
+
1794
+ @keyframes fadeIn {
1795
+ from { opacity: 0; transform: scale(0.95); }
1796
+ to { opacity: 1; transform: scale(1); }
1797
+ }
1798
+
1799
+ /* ================= Welcome modal styles ================= */
1800
+ .welcome-modal-overlay {
1801
+ position: fixed;
1802
+ top: 0; left: 0; right: 0; bottom: 0;
1803
+ background-color: rgba(0, 0, 0, 0.7);
1804
+ display: flex;
1805
+ justify-content: center;
1806
+ align-items: center;
1807
+ z-index: 9999;
1808
+ }
1809
+
1810
+ .welcome-modal {
1811
+ background-color: #1e1e24;
1812
+ padding: 40px;
1813
+ border-radius: 15px;
1814
+ text-align: center;
1815
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
1816
+ border: 1px solid #333;
1817
+ }
1818
+
1819
+ .welcome-modal h2 { margin-top: 0; color: #fff; }
1820
+ .welcome-modal p { margin-bottom: 30px; color: #ccc; }
1821
+ .welcome-buttons { display: flex; gap: 20px; justify-content: center; }
1822
+
1823
+ /* ================= Top-left avatar styles ================= */
1824
+ .avatar-container {
1825
+ position: absolute;
1826
+ left: 20px;
1827
+ cursor: pointer;
1828
+ z-index: 1;
1829
+ }
1830
+
1831
+ .avatar-circle {
1832
+ width: 40px;
1833
+ height: 40px;
1834
+ border-radius: 50%;
1835
+ display: flex;
1836
+ justify-content: center;
1837
+ align-items: center;
1838
+ font-weight: bold;
1839
+ font-size: 1.2rem;
1840
+ color: white;
1841
+ transition: all 0.3s ease;
1842
+ border: 2px solid transparent;
1843
+ }
1844
+
1845
+ .avatar-circle.user { background-color: #555; }
1846
+ .avatar-circle.admin { background-color: #ffaa00; border-color: #fff; box-shadow: 0 0 10px rgba(255, 170, 0, 0.5); }
1847
+
1848
+ /* ================ CALIBRATION OVERLAY ================ */
1849
+
1850
+ .cal-overlay {
1851
+ position: fixed;
1852
+ top: 0;
1853
+ left: 0;
1854
+ width: 100vw;
1855
+ height: 100vh;
1856
+ background: rgba(8, 15, 28, 0.94);
1857
+ backdrop-filter: blur(6px);
1858
+ z-index: 10000;
1859
+ display: flex;
1860
+ align-items: center;
1861
+ justify-content: center;
1862
+ font-family: 'Nunito', sans-serif;
1863
+ }
1864
+
1865
+ /* ---- header / instructions ---- */
1866
+ .cal-header {
1867
+ position: absolute;
1868
+ top: 36px;
1869
+ left: 50%;
1870
+ transform: translateX(-50%);
1871
+ text-align: center;
1872
+ pointer-events: none;
1873
+ }
1874
+
1875
+ .cal-eyebrow {
1876
+ display: inline-block;
1877
+ padding: 6px 14px;
1878
+ border-radius: 999px;
1879
+ font-size: 0.82rem;
1880
+ font-weight: 800;
1881
+ letter-spacing: 0.04em;
1882
+ text-transform: uppercase;
1883
+ }
1884
+
1885
+ .cal-eyebrow-collect {
1886
+ background: rgba(40, 167, 69, 0.18);
1887
+ color: #5ee882;
1888
+ }
1889
+
1890
+ .cal-eyebrow-verify {
1891
+ background: rgba(0, 123, 255, 0.18);
1892
+ color: #6bb8ff;
1893
+ }
1894
+
1895
+ .cal-instruction {
1896
+ margin: 10px 0 0;
1897
+ color: rgba(255, 255, 255, 0.7);
1898
+ font-size: 0.95rem;
1899
+ line-height: 1.5;
1900
+ }
1901
+
1902
+ /* ---- target dot + ring ---- */
1903
+ .cal-target {
1904
+ position: absolute;
1905
+ transform: translate(-50%, -50%);
1906
+ }
1907
+
1908
+ .cal-ring {
1909
+ position: absolute;
1910
+ left: -30px;
1911
+ top: -30px;
1912
+ }
1913
+
1914
+ .cal-dot {
1915
+ width: 20px;
1916
+ height: 20px;
1917
+ border-radius: 50%;
1918
+ transition: box-shadow 0.3s ease;
1919
+ }
1920
+
1921
+ /* ---- cancel button (matches focus-flow-secondary) ---- */
1922
+ .cal-cancel {
1923
+ position: absolute;
1924
+ bottom: 40px;
1925
+ left: 50%;
1926
+ transform: translateX(-50%);
1927
+ border: 1px solid rgba(255, 255, 255, 0.25);
1928
+ border-radius: 999px;
1929
+ padding: 12px 28px;
1930
+ background: rgba(255, 255, 255, 0.08);
1931
+ color: rgba(255, 255, 255, 0.85);
1932
+ font-family: 'Nunito', sans-serif;
1933
+ font-size: 0.95rem;
1934
+ font-weight: 700;
1935
+ cursor: pointer;
1936
+ transition: background 0.2s ease, border-color 0.2s ease;
1937
+ }
1938
+
1939
+ .cal-cancel:hover {
1940
+ background: rgba(255, 255, 255, 0.14);
1941
+ border-color: rgba(255, 255, 255, 0.4);
1942
+ }
1943
+
1944
+ /* ---- done card (matches focus-flow-card style) ---- */
1945
+ .cal-done-card {
1946
+ text-align: center;
1947
+ padding: 36px 44px;
1948
+ border-radius: 20px;
1949
+ border: 1px solid rgba(255, 255, 255, 0.08);
1950
+ box-shadow: 0 28px 80px rgba(0, 0, 0, 0.4);
1951
+ animation: fadeIn 0.4s ease;
1952
+ }
1953
+
1954
+ .cal-done-success {
1955
+ background: linear-gradient(168deg, rgba(40, 167, 69, 0.15) 0%, rgba(20, 30, 48, 0.95) 60%);
1956
+ border-color: rgba(40, 167, 69, 0.3);
1957
+ }
1958
+
1959
+ .cal-done-fail {
1960
+ background: linear-gradient(168deg, rgba(220, 53, 69, 0.15) 0%, rgba(20, 30, 48, 0.95) 60%);
1961
+ border-color: rgba(220, 53, 69, 0.3);
1962
+ }
1963
+
1964
+ .cal-done-eyebrow {
1965
+ display: inline-block;
1966
+ padding: 6px 14px;
1967
+ border-radius: 999px;
1968
+ font-size: 0.78rem;
1969
+ font-weight: 800;
1970
+ letter-spacing: 0.06em;
1971
+ text-transform: uppercase;
1972
+ margin-bottom: 14px;
1973
+ }
1974
+
1975
+ .cal-done-success .cal-done-eyebrow {
1976
+ background: rgba(40, 167, 69, 0.2);
1977
+ color: #5ee882;
1978
+ }
1979
+
1980
+ .cal-done-fail .cal-done-eyebrow {
1981
+ background: rgba(220, 53, 69, 0.2);
1982
+ color: #f87171;
1983
+ }
1984
+
1985
+ .cal-done-title {
1986
+ margin: 0 0 8px;
1987
+ font-size: 1.6rem;
1988
+ color: #fff;
1989
+ }
1990
+
1991
+ .cal-done-subtitle {
1992
+ margin: 0;
1993
+ color: rgba(255, 255, 255, 0.6);
1994
+ font-size: 0.95rem;
1995
+ line-height: 1.5;
1996
+ }
1997
+ /* ================= Home page 2x2 responsive button grid ================= */
1998
+ .home-button-grid {
1999
+ display: flex;
2000
+ justify-content: center;
2001
+ width: 100%;
2002
+ max-width: 360px;
2003
+ margin: 40px auto 0;
2004
+ }
2005
+
2006
+ .home-button-grid .btn-main {
2007
+ width: 100%;
2008
+ height: 60px; /* Keep all tiles at the same height. */
2009
+ margin: 0; /* Remove default outer spacing. */
2010
+ padding: 10px;
2011
+ font-size: 1rem;
2012
+ display: flex;
2013
+ justify-content: center;
2014
+ align-items: center;
2015
+ text-align: center;
2016
+ box-sizing: border-box; /* Prevent padding and borders from breaking the grid. */
2017
+ }
2018
+
2019
+ /* Mobile-only scaling for screens below 600px. */
2020
+ @media (max-width: 600px) {
2021
+ #top-menu {
2022
+ justify-content: flex-start;
2023
+ padding: 0 12px 0 68px;
2024
+ }
2025
+
2026
+ .menu-btn {
2027
+ padding: 10px 14px;
2028
+ font-size: 0.92rem;
2029
+ }
2030
+
2031
+ .separator {
2032
+ margin: 0 2px;
2033
+ }
2034
+
2035
+ .home-button-grid {
2036
+ gap: 15px;
2037
+ max-width: 90%;
2038
+ }
2039
+
2040
+ .home-button-grid .btn-main {
2041
+ height: 50px;
2042
+ font-size: 0.85rem;
2043
+ }
2044
+ }
src/App.jsx CHANGED
@@ -1,91 +1,125 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import './App.css';
3
- import { VideoManagerLocal } from './utils/VideoManagerLocal';
4
-
5
- import Home from './components/Home';
6
- import FocusPageLocal from './components/FocusPageLocal';
7
- import Achievement from './components/Achievement';
8
- import Records from './components/Records';
9
- import Customise from './components/Customise';
10
- import Help from './components/Help';
11
-
12
- function App() {
13
- const [activeTab, setActiveTab] = useState('home');
14
- const videoManagerRef = useRef(null);
15
- const [isSessionActive, setIsSessionActive] = useState(false);
16
- const [sessionResult, setSessionResult] = useState(null);
17
-
18
- useEffect(() => {
19
- const callbacks = {
20
- onSessionStart: () => {
21
- setIsSessionActive(true);
22
- setSessionResult(null);
23
- },
24
- onSessionEnd: (summary) => {
25
- setIsSessionActive(false);
26
- if (summary) setSessionResult(summary);
27
- }
28
- };
29
- videoManagerRef.current = new VideoManagerLocal(callbacks);
30
-
31
- return () => {
32
- if (videoManagerRef.current) videoManagerRef.current.stopStreaming();
33
- };
34
- }, []);
35
-
36
- const renderMenuButton = (tabId, label) => (
37
- <button
38
- className={`menu-btn ${activeTab === tabId ? 'active' : ''}`}
39
- onClick={() => setActiveTab(tabId)}
40
- >
41
- {label}
42
- </button>
43
- );
44
-
45
- return (
46
- <div className="app-container">
47
- <nav id="top-menu">
48
- <div className="top-menu-links">
49
- <button
50
- type="button"
51
- className={`menu-btn ${activeTab === 'home' ? 'active' : ''}`}
52
- onClick={() => setActiveTab('home')}
53
- >
54
- Home
55
- </button>
56
- <div className="separator" aria-hidden />
57
- <button className={`menu-btn ${activeTab === 'focus' ? 'active' : ''}`} onClick={() => setActiveTab('focus')}>
58
- Start Focus {isSessionActive && <span style={{ marginLeft: '8px', color: '#00FF00' }}>●</span>}
59
- </button>
60
- <div className="separator"></div>
61
-
62
- {renderMenuButton('achievement', 'My Achievement')}
63
- <div className="separator"></div>
64
-
65
- {renderMenuButton('records', 'My Records')}
66
- <div className="separator"></div>
67
-
68
- {renderMenuButton('customise', 'Settings')}
69
- <div className="separator"></div>
70
-
71
- {renderMenuButton('help', 'Help')}
72
- </div>
73
- </nav>
74
-
75
- {activeTab === 'home' && <Home setActiveTab={setActiveTab} />}
76
-
77
- <FocusPageLocal
78
- videoManager={videoManagerRef.current}
79
- sessionResult={sessionResult}
80
- setSessionResult={setSessionResult}
81
- isActive={activeTab === 'focus'}
82
- />
83
- {activeTab === 'achievement' && <Achievement />}
84
- {activeTab === 'records' && <Records />}
85
- {activeTab === 'customise' && <Customise />}
86
- {activeTab === 'help' && <Help />}
87
- </div>
88
- );
89
- }
90
-
91
- export default App;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import './App.css';
3
+ import { VideoManagerLocal } from './utils/VideoManagerLocal';
4
+
5
+ import Home from './components/Home';
6
+ import FocusPageLocal from './components/FocusPageLocal';
7
+ import Achievement from './components/Achievement';
8
+ import Records from './components/Records';
9
+ import Help from './components/Help';
10
+
11
+ function App() {
12
+ const [activeTab, setActiveTab] = useState('home');
13
+ const videoManagerRef = useRef(null);
14
+ const [isSessionActive, setIsSessionActive] = useState(false);
15
+ const [sessionResult, setSessionResult] = useState(null);
16
+ const [isTutorialActive, setIsTutorialActive] = useState(false);
17
+ const [hasSeenTutorial, setHasSeenTutorial] = useState(false);
18
+
19
+ useEffect(() => {
20
+ fetch('/api/history', { method: 'DELETE' })
21
+ .then(() => {
22
+ const backup = localStorage.getItem('focus_magic_backup');
23
+ if (backup) {
24
+ try {
25
+ const sessions = JSON.parse(backup);
26
+ fetch('/api/import', {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify(sessions)
30
+ });
31
+ } catch (err) {
32
+ console.error(err);
33
+ }
34
+ }
35
+ })
36
+ .catch(err => console.error(err));
37
+
38
+ const callbacks = {
39
+ onSessionStart: () => {
40
+ setIsSessionActive(true);
41
+ setSessionResult(null);
42
+ },
43
+ onSessionEnd: (summary) => {
44
+ setIsSessionActive(false);
45
+ if (summary) setSessionResult(summary);
46
+
47
+ fetch('/api/sessions?filter=all')
48
+ .then(res => res.json())
49
+ .then(data => {
50
+ if (data && Array.isArray(data)) {
51
+ localStorage.setItem('focus_magic_backup', JSON.stringify(data));
52
+ }
53
+ })
54
+ .catch(err => console.error(err));
55
+ }
56
+ };
57
+ videoManagerRef.current = new VideoManagerLocal(callbacks);
58
+
59
+ return () => {
60
+ if (videoManagerRef.current) videoManagerRef.current.stopStreaming();
61
+ };
62
+ }, []);
63
+
64
+ const handleStartFocus = () => {
65
+ if (!hasSeenTutorial) {
66
+ setIsTutorialActive(true);
67
+ }
68
+ setActiveTab('focus');
69
+ };
70
+
71
+ const handleStartTutorial = () => {
72
+ setIsTutorialActive(true);
73
+ setActiveTab('focus');
74
+ };
75
+
76
+ return (
77
+ <div className="app-container">
78
+ <nav id="top-menu">
79
+ <button
80
+ className={`menu-btn ${activeTab === 'home' ? 'active' : ''}`}
81
+ onClick={() => setActiveTab('home')}
82
+ >
83
+ Home
84
+ </button>
85
+ <div className="separator"></div>
86
+
87
+ <button className={`menu-btn ${activeTab === 'focus' ? 'active' : ''}`} onClick={handleStartFocus}>
88
+ Start Focus {isSessionActive && <span style={{ marginLeft: '8px', color: '#00FF00' }}>●</span>}
89
+ </button>
90
+ <div className="separator"></div>
91
+
92
+ <button className={`menu-btn ${activeTab === 'achievement' ? 'active' : ''}`} onClick={() => setActiveTab('achievement')}>
93
+ My Achievement
94
+ </button>
95
+ <div className="separator"></div>
96
+
97
+ <button className={`menu-btn ${activeTab === 'records' ? 'active' : ''}`} onClick={() => setActiveTab('records')}>
98
+ My Records
99
+ </button>
100
+ <div className="separator"></div>
101
+
102
+ <button className={`menu-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => setActiveTab('help')}>
103
+ Help
104
+ </button>
105
+ </nav>
106
+
107
+ {activeTab === 'home' && <Home onStartFocus={handleStartFocus} onStartTutorial={handleStartTutorial} />}
108
+
109
+ <FocusPageLocal
110
+ videoManager={videoManagerRef.current}
111
+ sessionResult={sessionResult}
112
+ setSessionResult={setSessionResult}
113
+ isActive={activeTab === 'focus'}
114
+ isTutorialActive={isTutorialActive}
115
+ setIsTutorialActive={setIsTutorialActive}
116
+ setHasSeenTutorial={setHasSeenTutorial}
117
+ />
118
+ {activeTab === 'achievement' && <Achievement />}
119
+ {activeTab === 'records' && <Records />}
120
+ {activeTab === 'help' && <Help />}
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export default App;
src/components/FocusPageLocal.jsx CHANGED
@@ -1,1048 +1,1040 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import CalibrationOverlay from './CalibrationOverlay';
3
-
4
- const FLOW_STEPS = {
5
- intro: 'intro',
6
- permission: 'permission',
7
- ready: 'ready'
8
- };
9
-
10
- const FOCUS_STATES = {
11
- pending: 'pending',
12
- focused: 'focused',
13
- notFocused: 'not-focused'
14
- };
15
-
16
- function HelloIcon() {
17
- return (
18
- <svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
19
- <circle cx="48" cy="48" r="40" fill="#007BFF" />
20
- <path d="M30 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
21
- <path d="M54 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
22
- <path d="M30 52c3 11 10 17 18 17s15-6 18-17" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
23
- </svg>
24
- );
25
- }
26
-
27
- function CameraIcon() {
28
- return (
29
- <svg width="110" height="110" viewBox="0 0 110 110" aria-hidden="true">
30
- <rect x="30" y="36" width="50" height="34" rx="5" fill="none" stroke="#007BFF" strokeWidth="6" />
31
- <path d="M24 72h62c0 9-7 16-16 16H40c-9 0-16-7-16-16Z" fill="none" stroke="#007BFF" strokeWidth="6" />
32
- <path d="M55 28v8" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
33
- <circle cx="55" cy="36" r="14" fill="none" stroke="#007BFF" strokeWidth="6" />
34
- <circle cx="55" cy="36" r="4" fill="#007BFF" />
35
- <path d="M46 83h18" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
36
- </svg>
37
- );
38
- }
39
-
40
- const MODEL_ORDER = ['hybrid', 'xgboost', 'mlp', 'geometric'];
41
-
42
- const MODEL_INFO = {
43
- hybrid: {
44
- label: 'Hybrid',
45
- tagline: 'Best overall — combines ML with geometric scoring',
46
- how: 'Fuses XGBoost predictions (30%) with geometric face/eye scores (70%). Uses a weighted blend tuned with LOPO evaluation.',
47
- accuracy: 'N/A',
48
- f1: '0.8409',
49
- auc: 'N/A',
50
- threshold: '0.46',
51
- evaluation: 'LOPO tuning (9 participants, 144K frames)',
52
- features: '10 features: head deviation, face score, eye scores (EAR), gaze offset, pitch, horizontal gaze, PERCLOS',
53
- strengths: 'Most robust across different people. Latest LOPO mean F1 is 0.8409 at w_mlp=0.3.',
54
- badge: 'Recommended',
55
- },
56
- xgboost: {
57
- label: 'XGBoost',
58
- tagline: 'Highest raw accuracy — gradient-boosted decision trees',
59
- how: 'Ensemble of 600 decision trees (max depth 8). Each tree learns to correct errors from previous trees. Outputs probability of focused state.',
60
- accuracy: '95.87%',
61
- f1: '0.9585',
62
- auc: '0.9908',
63
- threshold: '0.38',
64
- evaluation: 'Random split test (15%) + LOPO thresholds',
65
- features: '10 features: head deviation, face score, eye scores (EAR), gaze offset, pitch, horizontal gaze, PERCLOS',
66
- strengths: 'Strong pattern recognition and fast inference. LOPO: AUC 0.8695, optimal threshold 0.280, F1 0.8549.',
67
- badge: null,
68
- },
69
- mlp: {
70
- label: 'MLP',
71
- tagline: 'Lightweight neural network — fast and efficient',
72
- how: 'Two-layer neural network (64→32 neurons). Takes 10 face features, applies learned weights, outputs focused/unfocused probability via softmax.',
73
- accuracy: '92.92%',
74
- f1: '0.9287',
75
- auc: '0.9714',
76
- threshold: '0.23',
77
- evaluation: 'Random split test (15%) + LOPO thresholds',
78
- features: '10 features: head deviation, face score, eye scores (EAR), gaze offset, pitch, horizontal gaze, PERCLOS',
79
- strengths: 'Fastest inference and smallest model size. LOPO: AUC 0.8624, optimal threshold 0.228, F1 0.8578.',
80
- badge: null,
81
- },
82
- geometric: {
83
- label: 'Geometric',
84
- tagline: 'Baseline only — hardcoded thresholds, no learning',
85
- how: 'Uses fixed thresholds on head orientation (70%) and eye openness (30%). No training — just hand-tuned rules on 478 face landmarks. Cannot adapt to new faces or environments.',
86
- accuracy: 'N/A',
87
- f1: '0.8195',
88
- auc: 'N/A',
89
- threshold: '0.55',
90
- evaluation: 'LOPO geometric sweep',
91
- features: 'Head yaw/pitch/roll angles, eye aspect ratio (EAR), iris gaze offset, mouth aspect ratio (MAR)',
92
- strengths: 'No model files needed. Useful fallback when model checkpoints are unavailable.',
93
- badge: 'Baseline',
94
- },
95
- };
96
-
97
- function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActive }) {
98
- const [currentFrame, setCurrentFrame] = useState(15);
99
- const [timelineEvents, setTimelineEvents] = useState([]);
100
- const [stats, setStats] = useState(null);
101
- const [systemStats, setSystemStats] = useState(null);
102
- const [availableModels, setAvailableModels] = useState([]);
103
- const [currentModel, setCurrentModel] = useState('mlp');
104
- const [flowStep, setFlowStep] = useState(FLOW_STEPS.intro);
105
- const [cameraReady, setCameraReady] = useState(false);
106
- const [isStarting, setIsStarting] = useState(false);
107
- const [focusState, setFocusState] = useState(FOCUS_STATES.pending);
108
- const [cameraError, setCameraError] = useState('');
109
- const [calibration, setCalibration] = useState(null);
110
- const [l2csBoost, setL2csBoost] = useState(false);
111
- const [l2csBoostAvailable, setL2csBoostAvailable] = useState(false);
112
- const [showEyeGazeModal, setShowEyeGazeModal] = useState(false);
113
- const [eyeGazeDontShow, setEyeGazeDontShow] = useState(
114
- () => localStorage.getItem('focusguard_eyegaze_noshowalert') === 'true'
115
- );
116
-
117
- const localVideoRef = useRef(null);
118
- const displayCanvasRef = useRef(null);
119
- const pipVideoRef = useRef(null);
120
- const pipStreamRef = useRef(null);
121
- const previewFrameRef = useRef(null);
122
-
123
- const formatDuration = (seconds) => {
124
- if (seconds === 0) return '0s';
125
- const mins = Math.floor(seconds / 60);
126
- const secs = Math.floor(seconds % 60);
127
- return `${mins}m ${secs}s`;
128
- };
129
-
130
- const stopPreviewLoop = () => {
131
- if (previewFrameRef.current) {
132
- cancelAnimationFrame(previewFrameRef.current);
133
- previewFrameRef.current = null;
134
- }
135
- };
136
-
137
- const startPreviewLoop = () => {
138
- stopPreviewLoop();
139
- const renderPreview = () => {
140
- const canvas = displayCanvasRef.current;
141
- const video = localVideoRef.current;
142
-
143
- if (!canvas || !video || !cameraReady || videoManager?.isStreaming) {
144
- previewFrameRef.current = null;
145
- return;
146
- }
147
-
148
- if (video.readyState >= 2) {
149
- const ctx = canvas.getContext('2d');
150
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
151
- }
152
-
153
- previewFrameRef.current = requestAnimationFrame(renderPreview);
154
- };
155
-
156
- previewFrameRef.current = requestAnimationFrame(renderPreview);
157
- };
158
-
159
- const getErrorMessage = (err) => {
160
- if (err?.name === 'NotAllowedError') {
161
- return 'Camera permission denied. Please allow camera access.';
162
- }
163
- if (err?.name === 'NotFoundError') {
164
- return 'No camera found. Please connect a camera.';
165
- }
166
- if (err?.name === 'NotReadableError') {
167
- return 'Camera is already in use by another application.';
168
- }
169
- if (err?.target?.url) {
170
- return `WebSocket connection failed: ${err.target.url}. Check that the backend server is running.`;
171
- }
172
- return err?.message || 'Failed to start focus session.';
173
- };
174
-
175
- useEffect(() => {
176
- if (!videoManager) return;
177
-
178
- const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
179
- const originalOnSessionEnd = videoManager.callbacks.onSessionEnd;
180
-
181
- videoManager.callbacks.onStatusUpdate = (isFocused) => {
182
- setTimelineEvents((prev) => {
183
- const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
184
- if (newEvents.length > 60) newEvents.shift();
185
- return newEvents;
186
- });
187
- setFocusState(isFocused ? FOCUS_STATES.focused : FOCUS_STATES.notFocused);
188
- if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
189
- };
190
-
191
- videoManager.callbacks.onSessionEnd = (summary) => {
192
- setFocusState(FOCUS_STATES.pending);
193
- setCameraReady(false);
194
- if (originalOnSessionEnd) originalOnSessionEnd(summary);
195
- };
196
-
197
- videoManager.callbacks.onCalibrationUpdate = (cal) => {
198
- setCalibration(cal && cal.active ? { ...cal } : null);
199
- };
200
-
201
- const statsInterval = setInterval(() => {
202
- if (videoManager && videoManager.getStats) {
203
- setStats(videoManager.getStats());
204
- }
205
- }, 1000);
206
-
207
- return () => {
208
- if (videoManager) {
209
- videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
210
- videoManager.callbacks.onSessionEnd = originalOnSessionEnd;
211
- videoManager.callbacks.onCalibrationUpdate = null;
212
- }
213
- clearInterval(statsInterval);
214
- };
215
- }, [videoManager]);
216
-
217
- // Fetch available models on mount
218
- useEffect(() => {
219
- fetch('/api/models')
220
- .then((res) => res.json())
221
- .then((data) => {
222
- if (data.available) setAvailableModels(data.available);
223
- if (data.current) {
224
- // If L2CS was the active model, switch to a base model + enable boost
225
- if (data.current === 'l2cs') {
226
- const fallback = data.available.find((m) => m !== 'l2cs') || 'mlp';
227
- setCurrentModel(fallback);
228
- handleModelChange(fallback);
229
- } else {
230
- setCurrentModel(data.current);
231
- }
232
- }
233
- if (data.l2cs_boost !== undefined) setL2csBoost(data.l2cs_boost);
234
- if (data.l2cs_boost_available !== undefined) setL2csBoostAvailable(data.l2cs_boost_available);
235
- })
236
- .catch((err) => console.error('Failed to fetch models:', err));
237
- }, []);
238
-
239
- useEffect(() => {
240
- if (flowStep === FLOW_STEPS.ready && cameraReady && !videoManager?.isStreaming) {
241
- startPreviewLoop();
242
- return;
243
- }
244
- stopPreviewLoop();
245
- }, [cameraReady, flowStep, videoManager?.isStreaming]);
246
-
247
- useEffect(() => {
248
- if (!isActive) {
249
- stopPreviewLoop();
250
- }
251
- }, [isActive]);
252
-
253
- useEffect(() => {
254
- return () => {
255
- stopPreviewLoop();
256
- if (pipVideoRef.current) {
257
- pipVideoRef.current.pause();
258
- pipVideoRef.current.srcObject = null;
259
- }
260
- if (pipStreamRef.current) {
261
- pipStreamRef.current.getTracks().forEach((t) => t.stop());
262
- pipStreamRef.current = null;
263
- }
264
- };
265
- }, []);
266
-
267
- // Poll server CPU/memory for UI
268
- useEffect(() => {
269
- const fetchSystem = () => {
270
- fetch('/api/stats/system')
271
- .then(res => res.json())
272
- .then(data => setSystemStats(data))
273
- .catch(() => setSystemStats(null));
274
- };
275
- fetchSystem();
276
- const interval = setInterval(fetchSystem, 3000);
277
- return () => clearInterval(interval);
278
- }, []);
279
-
280
- const handleModelChange = async (modelName) => {
281
- try {
282
- const res = await fetch('/api/settings', {
283
- method: 'PUT',
284
- headers: { 'Content-Type': 'application/json' },
285
- body: JSON.stringify({ model_name: modelName })
286
- });
287
- const result = await res.json();
288
- if (result.updated) {
289
- setCurrentModel(modelName);
290
- }
291
- } catch (err) {
292
- console.error('Failed to switch model:', err);
293
- }
294
- };
295
-
296
- const handleEnableCamera = async () => {
297
- if (!videoManager) return;
298
-
299
- try {
300
- setCameraError('');
301
- await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
302
- setCameraReady(true);
303
- setFlowStep(FLOW_STEPS.ready);
304
- setFocusState(FOCUS_STATES.pending);
305
- } catch (err) {
306
- const errorMessage = getErrorMessage(err);
307
- setCameraError(errorMessage);
308
- console.error('Camera init error:', err);
309
- }
310
- };
311
-
312
- const applyEyeGazeChange = async (enable, withCalibration = true) => {
313
- try {
314
- const res = await fetch('/api/settings', {
315
- method: 'PUT',
316
- headers: { 'Content-Type': 'application/json' },
317
- body: JSON.stringify({ l2cs_boost: enable })
318
- });
319
- if (!res.ok) return;
320
- setL2csBoost(enable);
321
-
322
- if (enable && withCalibration && videoManager && videoManager.isStreaming) {
323
- videoManager.startCalibration();
324
- } else if (!enable && videoManager) {
325
- videoManager.cancelCalibration();
326
- }
327
- } catch (err) {
328
- console.error('Failed to toggle eye gaze:', err);
329
- }
330
- };
331
-
332
- const handleEyeGazeToggle = async () => {
333
- const next = !l2csBoost;
334
- if (next && !eyeGazeDontShow) {
335
- // Show the warning/calibration modal before enabling
336
- setShowEyeGazeModal(true);
337
- return;
338
- }
339
- await applyEyeGazeChange(next, true);
340
- };
341
-
342
- const handleEyeGazeModalAction = async (withCalibration) => {
343
- if (eyeGazeDontShow) {
344
- localStorage.setItem('focusguard_eyegaze_noshowalert', 'true');
345
- }
346
- setShowEyeGazeModal(false);
347
- await applyEyeGazeChange(true, withCalibration);
348
- };
349
-
350
- const handleStart = async () => {
351
- try {
352
- setIsStarting(true);
353
- setSessionResult(null);
354
- setTimelineEvents([]);
355
- setFocusState(FOCUS_STATES.pending);
356
- setCameraError('');
357
-
358
- if (!cameraReady) {
359
- await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
360
- setCameraReady(true);
361
- setFlowStep(FLOW_STEPS.ready);
362
- }
363
-
364
- await videoManager.startStreaming();
365
- } catch (err) {
366
- const errorMessage = getErrorMessage(err);
367
- setCameraError(errorMessage);
368
- setFocusState(FOCUS_STATES.pending);
369
- console.error('Start error:', err);
370
- alert(`Failed to start: ${errorMessage}\n\nCheck browser console for details.`);
371
- } finally {
372
- setIsStarting(false);
373
- }
374
- };
375
-
376
- const handleStop = async () => {
377
- if (videoManager) {
378
- await videoManager.stopStreaming();
379
- }
380
- try {
381
- if (document.pictureInPictureElement === pipVideoRef.current) {
382
- await document.exitPictureInPicture();
383
- }
384
- } catch (_) {}
385
- if (pipVideoRef.current) {
386
- pipVideoRef.current.pause();
387
- pipVideoRef.current.srcObject = null;
388
- }
389
- if (pipStreamRef.current) {
390
- pipStreamRef.current.getTracks().forEach((t) => t.stop());
391
- pipStreamRef.current = null;
392
- }
393
- stopPreviewLoop();
394
- setFocusState(FOCUS_STATES.pending);
395
- setCameraReady(false);
396
- };
397
-
398
- const handlePiP = async () => {
399
- try {
400
- //
401
- if (!videoManager || !videoManager.isStreaming) {
402
- alert('Please start the video first.');
403
- return;
404
- }
405
-
406
- if (!displayCanvasRef.current) {
407
- alert('Video not ready.');
408
- return;
409
- }
410
-
411
- //
412
- if (document.pictureInPictureElement === pipVideoRef.current) {
413
- await document.exitPictureInPicture();
414
- console.log('PiP exited');
415
- return;
416
- }
417
-
418
- //
419
- if (!document.pictureInPictureEnabled) {
420
- alert('Picture-in-Picture is not supported in this browser.');
421
- return;
422
- }
423
-
424
- //
425
- const pipVideo = pipVideoRef.current;
426
- if (!pipVideo) {
427
- alert('PiP video element not ready.');
428
- return;
429
- }
430
-
431
- const isSafariPiP = typeof pipVideo.webkitSetPresentationMode === 'function';
432
-
433
- //
434
- let stream = pipStreamRef.current;
435
- if (!stream) {
436
- const capture = displayCanvasRef.current.captureStream;
437
- if (typeof capture === 'function') {
438
- stream = capture.call(displayCanvasRef.current, 30);
439
- }
440
- if (!stream || stream.getTracks().length === 0) {
441
- const cameraStream = localVideoRef.current?.srcObject;
442
- if (!cameraStream) {
443
- alert('Camera stream not ready.');
444
- return;
445
- }
446
- stream = cameraStream;
447
- }
448
- pipStreamRef.current = stream;
449
- }
450
-
451
- //
452
- if (!stream || stream.getTracks().length === 0) {
453
- alert('Failed to capture video stream from canvas.');
454
- return;
455
- }
456
-
457
- pipVideo.srcObject = stream;
458
-
459
- //
460
- if (pipVideo.readyState < 2) {
461
- await new Promise((resolve) => {
462
- const onReady = () => {
463
- pipVideo.removeEventListener('loadeddata', onReady);
464
- pipVideo.removeEventListener('canplay', onReady);
465
- resolve();
466
- };
467
- pipVideo.addEventListener('loadeddata', onReady);
468
- pipVideo.addEventListener('canplay', onReady);
469
- //
470
- setTimeout(resolve, 600);
471
- });
472
- }
473
-
474
- try {
475
- await pipVideo.play();
476
- } catch (_) {}
477
-
478
- //
479
- if (isSafariPiP) {
480
- try {
481
- pipVideo.webkitSetPresentationMode('picture-in-picture');
482
- console.log('PiP activated (Safari)');
483
- return;
484
- } catch (e) {
485
- //
486
- const cameraStream = localVideoRef.current?.srcObject;
487
- if (cameraStream && cameraStream !== pipVideo.srcObject) {
488
- pipVideo.srcObject = cameraStream;
489
- try {
490
- await pipVideo.play();
491
- } catch (_) {}
492
- pipVideo.webkitSetPresentationMode('picture-in-picture');
493
- console.log('PiP activated (Safari fallback)');
494
- return;
495
- }
496
- throw e;
497
- }
498
- }
499
-
500
- //
501
- if (typeof pipVideo.requestPictureInPicture === 'function') {
502
- await pipVideo.requestPictureInPicture();
503
- console.log('PiP activated');
504
- } else {
505
- alert('Picture-in-Picture is not supported in this browser.');
506
- }
507
-
508
- } catch (err) {
509
- console.error('PiP error:', err);
510
- alert(`Failed to enter Picture-in-Picture: ${err.message}`);
511
- }
512
- };
513
-
514
- const handleFloatingWindow = () => {
515
- handlePiP();
516
- };
517
-
518
- const handleFrameChange = (val) => {
519
- const rate = parseInt(val, 10);
520
- setCurrentFrame(rate);
521
- if (videoManager) {
522
- videoManager.setFrameRate(rate);
523
- }
524
- };
525
-
526
- const handlePreview = () => {
527
- if (!videoManager || !videoManager.isStreaming) {
528
- alert('Please start a session first.');
529
- return;
530
- }
531
-
532
- //
533
- const currentStats = videoManager.getStats();
534
-
535
- if (!currentStats.sessionId) {
536
- alert('No active session.');
537
- return;
538
- }
539
-
540
- const sessionDuration = Math.floor((Date.now() - (videoManager.sessionStartTime || Date.now())) / 1000);
541
- const totalFrames = currentStats.framesProcessed || 0;
542
- const focusedFrames = currentStats.focusedFrames ?? 0;
543
- const focusScore = totalFrames > 0 ? focusedFrames / totalFrames : 0;
544
-
545
- setSessionResult({
546
- duration_seconds: sessionDuration,
547
- focus_score: focusScore,
548
- total_frames: totalFrames,
549
- focused_frames: focusedFrames
550
- });
551
- };
552
-
553
- const handleCloseOverlay = () => {
554
- setSessionResult(null);
555
- };
556
-
557
- const pageStyle = isActive
558
- ? undefined
559
- : {
560
- position: 'absolute',
561
- width: '1px',
562
- height: '1px',
563
- overflow: 'hidden',
564
- opacity: 0,
565
- pointerEvents: 'none'
566
- };
567
-
568
- const focusStateLabel = {
569
- [FOCUS_STATES.pending]: 'Pending',
570
- [FOCUS_STATES.focused]: 'Focused',
571
- [FOCUS_STATES.notFocused]: 'Not Focused'
572
- }[focusState];
573
-
574
- const introHighlights = [
575
- {
576
- title: 'Live focus tracking',
577
- text: 'Head pose, gaze, and eye openness are read continuously during the session.'
578
- },
579
- {
580
- title: 'Quick setup',
581
- text: 'Front-facing light and a stable camera angle give the cleanest preview.'
582
- },
583
- {
584
- title: 'Private by default',
585
- text: 'Only session metadata is stored, not the raw camera footage.'
586
- }
587
- ];
588
-
589
- const permissionSteps = [
590
- {
591
- title: 'Allow browser access',
592
- text: 'Approve the camera prompt so the preview can appear immediately.'
593
- },
594
- {
595
- title: 'Check your framing',
596
- text: 'Keep your face visible and centered for more stable landmark detection.'
597
- },
598
- {
599
- title: 'Start when ready',
600
- text: 'After the preview appears, use the page controls to begin or stop.'
601
- }
602
- ];
603
-
604
- const renderIntroCard = () => {
605
- if (flowStep === FLOW_STEPS.intro) {
606
- return (
607
- <div className="focus-flow-overlay">
608
- <div className="focus-flow-card">
609
- <div className="focus-flow-header">
610
- <div>
611
- <div className="focus-flow-eyebrow">Focus Session</div>
612
- <h2>Before you begin</h2>
613
- </div>
614
- <div className="focus-flow-icon">
615
- <HelloIcon />
616
- </div>
617
- </div>
618
-
619
- <p className="focus-flow-lead">
620
- The focus page uses your live camera preview to estimate attention in real time.
621
- Review the setup notes below, then continue to camera access.
622
- </p>
623
-
624
- <div className="focus-flow-grid">
625
- {introHighlights.map((item) => (
626
- <article key={item.title} className="focus-flow-panel">
627
- <h3>{item.title}</h3>
628
- <p>{item.text}</p>
629
- </article>
630
- ))}
631
- </div>
632
-
633
- <div className="focus-flow-glasses-note">
634
- <strong>Wearing glasses?</strong> Glasses may reduce detection accuracy on some models. If results seem inaccurate, try switching to a different model (e.g. Geometric or MLP).
635
- </div>
636
-
637
- <div className="focus-flow-footer">
638
- <div className="focus-flow-note">
639
- You can still change frame rate and available model options after the preview loads.
640
- </div>
641
- <button className="focus-flow-button" onClick={() => setFlowStep(FLOW_STEPS.permission)}>
642
- Continue
643
- </button>
644
- </div>
645
- </div>
646
- </div>
647
- );
648
- }
649
-
650
- if (flowStep === FLOW_STEPS.permission && !cameraReady) {
651
- return (
652
- <div className="focus-flow-overlay">
653
- <div className="focus-flow-card">
654
- <div className="focus-flow-header">
655
- <div>
656
- <div className="focus-flow-eyebrow">Camera Setup</div>
657
- <h2>Enable camera access</h2>
658
- </div>
659
- <div className="focus-flow-icon">
660
- <CameraIcon />
661
- </div>
662
- </div>
663
-
664
- <p className="focus-flow-lead">
665
- Once access is granted, your preview appears here and the rest of the Focus page
666
- behaves like the other dashboard screens.
667
- </p>
668
-
669
- <div className="focus-flow-steps">
670
- {permissionSteps.map((item, index) => (
671
- <div key={item.title} className="focus-flow-step">
672
- <div className="focus-flow-step-number">{index + 1}</div>
673
- <div className="focus-flow-step-copy">
674
- <h3>{item.title}</h3>
675
- <p>{item.text}</p>
676
- </div>
677
- </div>
678
- ))}
679
- </div>
680
-
681
- {cameraError ? <div className="focus-inline-error">{cameraError}</div> : null}
682
-
683
- <div className="focus-flow-footer">
684
- <button
685
- type="button"
686
- className="focus-flow-secondary"
687
- onClick={() => setFlowStep(FLOW_STEPS.intro)}
688
- >
689
- Back
690
- </button>
691
- <button className="focus-flow-button" onClick={handleEnableCamera}>
692
- Enable Camera
693
- </button>
694
- </div>
695
- </div>
696
- </div>
697
- );
698
- }
699
-
700
- return null;
701
- };
702
-
703
- const renderEyeGazeModal = () => {
704
- if (!showEyeGazeModal) return null;
705
- return (
706
- <div className="focus-flow-overlay" style={{ zIndex: 2000 }}>
707
- <div className="focus-flow-card">
708
- <div className="focus-flow-header">
709
- <div>
710
- <div className="focus-flow-eyebrow">Eye Gaze Tracking</div>
711
- <h2>Before you enable</h2>
712
- </div>
713
- <div className="focus-flow-icon">
714
- <svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
715
- <ellipse cx="48" cy="48" rx="38" ry="24" fill="none" stroke="#007BFF" strokeWidth="5" />
716
- <circle cx="48" cy="48" r="13" fill="none" stroke="#007BFF" strokeWidth="5" />
717
- <circle cx="48" cy="48" r="5" fill="#007BFF" />
718
- </svg>
719
- </div>
720
- </div>
721
-
722
- <p className="focus-flow-lead">
723
- Eye gaze tracking runs an additional deep neural network (L2CS-Net) alongside your current model.
724
- Please read the notes below before proceeding.
725
- </p>
726
-
727
- <div className="focus-flow-grid">
728
- <article className="focus-flow-panel focus-flow-panel-warn">
729
- <h3>Performance impact</h3>
730
- <p>Enabling eye gaze tracking increases CPU usage and may reduce frame rate. If the system feels sluggish, consider disabling it.</p>
731
- </article>
732
- <article className="focus-flow-panel">
733
- <h3>Calibration (recommended)</h3>
734
- <p>For best accuracy, calibrate by looking at 9 screen positions one at a time, followed by 1 validation point. The whole process takes about 30 seconds.</p>
735
- </article>
736
- </div>
737
-
738
- <div className="focus-flow-steps">
739
- <div className="focus-flow-step">
740
- <div className="focus-flow-step-number">1</div>
741
- <div className="focus-flow-step-copy">
742
- <h3>Click "Start Calibration"</h3>
743
- <p>A dot will appear on screen. Look directly at it and keep your gaze steady. It will cycle through 9 positions then show a final validation dot.</p>
744
- </div>
745
- </div>
746
- <div className="focus-flow-step">
747
- <div className="focus-flow-step-number">2</div>
748
- <div className="focus-flow-step-copy">
749
- <h3>Or skip for now</h3>
750
- <p>Click "Skip" to enable eye gaze tracking without calibrating. You can recalibrate at any time using the "Recalibrate" button during a session.</p>
751
- </div>
752
- </div>
753
- </div>
754
-
755
- <label className="eye-gaze-modal-checkbox">
756
- <input
757
- type="checkbox"
758
- checked={eyeGazeDontShow}
759
- onChange={(e) => setEyeGazeDontShow(e.target.checked)}
760
- />
761
- Don't show this again
762
- </label>
763
-
764
- <div className="focus-flow-footer">
765
- <button
766
- type="button"
767
- className="focus-flow-secondary"
768
- onClick={() => handleEyeGazeModalAction(false)}
769
- >
770
- Skip
771
- </button>
772
- <button
773
- className="focus-flow-button"
774
- onClick={() => handleEyeGazeModalAction(true)}
775
- >
776
- Start Calibration
777
- </button>
778
- </div>
779
- </div>
780
- </div>
781
- );
782
- };
783
-
784
- return (
785
- <main id="page-b" className="page" style={pageStyle}>
786
- {renderIntroCard()}
787
- {renderEyeGazeModal()}
788
-
789
- <section id="display-area" className="focus-display-shell">
790
- <video
791
- ref={pipVideoRef}
792
- muted
793
- playsInline
794
- autoPlay
795
- style={{
796
- position: 'absolute',
797
- width: '1px',
798
- height: '1px',
799
- opacity: 0,
800
- pointerEvents: 'none'
801
- }}
802
- />
803
- {/* local video (hidden, for capture) */}
804
- <video
805
- ref={localVideoRef}
806
- muted
807
- playsInline
808
- autoPlay
809
- style={{ display: 'none' }}
810
- />
811
-
812
- {/* processed video (canvas) */}
813
- <canvas
814
- ref={displayCanvasRef}
815
- width={640}
816
- height={480}
817
- style={{
818
- width: '100%',
819
- height: '100%',
820
- objectFit: 'contain',
821
- backgroundColor: '#101010'
822
- }}
823
- />
824
-
825
- {flowStep === FLOW_STEPS.ready ? (
826
- <>
827
- <div className={`focus-state-pill ${focusState}`}>
828
- <span className="focus-state-dot" />
829
- {focusStateLabel}
830
- </div>
831
- {!cameraReady && !videoManager?.isStreaming ? (
832
- <div className="focus-idle-overlay">
833
- <p>Camera is paused.</p>
834
- <span>Use Start to enable the camera and begin detection.</span>
835
- </div>
836
- ) : null}
837
- </>
838
- ) : null}
839
-
840
- {sessionResult && (
841
- <div className="session-result-overlay">
842
- <h3>Session Complete!</h3>
843
- <div className="result-item">
844
- <span className="label">Duration:</span>
845
- <span className="value">{formatDuration(sessionResult.duration_seconds)}</span>
846
- </div>
847
- <div className="result-item">
848
- <span className="label">Focus Score:</span>
849
- <span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
850
- </div>
851
-
852
- <button
853
- onClick={handleCloseOverlay}
854
- style={{
855
- marginTop: '20px',
856
- padding: '8px 20px',
857
- background: 'transparent',
858
- border: '1px solid white',
859
- color: 'white',
860
- borderRadius: '20px',
861
- cursor: 'pointer'
862
- }}
863
- >
864
- Close
865
- </button>
866
- </div>
867
- )}
868
-
869
- </section>
870
-
871
- {flowStep === FLOW_STEPS.ready ? (
872
- <>
873
- {/* Model selector */}
874
- {availableModels.length > 0 ? (
875
- <section className="focus-model-strip">
876
- <span className="focus-model-label">Model:</span>
877
- {MODEL_ORDER.filter((n) => availableModels.includes(n)).map((name) => (
878
- <button
879
- key={name}
880
- onClick={() => handleModelChange(name)}
881
- className={`focus-model-button ${currentModel === name ? 'active' : ''}`}
882
- >
883
- {MODEL_INFO[name]?.label || name}
884
- </button>
885
- ))}
886
-
887
- {l2csBoostAvailable && (
888
- <>
889
- <span className="focus-model-sep" />
890
- <button
891
- onClick={handleEyeGazeToggle}
892
- className={`eye-gaze-toggle ${l2csBoost ? 'on' : 'off'}`}
893
- title={l2csBoost ? 'Eye gaze tracking active — click to disable' : 'Enable eye gaze tracking (requires calibration)'}
894
- >
895
- <svg width="16" height="16" viewBox="0 0 16 16" className="eye-gaze-icon" aria-hidden="true">
896
- <ellipse cx="8" cy="8" rx="7" ry="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
897
- <circle cx="8" cy="8" r="2.2" fill="currentColor" />
898
- </svg>
899
- {l2csBoost ? 'Eye Gaze On' : 'Eye Gaze'}
900
- </button>
901
- {l2csBoost && stats && stats.isStreaming && (
902
- <button
903
- onClick={() => videoManager && videoManager.startCalibration()}
904
- className="focus-model-button recalibrate"
905
- title="Re-run gaze calibration"
906
- >
907
- Recalibrate
908
- </button>
909
- )}
910
- </>
911
- )}
912
- </section>
913
- ) : null}
914
-
915
- {/* Server stats */}
916
- {systemStats && systemStats.cpu_percent != null && (
917
- <section className="focus-system-stats">
918
- <span>CPU: <strong>{systemStats.cpu_percent}%</strong></span>
919
- <span className="focus-system-stats-sep" />
920
- <span>RAM: <strong>{systemStats.memory_percent}%</strong> ({systemStats.memory_used_mb}/{systemStats.memory_total_mb} MB)</span>
921
- </section>
922
- )}
923
-
924
- <section id="timeline-area">
925
- <div className="timeline-label">Timeline</div>
926
- <div id="timeline-visuals">
927
- {timelineEvents.map((event, index) => (
928
- <div
929
- key={index}
930
- className="timeline-block"
931
- style={{
932
- backgroundColor: event.isFocused ? '#28a745' : '#dc3545',
933
- width: '10px',
934
- height: '20px',
935
- borderRadius: '2px',
936
- flexShrink: 0
937
- }}
938
- title={event.isFocused ? 'Focused' : 'Distracted'}
939
- />
940
- ))}
941
- </div>
942
- <div id="timeline-line" />
943
- </section>
944
-
945
- <section id="control-panel">
946
- <button id="btn-cam-start" className="action-btn green" onClick={handleStart} disabled={isStarting}>
947
- {isStarting ? 'Starting...' : 'Start'}
948
- </button>
949
-
950
- <button id="btn-floating" className="action-btn blue" onClick={handlePiP}>
951
- Floating Window
952
- </button>
953
-
954
- <button id="btn-preview" className="action-btn orange" onClick={handlePreview}>
955
- Preview Result
956
- </button>
957
-
958
- <button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>
959
- Stop
960
- </button>
961
- </section>
962
-
963
- {cameraError ? (
964
- <div className="focus-inline-error focus-inline-error-standalone">{cameraError}</div>
965
- ) : null}
966
-
967
- {/* Model info card — below action buttons */}
968
- {MODEL_INFO[currentModel] && (
969
- <section className="model-card">
970
- <div className="model-card-header">
971
- <h3 className="model-card-title">{MODEL_INFO[currentModel].label}</h3>
972
- {MODEL_INFO[currentModel].badge && (
973
- <span className={MODEL_INFO[currentModel].badge === 'Baseline' ? 'model-card-badge-baseline' : 'model-card-badge'}>
974
- {MODEL_INFO[currentModel].badge}
975
- </span>
976
- )}
977
- </div>
978
- <p className="model-card-tagline">{MODEL_INFO[currentModel].tagline}</p>
979
-
980
- <div className="model-card-metrics">
981
- <div className="model-card-metric">
982
- <span className="model-card-metric-value">{MODEL_INFO[currentModel].accuracy}</span>
983
- <span className="model-card-metric-label">Accuracy</span>
984
- </div>
985
- <div className="model-card-metric">
986
- <span className="model-card-metric-value">{MODEL_INFO[currentModel].f1}</span>
987
- <span className="model-card-metric-label">F1 Score</span>
988
- </div>
989
- <div className="model-card-metric">
990
- <span className="model-card-metric-value">{MODEL_INFO[currentModel].auc}</span>
991
- <span className="model-card-metric-label">ROC-AUC</span>
992
- </div>
993
- <div className="model-card-metric">
994
- <span className="model-card-metric-value">{MODEL_INFO[currentModel].threshold}</span>
995
- <span className="model-card-metric-label">Threshold</span>
996
- </div>
997
- </div>
998
-
999
- <div className="model-card-details">
1000
- <div className="model-card-section">
1001
- <h4>How it works</h4>
1002
- <p>{MODEL_INFO[currentModel].how}</p>
1003
- </div>
1004
- <div className="model-card-section">
1005
- <h4>Features used</h4>
1006
- <p>{MODEL_INFO[currentModel].features}</p>
1007
- </div>
1008
- <div className="model-card-section">
1009
- <h4>Strengths</h4>
1010
- <p>{MODEL_INFO[currentModel].strengths}</p>
1011
- </div>
1012
- </div>
1013
-
1014
- <div className="model-card-eval">
1015
- Evaluated with {MODEL_INFO[currentModel].evaluation}
1016
- </div>
1017
- </section>
1018
- )}
1019
-
1020
- <section id="frame-control">
1021
- <label htmlFor="frame-slider">Frame Rate (FPS)</label>
1022
- <input
1023
- type="range"
1024
- id="frame-slider"
1025
- min="10"
1026
- max="30"
1027
- value={currentFrame}
1028
- onChange={(e) => handleFrameChange(e.target.value)}
1029
- />
1030
- <input
1031
- type="number"
1032
- id="frame-input"
1033
- min="10"
1034
- max="30"
1035
- value={currentFrame}
1036
- onChange={(e) => handleFrameChange(e.target.value)}
1037
- />
1038
- </section>
1039
- </>
1040
- ) : null}
1041
-
1042
- {/* Calibration overlay (fixed fullscreen, must be outside overflow:hidden containers) */}
1043
- <CalibrationOverlay calibration={calibration} videoManager={videoManager} />
1044
- </main>
1045
- );
1046
- }
1047
-
1048
- export default FocusPageLocal;
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import CalibrationOverlay from './CalibrationOverlay';
3
+
4
+ const FLOW_STEPS = {
5
+ intro: 'intro',
6
+ permission: 'permission',
7
+ ready: 'ready'
8
+ };
9
+
10
+ const FOCUS_STATES = {
11
+ pending: 'pending',
12
+ focused: 'focused',
13
+ notFocused: 'not-focused'
14
+ };
15
+
16
+ function HelloIcon() {
17
+ return (
18
+ <svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
19
+ <circle cx="48" cy="48" r="40" fill="#007BFF" />
20
+ <path d="M30 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
21
+ <path d="M54 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
22
+ <path d="M30 52c3 11 10 17 18 17s15-6 18-17" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
23
+ </svg>
24
+ );
25
+ }
26
+
27
+ function CameraIcon() {
28
+ return (
29
+ <svg width="110" height="110" viewBox="0 0 110 110" aria-hidden="true">
30
+ <rect x="30" y="36" width="50" height="34" rx="5" fill="none" stroke="#007BFF" strokeWidth="6" />
31
+ <path d="M24 72h62c0 9-7 16-16 16H40c-9 0-16-7-16-16Z" fill="none" stroke="#007BFF" strokeWidth="6" />
32
+ <path d="M55 28v8" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
33
+ <circle cx="55" cy="36" r="14" fill="none" stroke="#007BFF" strokeWidth="6" />
34
+ <circle cx="55" cy="36" r="4" fill="#007BFF" />
35
+ <path d="M46 83h18" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ const MODEL_ORDER = ['hybrid', 'xgboost', 'mlp', 'geometric'];
41
+
42
+ const MODEL_INFO = {
43
+ hybrid: {
44
+ label: 'Hybrid',
45
+ tagline: 'Best overall — combines ML with geometric scoring',
46
+ how: 'Fuses XGBoost predictions (30%) with geometric face/eye scores (70%). Uses a weighted blend tuned with LOPO evaluation.',
47
+ accuracy: 'N/A',
48
+ f1: '0.8409',
49
+ auc: 'N/A',
50
+ threshold: '0.46',
51
+ evaluation: 'LOPO tuning (9 participants, 144K frames)',
52
+ features: '10 features: head deviation, face score, eye scores (EAR), gaze offset, pitch, horizontal gaze, PERCLOS',
53
+ strengths: 'Most robust across different people. Latest LOPO mean F1 is 0.8409 at w_mlp=0.3.',
54
+ badge: 'Recommended',
55
+ },
56
+ xgboost: {
57
+ label: 'XGBoost',
58
+ tagline: 'Highest raw accuracy — gradient-boosted decision trees',
59
+ how: 'Ensemble of 600 decision trees (max depth 8). Each tree learns to correct errors from previous trees. Outputs probability of focused state.',
60
+ accuracy: '95.87%',
61
+ f1: '0.9585',
62
+ auc: '0.9908',
63
+ threshold: '0.38',
64
+ evaluation: 'Random split test (15%) + LOPO thresholds',
65
+ features: '10 features: head deviation, face score, eye scores (EAR), gaze offset, pitch, horizontal gaze, PERCLOS',
66
+ strengths: 'Strong pattern recognition and fast inference. LOPO: AUC 0.8695, optimal threshold 0.280, F1 0.8549.',
67
+ badge: null,
68
+ },
69
+ mlp: {
70
+ label: 'MLP',
71
+ tagline: 'Lightweight neural network — fast and efficient',
72
+ how: 'Two-layer neural network (64→32 neurons). Takes 10 face features, applies learned weights, outputs focused/unfocused probability via softmax.',
73
+ accuracy: '92.92%',
74
+ f1: '0.9287',
75
+ auc: '0.9714',
76
+ threshold: '0.23',
77
+ evaluation: 'Random split test (15%) + LOPO thresholds',
78
+ features: '10 features: head deviation, face score, eye scores (EAR), gaze offset, pitch, horizontal gaze, PERCLOS',
79
+ strengths: 'Fastest inference and smallest model size. LOPO: AUC 0.8624, optimal threshold 0.228, F1 0.8578.',
80
+ badge: null,
81
+ },
82
+ geometric: {
83
+ label: 'Geometric',
84
+ tagline: 'Baseline only — hardcoded thresholds, no learning',
85
+ how: 'Uses fixed thresholds on head orientation (70%) and eye openness (30%). No training — just hand-tuned rules on 478 face landmarks. Cannot adapt to new faces or environments.',
86
+ accuracy: 'N/A',
87
+ f1: '0.8195',
88
+ auc: 'N/A',
89
+ threshold: '0.55',
90
+ evaluation: 'LOPO geometric sweep',
91
+ features: 'Head yaw/pitch/roll angles, eye aspect ratio (EAR), iris gaze offset, mouth aspect ratio (MAR)',
92
+ strengths: 'No model files needed. Useful fallback when model checkpoints are unavailable.',
93
+ badge: 'Baseline',
94
+ },
95
+ };
96
+
97
+ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActive, isTutorialActive, setIsTutorialActive, setHasSeenTutorial }) {
98
+ const [currentFrame, setCurrentFrame] = useState(15);
99
+ const [timelineEvents, setTimelineEvents] = useState([]);
100
+ const [stats, setStats] = useState(null);
101
+ const [systemStats, setSystemStats] = useState(null);
102
+ const [availableModels, setAvailableModels] = useState([]);
103
+ const [currentModel, setCurrentModel] = useState('mlp');
104
+ const [flowStep, setFlowStep] = useState(FLOW_STEPS.ready);
105
+ const [cameraReady, setCameraReady] = useState(false);
106
+ const [isStarting, setIsStarting] = useState(false);
107
+ const [focusState, setFocusState] = useState(FOCUS_STATES.pending);
108
+ const [cameraError, setCameraError] = useState('');
109
+ const [calibration, setCalibration] = useState(null);
110
+ const [l2csBoost, setL2csBoost] = useState(false);
111
+ const [l2csBoostAvailable, setL2csBoostAvailable] = useState(false);
112
+ const [showEyeGazeModal, setShowEyeGazeModal] = useState(false);
113
+ const [eyeGazeDontShow, setEyeGazeDontShow] = useState(
114
+ () => localStorage.getItem('focusguard_eyegaze_noshowalert') === 'true'
115
+ );
116
+
117
+ const localVideoRef = useRef(null);
118
+ const displayCanvasRef = useRef(null);
119
+ const pipVideoRef = useRef(null);
120
+ const pipStreamRef = useRef(null);
121
+ const previewFrameRef = useRef(null);
122
+
123
+ useEffect(() => {
124
+ if (isTutorialActive) {
125
+ setFlowStep(FLOW_STEPS.intro);
126
+ } else {
127
+ setFlowStep(FLOW_STEPS.ready);
128
+ }
129
+ }, [isTutorialActive]);
130
+
131
+ const formatDuration = (seconds) => {
132
+ if (seconds === 0) return '0s';
133
+ const mins = Math.floor(seconds / 60);
134
+ const secs = Math.floor(seconds % 60);
135
+ return `${mins}m ${secs}s`;
136
+ };
137
+
138
+ const stopPreviewLoop = () => {
139
+ if (previewFrameRef.current) {
140
+ cancelAnimationFrame(previewFrameRef.current);
141
+ previewFrameRef.current = null;
142
+ }
143
+ };
144
+
145
+ const startPreviewLoop = () => {
146
+ stopPreviewLoop();
147
+ const renderPreview = () => {
148
+ const canvas = displayCanvasRef.current;
149
+ const video = localVideoRef.current;
150
+
151
+ if (!canvas || !video || !cameraReady || videoManager?.isStreaming) {
152
+ previewFrameRef.current = null;
153
+ return;
154
+ }
155
+
156
+ if (video.readyState >= 2) {
157
+ const ctx = canvas.getContext('2d');
158
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
159
+ }
160
+
161
+ previewFrameRef.current = requestAnimationFrame(renderPreview);
162
+ };
163
+
164
+ previewFrameRef.current = requestAnimationFrame(renderPreview);
165
+ };
166
+
167
+ const getErrorMessage = (err) => {
168
+ if (err?.name === 'NotAllowedError') {
169
+ return 'Camera permission denied. Please allow camera access.';
170
+ }
171
+ if (err?.name === 'NotFoundError') {
172
+ return 'No camera found. Please connect a camera.';
173
+ }
174
+ if (err?.name === 'NotReadableError') {
175
+ return 'Camera is already in use by another application.';
176
+ }
177
+ if (err?.target?.url) {
178
+ return `WebSocket connection failed: ${err.target.url}. Check that the backend server is running.`;
179
+ }
180
+ return err?.message || 'Failed to start focus session.';
181
+ };
182
+
183
+ useEffect(() => {
184
+ if (!videoManager) return;
185
+
186
+ const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
187
+ const originalOnSessionEnd = videoManager.callbacks.onSessionEnd;
188
+
189
+ videoManager.callbacks.onStatusUpdate = (isFocused) => {
190
+ setTimelineEvents((prev) => {
191
+ const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
192
+ if (newEvents.length > 60) newEvents.shift();
193
+ return newEvents;
194
+ });
195
+ setFocusState(isFocused ? FOCUS_STATES.focused : FOCUS_STATES.notFocused);
196
+ if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
197
+ };
198
+
199
+ videoManager.callbacks.onSessionEnd = (summary) => {
200
+ setFocusState(FOCUS_STATES.pending);
201
+ setCameraReady(false);
202
+ if (originalOnSessionEnd) originalOnSessionEnd(summary);
203
+ };
204
+
205
+ videoManager.callbacks.onCalibrationUpdate = (cal) => {
206
+ setCalibration(cal && cal.active ? { ...cal } : null);
207
+ };
208
+
209
+ const statsInterval = setInterval(() => {
210
+ if (videoManager && videoManager.getStats) {
211
+ setStats(videoManager.getStats());
212
+ }
213
+ }, 1000);
214
+
215
+ return () => {
216
+ if (videoManager) {
217
+ videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
218
+ videoManager.callbacks.onSessionEnd = originalOnSessionEnd;
219
+ videoManager.callbacks.onCalibrationUpdate = null;
220
+ }
221
+ clearInterval(statsInterval);
222
+ };
223
+ }, [videoManager]);
224
+
225
+ useEffect(() => {
226
+ fetch('/api/models')
227
+ .then((res) => res.json())
228
+ .then((data) => {
229
+ if (data.available) setAvailableModels(data.available);
230
+ if (data.current) {
231
+ if (data.current === 'l2cs') {
232
+ const fallback = data.available.find((m) => m !== 'l2cs') || 'mlp';
233
+ setCurrentModel(fallback);
234
+ handleModelChange(fallback);
235
+ } else {
236
+ setCurrentModel(data.current);
237
+ }
238
+ }
239
+ if (data.l2cs_boost !== undefined) setL2csBoost(data.l2cs_boost);
240
+ if (data.l2cs_boost_available !== undefined) setL2csBoostAvailable(data.l2cs_boost_available);
241
+ })
242
+ .catch((err) => console.error('Failed to fetch models:', err));
243
+ }, []);
244
+
245
+ useEffect(() => {
246
+ if (flowStep === FLOW_STEPS.ready && cameraReady && !videoManager?.isStreaming) {
247
+ startPreviewLoop();
248
+ return;
249
+ }
250
+ stopPreviewLoop();
251
+ }, [cameraReady, flowStep, videoManager?.isStreaming]);
252
+
253
+ useEffect(() => {
254
+ if (!isActive) {
255
+ stopPreviewLoop();
256
+ }
257
+ }, [isActive]);
258
+
259
+ useEffect(() => {
260
+ return () => {
261
+ stopPreviewLoop();
262
+ if (pipVideoRef.current) {
263
+ pipVideoRef.current.pause();
264
+ pipVideoRef.current.srcObject = null;
265
+ }
266
+ if (pipStreamRef.current) {
267
+ pipStreamRef.current.getTracks().forEach((t) => t.stop());
268
+ pipStreamRef.current = null;
269
+ }
270
+ };
271
+ }, []);
272
+
273
+ useEffect(() => {
274
+ const fetchSystem = () => {
275
+ fetch('/api/stats/system')
276
+ .then(res => res.json())
277
+ .then(data => setSystemStats(data))
278
+ .catch(() => setSystemStats(null));
279
+ };
280
+ fetchSystem();
281
+ const interval = setInterval(fetchSystem, 3000);
282
+ return () => clearInterval(interval);
283
+ }, []);
284
+
285
+ const handleModelChange = async (modelName) => {
286
+ try {
287
+ const res = await fetch('/api/settings', {
288
+ method: 'PUT',
289
+ headers: { 'Content-Type': 'application/json' },
290
+ body: JSON.stringify({ model_name: modelName })
291
+ });
292
+ const result = await res.json();
293
+ if (result.updated) {
294
+ setCurrentModel(modelName);
295
+ }
296
+ } catch (err) {
297
+ console.error('Failed to switch model:', err);
298
+ }
299
+ };
300
+
301
+ const closeTutorial = () => {
302
+ setFlowStep(FLOW_STEPS.ready);
303
+ setIsTutorialActive(false);
304
+ setHasSeenTutorial(true);
305
+ };
306
+
307
+ const handleEnableCamera = async () => {
308
+ if (!videoManager) return;
309
+ try {
310
+ setCameraError('');
311
+ await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
312
+ setCameraReady(true);
313
+ closeTutorial();
314
+ setFocusState(FOCUS_STATES.pending);
315
+ } catch (err) {
316
+ const errorMessage = getErrorMessage(err);
317
+ setCameraError(errorMessage);
318
+ console.error('Camera init error:', err);
319
+ }
320
+ };
321
+
322
+ const applyEyeGazeChange = async (enable, withCalibration = true) => {
323
+ try {
324
+ const res = await fetch('/api/settings', {
325
+ method: 'PUT',
326
+ headers: { 'Content-Type': 'application/json' },
327
+ body: JSON.stringify({ l2cs_boost: enable })
328
+ });
329
+ if (!res.ok) return;
330
+ setL2csBoost(enable);
331
+
332
+ if (enable && withCalibration && videoManager && videoManager.isStreaming) {
333
+ videoManager.startCalibration();
334
+ } else if (!enable && videoManager) {
335
+ videoManager.cancelCalibration();
336
+ }
337
+ } catch (err) {
338
+ console.error('Failed to toggle eye gaze:', err);
339
+ }
340
+ };
341
+
342
+ const handleEyeGazeToggle = async () => {
343
+ const next = !l2csBoost;
344
+ if (next && !eyeGazeDontShow) {
345
+ setShowEyeGazeModal(true);
346
+ return;
347
+ }
348
+ await applyEyeGazeChange(next, true);
349
+ };
350
+
351
+ const handleEyeGazeModalAction = async (withCalibration) => {
352
+ if (eyeGazeDontShow) {
353
+ localStorage.setItem('focusguard_eyegaze_noshowalert', 'true');
354
+ }
355
+ setShowEyeGazeModal(false);
356
+ await applyEyeGazeChange(true, withCalibration);
357
+ };
358
+
359
+ const handleStart = async () => {
360
+ try {
361
+ setIsStarting(true);
362
+ setSessionResult(null);
363
+ setTimelineEvents([]);
364
+ setFocusState(FOCUS_STATES.pending);
365
+ setCameraError('');
366
+
367
+ if (!cameraReady) {
368
+ await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
369
+ setCameraReady(true);
370
+ setFlowStep(FLOW_STEPS.ready);
371
+ }
372
+
373
+ await videoManager.startStreaming();
374
+ } catch (err) {
375
+ const errorMessage = getErrorMessage(err);
376
+ setCameraError(errorMessage);
377
+ setFocusState(FOCUS_STATES.pending);
378
+ console.error('Start error:', err);
379
+ alert(`Failed to start: ${errorMessage}\n\nCheck browser console for details.`);
380
+ } finally {
381
+ setIsStarting(false);
382
+ }
383
+ };
384
+
385
+ const handleStop = async () => {
386
+ if (videoManager) {
387
+ await videoManager.stopStreaming();
388
+ }
389
+ try {
390
+ if (document.pictureInPictureElement === pipVideoRef.current) {
391
+ await document.exitPictureInPicture();
392
+ }
393
+ } catch (_) {}
394
+ if (pipVideoRef.current) {
395
+ pipVideoRef.current.pause();
396
+ pipVideoRef.current.srcObject = null;
397
+ }
398
+ if (pipStreamRef.current) {
399
+ pipStreamRef.current.getTracks().forEach((t) => t.stop());
400
+ pipStreamRef.current = null;
401
+ }
402
+ stopPreviewLoop();
403
+ setFocusState(FOCUS_STATES.pending);
404
+ setCameraReady(false);
405
+ };
406
+
407
+ const handlePiP = async () => {
408
+ try {
409
+ if (!videoManager || !videoManager.isStreaming) {
410
+ alert('Please start the video first.');
411
+ return;
412
+ }
413
+ if (!displayCanvasRef.current) {
414
+ alert('Video not ready.');
415
+ return;
416
+ }
417
+ if (document.pictureInPictureElement === pipVideoRef.current) {
418
+ await document.exitPictureInPicture();
419
+ console.log('PiP exited');
420
+ return;
421
+ }
422
+ if (!document.pictureInPictureEnabled) {
423
+ alert('Picture-in-Picture is not supported in this browser.');
424
+ return;
425
+ }
426
+
427
+ const pipVideo = pipVideoRef.current;
428
+ if (!pipVideo) {
429
+ alert('PiP video element not ready.');
430
+ return;
431
+ }
432
+
433
+ const isSafariPiP = typeof pipVideo.webkitSetPresentationMode === 'function';
434
+ let stream = pipStreamRef.current;
435
+ if (!stream) {
436
+ const capture = displayCanvasRef.current.captureStream;
437
+ if (typeof capture === 'function') {
438
+ stream = capture.call(displayCanvasRef.current, 30);
439
+ }
440
+ if (!stream || stream.getTracks().length === 0) {
441
+ const cameraStream = localVideoRef.current?.srcObject;
442
+ if (!cameraStream) {
443
+ alert('Camera stream not ready.');
444
+ return;
445
+ }
446
+ stream = cameraStream;
447
+ }
448
+ pipStreamRef.current = stream;
449
+ }
450
+
451
+ if (!stream || stream.getTracks().length === 0) {
452
+ alert('Failed to capture video stream from canvas.');
453
+ return;
454
+ }
455
+
456
+ pipVideo.srcObject = stream;
457
+ if (pipVideo.readyState < 2) {
458
+ await new Promise((resolve) => {
459
+ const onReady = () => {
460
+ pipVideo.removeEventListener('loadeddata', onReady);
461
+ pipVideo.removeEventListener('canplay', onReady);
462
+ resolve();
463
+ };
464
+ pipVideo.addEventListener('loadeddata', onReady);
465
+ pipVideo.addEventListener('canplay', onReady);
466
+ setTimeout(resolve, 600);
467
+ });
468
+ }
469
+
470
+ try {
471
+ await pipVideo.play();
472
+ } catch (_) {}
473
+
474
+ if (isSafariPiP) {
475
+ try {
476
+ pipVideo.webkitSetPresentationMode('picture-in-picture');
477
+ console.log('PiP activated (Safari)');
478
+ return;
479
+ } catch (e) {
480
+ const cameraStream = localVideoRef.current?.srcObject;
481
+ if (cameraStream && cameraStream !== pipVideo.srcObject) {
482
+ pipVideo.srcObject = cameraStream;
483
+ try {
484
+ await pipVideo.play();
485
+ } catch (_) {}
486
+ pipVideo.webkitSetPresentationMode('picture-in-picture');
487
+ console.log('PiP activated (Safari fallback)');
488
+ return;
489
+ }
490
+ throw e;
491
+ }
492
+ }
493
+
494
+ if (typeof pipVideo.requestPictureInPicture === 'function') {
495
+ await pipVideo.requestPictureInPicture();
496
+ console.log('PiP activated');
497
+ } else {
498
+ alert('Picture-in-Picture is not supported in this browser.');
499
+ }
500
+
501
+ } catch (err) {
502
+ console.error('PiP error:', err);
503
+ alert(`Failed to enter Picture-in-Picture: ${err.message}`);
504
+ }
505
+ };
506
+
507
+ const handleFloatingWindow = () => {
508
+ handlePiP();
509
+ };
510
+
511
+ const handleFrameChange = (val) => {
512
+ const rate = parseInt(val, 10);
513
+ setCurrentFrame(rate);
514
+ if (videoManager) {
515
+ videoManager.setFrameRate(rate);
516
+ }
517
+ };
518
+
519
+ const handlePreview = () => {
520
+ if (!videoManager || !videoManager.isStreaming) {
521
+ alert('Please start a session first.');
522
+ return;
523
+ }
524
+ const currentStats = videoManager.getStats();
525
+ if (!currentStats.sessionId) {
526
+ alert('No active session.');
527
+ return;
528
+ }
529
+ const sessionDuration = Math.floor((Date.now() - (videoManager.sessionStartTime || Date.now())) / 1000);
530
+ const totalFrames = currentStats.framesProcessed || 0;
531
+ const focusedFrames = currentStats.focusedFrames ?? 0;
532
+ const focusScore = totalFrames > 0 ? focusedFrames / totalFrames : 0;
533
+
534
+ setSessionResult({
535
+ duration_seconds: sessionDuration,
536
+ focus_score: focusScore,
537
+ total_frames: totalFrames,
538
+ focused_frames: focusedFrames
539
+ });
540
+ };
541
+
542
+ const handleCloseOverlay = () => {
543
+ setSessionResult(null);
544
+ };
545
+
546
+ const pageStyle = isActive
547
+ ? undefined
548
+ : {
549
+ position: 'absolute',
550
+ width: '1px',
551
+ height: '1px',
552
+ overflow: 'hidden',
553
+ opacity: 0,
554
+ pointerEvents: 'none'
555
+ };
556
+
557
+ const focusStateLabel = {
558
+ [FOCUS_STATES.pending]: 'Pending',
559
+ [FOCUS_STATES.focused]: 'Focused',
560
+ [FOCUS_STATES.notFocused]: 'Not Focused'
561
+ }[focusState];
562
+
563
+ const introHighlights = [
564
+ {
565
+ title: 'Live focus tracking',
566
+ text: 'Head pose, gaze, and eye openness are read continuously during the session.'
567
+ },
568
+ {
569
+ title: 'Quick setup',
570
+ text: 'Front-facing light and a stable camera angle give the cleanest preview.'
571
+ },
572
+ {
573
+ title: 'Private by default',
574
+ text: 'Only session metadata is stored, not the raw camera footage.'
575
+ },
576
+ {
577
+ title: 'Sync across devices',
578
+ text: 'Your history auto-saves to this browser. To switch devices, use the Data Management tools at the bottom of the My Records tab to export or import your data.'
579
+ }
580
+ ];
581
+
582
+ const permissionSteps = [
583
+ {
584
+ title: 'Allow browser access',
585
+ text: 'Approve the camera prompt so the preview can appear immediately.'
586
+ },
587
+ {
588
+ title: 'Check your framing',
589
+ text: 'Keep your face visible and centered for more stable landmark detection.'
590
+ },
591
+ {
592
+ title: 'Start when ready',
593
+ text: 'After the preview appears, use the page controls to begin or stop.'
594
+ }
595
+ ];
596
+
597
+ const renderIntroCard = () => {
598
+ if (flowStep === FLOW_STEPS.intro) {
599
+ return (
600
+ <div className="focus-flow-overlay">
601
+ <div className="focus-flow-card">
602
+ <div className="focus-flow-header">
603
+ <div>
604
+ <div className="focus-flow-eyebrow">Focus Session</div>
605
+ <h2>Before you begin</h2>
606
+ </div>
607
+ <div className="focus-flow-icon">
608
+ <HelloIcon />
609
+ </div>
610
+ </div>
611
+
612
+ <p className="focus-flow-lead">
613
+ The focus page uses your live camera preview to estimate attention in real time.
614
+ Review the setup notes below, then continue to camera access.
615
+ </p>
616
+
617
+ <div className="focus-flow-grid">
618
+ {introHighlights.map((item) => (
619
+ <article key={item.title} className="focus-flow-panel">
620
+ <h3>{item.title}</h3>
621
+ <p>{item.text}</p>
622
+ </article>
623
+ ))}
624
+ </div>
625
+
626
+ <div className="focus-flow-glasses-note">
627
+ <strong>Wearing glasses?</strong> Glasses may reduce detection accuracy on some models. If results seem inaccurate, try switching to a different model (e.g. Geometric or MLP).
628
+ </div>
629
+
630
+ <div className="focus-flow-footer">
631
+ <div className="focus-flow-note">
632
+ You can still change frame rate and available model options after the preview loads.
633
+ </div>
634
+ <div style={{ display: 'flex', gap: '10px' }}>
635
+ <button className="focus-flow-secondary" onClick={closeTutorial}>
636
+ Skip
637
+ </button>
638
+ <button className="focus-flow-button" onClick={() => setFlowStep(FLOW_STEPS.permission)}>
639
+ Continue
640
+ </button>
641
+ </div>
642
+ </div>
643
+ </div>
644
+ </div>
645
+ );
646
+ }
647
+
648
+ if (flowStep === FLOW_STEPS.permission && !cameraReady) {
649
+ return (
650
+ <div className="focus-flow-overlay">
651
+ <div className="focus-flow-card">
652
+ <div className="focus-flow-header">
653
+ <div>
654
+ <div className="focus-flow-eyebrow">Camera Setup</div>
655
+ <h2>Enable camera access</h2>
656
+ </div>
657
+ <div className="focus-flow-icon">
658
+ <CameraIcon />
659
+ </div>
660
+ </div>
661
+
662
+ <p className="focus-flow-lead">
663
+ Once access is granted, your preview appears here and the rest of the Focus page
664
+ behaves like the other dashboard screens.
665
+ </p>
666
+
667
+ <div className="focus-flow-steps">
668
+ {permissionSteps.map((item, index) => (
669
+ <div key={item.title} className="focus-flow-step">
670
+ <div className="focus-flow-step-number">{index + 1}</div>
671
+ <div className="focus-flow-step-copy">
672
+ <h3>{item.title}</h3>
673
+ <p>{item.text}</p>
674
+ </div>
675
+ </div>
676
+ ))}
677
+ </div>
678
+
679
+ {cameraError ? <div className="focus-inline-error">{cameraError}</div> : null}
680
+
681
+ <div className="focus-flow-footer">
682
+ <button
683
+ type="button"
684
+ className="focus-flow-secondary"
685
+ onClick={() => setFlowStep(FLOW_STEPS.intro)}
686
+ >
687
+ Back
688
+ </button>
689
+ <button className="focus-flow-button" onClick={handleEnableCamera}>
690
+ Enable Camera
691
+ </button>
692
+ </div>
693
+ </div>
694
+ </div>
695
+ );
696
+ }
697
+
698
+ return null;
699
+ };
700
+
701
+ const renderEyeGazeModal = () => {
702
+ if (!showEyeGazeModal) return null;
703
+ return (
704
+ <div className="focus-flow-overlay" style={{ zIndex: 2000 }}>
705
+ <div className="focus-flow-card">
706
+ <div className="focus-flow-header">
707
+ <div>
708
+ <div className="focus-flow-eyebrow">Eye Gaze Tracking</div>
709
+ <h2>Before you enable</h2>
710
+ </div>
711
+ <div className="focus-flow-icon">
712
+ <svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
713
+ <ellipse cx="48" cy="48" rx="38" ry="24" fill="none" stroke="#007BFF" strokeWidth="5" />
714
+ <circle cx="48" cy="48" r="13" fill="none" stroke="#007BFF" strokeWidth="5" />
715
+ <circle cx="48" cy="48" r="5" fill="#007BFF" />
716
+ </svg>
717
+ </div>
718
+ </div>
719
+
720
+ <p className="focus-flow-lead">
721
+ Eye gaze tracking runs an additional deep neural network (L2CS-Net) alongside your current model.
722
+ Please read the notes below before proceeding.
723
+ </p>
724
+
725
+ <div className="focus-flow-grid">
726
+ <article className="focus-flow-panel focus-flow-panel-warn">
727
+ <h3>Performance impact</h3>
728
+ <p>Enabling eye gaze tracking increases CPU usage and may reduce frame rate. If the system feels sluggish, consider disabling it.</p>
729
+ </article>
730
+ <article className="focus-flow-panel">
731
+ <h3>Calibration (recommended)</h3>
732
+ <p>For best accuracy, calibrate by looking at 9 screen positions one at a time, followed by 1 validation point. The whole process takes about 30 seconds.</p>
733
+ </article>
734
+ </div>
735
+
736
+ <div className="focus-flow-steps">
737
+ <div className="focus-flow-step">
738
+ <div className="focus-flow-step-number">1</div>
739
+ <div className="focus-flow-step-copy">
740
+ <h3>Click "Start Calibration"</h3>
741
+ <p>A dot will appear on screen. Look directly at it and keep your gaze steady. It will cycle through 9 positions then show a final validation dot.</p>
742
+ </div>
743
+ </div>
744
+ <div className="focus-flow-step">
745
+ <div className="focus-flow-step-number">2</div>
746
+ <div className="focus-flow-step-copy">
747
+ <h3>Or skip for now</h3>
748
+ <p>Click "Skip" to enable eye gaze tracking without calibrating. You can recalibrate at any time using the "Recalibrate" button during a session.</p>
749
+ </div>
750
+ </div>
751
+ </div>
752
+
753
+ <label className="eye-gaze-modal-checkbox">
754
+ <input
755
+ type="checkbox"
756
+ checked={eyeGazeDontShow}
757
+ onChange={(e) => setEyeGazeDontShow(e.target.checked)}
758
+ />
759
+ Don't show this again
760
+ </label>
761
+
762
+ <div className="focus-flow-footer">
763
+ <button
764
+ type="button"
765
+ className="focus-flow-secondary"
766
+ onClick={() => handleEyeGazeModalAction(false)}
767
+ >
768
+ Skip
769
+ </button>
770
+ <button
771
+ className="focus-flow-button"
772
+ onClick={() => handleEyeGazeModalAction(true)}
773
+ >
774
+ Start Calibration
775
+ </button>
776
+ </div>
777
+ </div>
778
+ </div>
779
+ );
780
+ };
781
+
782
+ return (
783
+ <main id="page-b" className="page" style={pageStyle}>
784
+ {renderIntroCard()}
785
+ {renderEyeGazeModal()}
786
+
787
+ <section id="display-area" className="focus-display-shell">
788
+ <video
789
+ ref={pipVideoRef}
790
+ muted
791
+ playsInline
792
+ autoPlay
793
+ style={{
794
+ position: 'absolute',
795
+ width: '1px',
796
+ height: '1px',
797
+ opacity: 0,
798
+ pointerEvents: 'none'
799
+ }}
800
+ />
801
+ <video
802
+ ref={localVideoRef}
803
+ muted
804
+ playsInline
805
+ autoPlay
806
+ style={{ display: 'none' }}
807
+ />
808
+
809
+ <canvas
810
+ ref={displayCanvasRef}
811
+ width={640}
812
+ height={480}
813
+ style={{
814
+ width: '100%',
815
+ height: '100%',
816
+ objectFit: 'contain',
817
+ backgroundColor: '#101010'
818
+ }}
819
+ />
820
+
821
+ {flowStep === FLOW_STEPS.ready ? (
822
+ <>
823
+ <div className={`focus-state-pill ${focusState}`}>
824
+ <span className="focus-state-dot" />
825
+ {focusStateLabel}
826
+ </div>
827
+ {!cameraReady && !videoManager?.isStreaming ? (
828
+ <div className="focus-idle-overlay">
829
+ <p>Camera is paused.</p>
830
+ <span>Use Start to enable the camera and begin detection.</span>
831
+ </div>
832
+ ) : null}
833
+ </>
834
+ ) : null}
835
+
836
+ {sessionResult && (
837
+ <div className="session-result-overlay">
838
+ <h3>Session Complete!</h3>
839
+ <div className="result-item">
840
+ <span className="label">Duration:</span>
841
+ <span className="value">{formatDuration(sessionResult.duration_seconds)}</span>
842
+ </div>
843
+ <div className="result-item">
844
+ <span className="label">Focus Score:</span>
845
+ <span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
846
+ </div>
847
+
848
+ <button
849
+ onClick={handleCloseOverlay}
850
+ style={{
851
+ marginTop: '20px',
852
+ padding: '8px 20px',
853
+ background: 'transparent',
854
+ border: '1px solid white',
855
+ color: 'white',
856
+ borderRadius: '20px',
857
+ cursor: 'pointer'
858
+ }}
859
+ >
860
+ Close
861
+ </button>
862
+ </div>
863
+ )}
864
+
865
+ </section>
866
+
867
+ {flowStep === FLOW_STEPS.ready ? (
868
+ <>
869
+ {availableModels.length > 0 ? (
870
+ <section className="focus-model-strip">
871
+ <span className="focus-model-label">Model:</span>
872
+ {MODEL_ORDER.filter((n) => availableModels.includes(n)).map((name) => (
873
+ <button
874
+ key={name}
875
+ onClick={() => handleModelChange(name)}
876
+ className={`focus-model-button ${currentModel === name ? 'active' : ''}`}
877
+ >
878
+ {MODEL_INFO[name]?.label || name}
879
+ </button>
880
+ ))}
881
+
882
+ {l2csBoostAvailable && (
883
+ <>
884
+ <span className="focus-model-sep" />
885
+ <button
886
+ onClick={handleEyeGazeToggle}
887
+ className={`eye-gaze-toggle ${l2csBoost ? 'on' : 'off'}`}
888
+ title={l2csBoost ? 'Eye gaze tracking active — click to disable' : 'Enable eye gaze tracking (requires calibration)'}
889
+ >
890
+ <svg width="16" height="16" viewBox="0 0 16 16" className="eye-gaze-icon" aria-hidden="true">
891
+ <ellipse cx="8" cy="8" rx="7" ry="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
892
+ <circle cx="8" cy="8" r="2.2" fill="currentColor" />
893
+ </svg>
894
+ {l2csBoost ? 'Eye Gaze On' : 'Eye Gaze'}
895
+ </button>
896
+ {l2csBoost && stats && stats.isStreaming && (
897
+ <button
898
+ onClick={() => videoManager && videoManager.startCalibration()}
899
+ className="focus-model-button recalibrate"
900
+ title="Re-run gaze calibration"
901
+ >
902
+ Recalibrate
903
+ </button>
904
+ )}
905
+ </>
906
+ )}
907
+ </section>
908
+ ) : null}
909
+
910
+ {systemStats && systemStats.cpu_percent != null && (
911
+ <section className="focus-system-stats">
912
+ <span>CPU: <strong>{systemStats.cpu_percent}%</strong></span>
913
+ <span className="focus-system-stats-sep" />
914
+ <span>RAM: <strong>{systemStats.memory_percent}%</strong> ({systemStats.memory_used_mb}/{systemStats.memory_total_mb} MB)</span>
915
+ </section>
916
+ )}
917
+
918
+ <section id="timeline-area">
919
+ <div className="timeline-label">Timeline</div>
920
+ <div id="timeline-visuals">
921
+ {timelineEvents.map((event, index) => (
922
+ <div
923
+ key={index}
924
+ className="timeline-block"
925
+ style={{
926
+ backgroundColor: event.isFocused ? '#28a745' : '#dc3545',
927
+ width: '10px',
928
+ height: '20px',
929
+ borderRadius: '2px',
930
+ flexShrink: 0
931
+ }}
932
+ title={event.isFocused ? 'Focused' : 'Distracted'}
933
+ />
934
+ ))}
935
+ </div>
936
+ <div id="timeline-line" />
937
+ </section>
938
+
939
+ <section id="control-panel">
940
+ <button id="btn-cam-start" className="action-btn green" onClick={handleStart} disabled={isStarting}>
941
+ {isStarting ? 'Starting...' : 'Start'}
942
+ </button>
943
+
944
+ <button id="btn-floating" className="action-btn blue" onClick={handlePiP}>
945
+ Floating Window
946
+ </button>
947
+
948
+ <button id="btn-preview" className="action-btn orange" onClick={handlePreview}>
949
+ Preview Result
950
+ </button>
951
+
952
+ <button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>
953
+ Stop
954
+ </button>
955
+ </section>
956
+
957
+ {cameraError ? (
958
+ <div className="focus-inline-error focus-inline-error-standalone">{cameraError}</div>
959
+ ) : null}
960
+
961
+ {MODEL_INFO[currentModel] && (
962
+ <section className="model-card">
963
+ <div className="model-card-header">
964
+ <h3 className="model-card-title">{MODEL_INFO[currentModel].label}</h3>
965
+ {MODEL_INFO[currentModel].badge && (
966
+ <span className={MODEL_INFO[currentModel].badge === 'Baseline' ? 'model-card-badge-baseline' : 'model-card-badge'}>
967
+ {MODEL_INFO[currentModel].badge}
968
+ </span>
969
+ )}
970
+ </div>
971
+ <p className="model-card-tagline">{MODEL_INFO[currentModel].tagline}</p>
972
+
973
+ <div className="model-card-metrics">
974
+ <div className="model-card-metric">
975
+ <span className="model-card-metric-value">{MODEL_INFO[currentModel].accuracy}</span>
976
+ <span className="model-card-metric-label">Accuracy</span>
977
+ </div>
978
+ <div className="model-card-metric">
979
+ <span className="model-card-metric-value">{MODEL_INFO[currentModel].f1}</span>
980
+ <span className="model-card-metric-label">F1 Score</span>
981
+ </div>
982
+ <div className="model-card-metric">
983
+ <span className="model-card-metric-value">{MODEL_INFO[currentModel].auc}</span>
984
+ <span className="model-card-metric-label">ROC-AUC</span>
985
+ </div>
986
+ <div className="model-card-metric">
987
+ <span className="model-card-metric-value">{MODEL_INFO[currentModel].threshold}</span>
988
+ <span className="model-card-metric-label">Threshold</span>
989
+ </div>
990
+ </div>
991
+
992
+ <div className="model-card-details">
993
+ <div className="model-card-section">
994
+ <h4>How it works</h4>
995
+ <p>{MODEL_INFO[currentModel].how}</p>
996
+ </div>
997
+ <div className="model-card-section">
998
+ <h4>Features used</h4>
999
+ <p>{MODEL_INFO[currentModel].features}</p>
1000
+ </div>
1001
+ <div className="model-card-section">
1002
+ <h4>Strengths</h4>
1003
+ <p>{MODEL_INFO[currentModel].strengths}</p>
1004
+ </div>
1005
+ </div>
1006
+
1007
+ <div className="model-card-eval">
1008
+ Evaluated with {MODEL_INFO[currentModel].evaluation}
1009
+ </div>
1010
+ </section>
1011
+ )}
1012
+
1013
+ <section id="frame-control">
1014
+ <label htmlFor="frame-slider">Frame Rate (FPS)</label>
1015
+ <input
1016
+ type="range"
1017
+ id="frame-slider"
1018
+ min="10"
1019
+ max="30"
1020
+ value={currentFrame}
1021
+ onChange={(e) => handleFrameChange(e.target.value)}
1022
+ />
1023
+ <input
1024
+ type="number"
1025
+ id="frame-input"
1026
+ min="10"
1027
+ max="30"
1028
+ value={currentFrame}
1029
+ onChange={(e) => handleFrameChange(e.target.value)}
1030
+ />
1031
+ </section>
1032
+ </>
1033
+ ) : null}
1034
+
1035
+ <CalibrationOverlay calibration={calibration} videoManager={videoManager} />
1036
+ </main>
1037
+ );
1038
+ }
1039
+
1040
+ export default FocusPageLocal;
 
 
 
 
 
 
 
 
src/components/Home.jsx CHANGED
@@ -1,18 +1,22 @@
1
- import React from 'react';
2
-
3
- function Home({ setActiveTab }) {
4
- return (
5
- <main id="page-a" className="page">
6
- <h1>FocusGuard</h1>
7
- <p>Your productivity monitor assistant.</p>
8
-
9
- <div className="home-button-grid">
10
- <button type="button" className="btn-main" onClick={() => setActiveTab('focus')}>
11
- Start Focus
12
- </button>
13
- </div>
14
- </main>
15
- );
16
- }
17
-
18
- export default Home;
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ function Home({ onStartFocus, onStartTutorial }) {
4
+ return (
5
+ <main id="page-a" className="page">
6
+ <h1>FocusGuard</h1>
7
+ <p>Your productivity monitor assistant.</p>
8
+
9
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '15px', alignItems: 'center', marginTop: '30px' }}>
10
+ <button className="btn-main" onClick={onStartFocus} style={{ width: '250px' }}>
11
+ Start Focus
12
+ </button>
13
+
14
+ <button className="btn-main" onClick={onStartTutorial} style={{ width: '250px' }}>
15
+ Tutorial
16
+ </button>
17
+ </div>
18
+ </main>
19
+ );
20
+ }
21
+
22
+ export default Home;
src/components/Records.jsx CHANGED
@@ -1,645 +1,757 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
-
3
- function Records() {
4
- const [filter, setFilter] = useState('all');
5
- const [sessions, setSessions] = useState([]);
6
- const [loading, setLoading] = useState(false);
7
- const [detailState, setDetailState] = useState({
8
- open: false,
9
- loading: false,
10
- error: '',
11
- session: null
12
- });
13
- const chartRef = useRef(null);
14
-
15
- // Format a session duration.
16
- const formatDuration = (seconds) => {
17
- const safeSeconds = Math.max(0, Number(seconds) || 0);
18
- const mins = Math.floor(safeSeconds / 60);
19
- const secs = safeSeconds % 60;
20
- return `${mins}m ${secs}s`;
21
- };
22
-
23
- // Format a session timestamp for table display.
24
- const formatDate = (dateString) => {
25
- const date = new Date(dateString);
26
- return date.toLocaleDateString('en-US', {
27
- month: 'short',
28
- day: 'numeric',
29
- hour: '2-digit',
30
- minute: '2-digit'
31
- });
32
- };
33
-
34
- const formatDateTime = (dateString) => {
35
- if (!dateString) return 'Not available';
36
- const date = new Date(dateString);
37
- return date.toLocaleString('en-US', {
38
- month: 'short',
39
- day: 'numeric',
40
- year: 'numeric',
41
- hour: '2-digit',
42
- minute: '2-digit'
43
- });
44
- };
45
-
46
- const parseMetadata = (detectionData) => {
47
- if (!detectionData) return {};
48
- if (typeof detectionData === 'object') return detectionData;
49
- try {
50
- return JSON.parse(detectionData);
51
- } catch (_) {
52
- return {};
53
- }
54
- };
55
-
56
- const averageOf = (values) => {
57
- const valid = values.filter((value) => Number.isFinite(value));
58
- if (valid.length === 0) return null;
59
- return valid.reduce((sum, value) => sum + value, 0) / valid.length;
60
- };
61
-
62
- const buildTimelineSegments = (events, maxSegments = 48) => {
63
- if (!events.length) return [];
64
-
65
- const segmentSize = Math.ceil(events.length / maxSegments);
66
- const segments = [];
67
-
68
- for (let i = 0; i < events.length; i += segmentSize) {
69
- const slice = events.slice(i, i + segmentSize);
70
- const focusedCount = slice.filter((event) => event.isFocused).length;
71
- const focusRatio = focusedCount / slice.length;
72
- const confidence = averageOf(slice.map((event) => event.confidence));
73
-
74
- let tone = 'distracted';
75
- if (focusRatio >= 0.75) tone = 'focused';
76
- else if (focusRatio >= 0.35) tone = 'mixed';
77
-
78
- segments.push({
79
- tone,
80
- focusRatio,
81
- confidence,
82
- count: slice.length
83
- });
84
- }
85
-
86
- return segments;
87
- };
88
-
89
- const buildDetailView = (session) => {
90
- if (!session) return null;
91
-
92
- const parsedEvents = (session.events || []).map((event) => {
93
- const metadata = parseMetadata(event.detection_data);
94
- return {
95
- ...event,
96
- metadata,
97
- isFocused: Boolean(event.is_focused),
98
- confidence: Number(event.confidence) || 0
99
- };
100
- });
101
-
102
- const focusRatio = session.total_frames
103
- ? session.focused_frames / session.total_frames
104
- : parsedEvents.length
105
- ? parsedEvents.filter((event) => event.isFocused).length / parsedEvents.length
106
- : 0;
107
-
108
- const modelCounts = parsedEvents.reduce((counts, event) => {
109
- const model = event.metadata?.model;
110
- if (model) counts[model] = (counts[model] || 0) + 1;
111
- return counts;
112
- }, {});
113
-
114
- const dominantModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Unavailable';
115
- const avgConfidence = averageOf(parsedEvents.map((event) => event.confidence));
116
- const avgFaceScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_face)));
117
- const avgEyeScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_eye)));
118
- const avgMar = averageOf(parsedEvents.map((event) => Number(event.metadata?.mar)));
119
-
120
- const startTime = session.start_time ? new Date(session.start_time) : null;
121
- const timeline = buildTimelineSegments(parsedEvents);
122
- const recentEvents = parsedEvents.slice(-10).reverse();
123
-
124
- return {
125
- parsedEvents,
126
- focusRatio,
127
- dominantModel,
128
- avgConfidence,
129
- avgFaceScore,
130
- avgEyeScore,
131
- avgMar,
132
- timeline,
133
- recentEvents,
134
- formatOffset(timestamp) {
135
- if (!startTime || !timestamp) return '--';
136
- const offsetSeconds = Math.max(0, Math.round((new Date(timestamp) - startTime) / 1000));
137
- const mins = Math.floor(offsetSeconds / 60);
138
- const secs = offsetSeconds % 60;
139
- return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
140
- }
141
- };
142
- };
143
-
144
- const getScoreTone = (score) => {
145
- if (score >= 0.8) return 'excellent';
146
- if (score >= 0.6) return 'good';
147
- if (score >= 0.4) return 'fair';
148
- return 'low';
149
- };
150
-
151
- const closeDetails = () => {
152
- setDetailState({
153
- open: false,
154
- loading: false,
155
- error: '',
156
- session: null
157
- });
158
- };
159
-
160
- // Load session rows for the selected filter.
161
- const loadSessions = async (filterType) => {
162
- setLoading(true);
163
- try {
164
- const response = await fetch(`/api/sessions?filter=${filterType}&limit=50`);
165
- const data = await response.json();
166
- setSessions(data);
167
- drawChart(data);
168
- } catch (error) {
169
- console.error('Failed to load sessions:', error);
170
- } finally {
171
- setLoading(false);
172
- }
173
- };
174
-
175
- // Draw the session score chart.
176
- const drawChart = (data) => {
177
- const canvas = chartRef.current;
178
- if (!canvas) return;
179
-
180
- const ctx = canvas.getContext('2d');
181
- const width = canvas.width = canvas.offsetWidth;
182
- const height = canvas.height = 300;
183
-
184
- // Clear the canvas before each redraw.
185
- ctx.clearRect(0, 0, width, height);
186
-
187
- if (data.length === 0) {
188
- ctx.fillStyle = '#999';
189
- ctx.font = '16px Nunito';
190
- ctx.textAlign = 'center';
191
- ctx.fillText('No data available', width / 2, height / 2);
192
- return;
193
- }
194
-
195
- // Use at most the latest 20 sessions in the chart.
196
- const displayData = data.slice(0, 20).reverse();
197
- const padding = 50;
198
- const chartWidth = width - padding * 2;
199
- const chartHeight = height - padding * 2;
200
- const barWidth = chartWidth / displayData.length;
201
-
202
- // Use a normalized max score for chart scaling.
203
- const maxScore = 1.0;
204
-
205
- // Draw the chart axes.
206
- ctx.strokeStyle = '#E0E0E0';
207
- ctx.lineWidth = 2;
208
- ctx.beginPath();
209
- ctx.moveTo(padding, padding);
210
- ctx.lineTo(padding, height - padding);
211
- ctx.lineTo(width - padding, height - padding);
212
- ctx.stroke();
213
-
214
- // Draw Y-axis labels.
215
- ctx.fillStyle = '#666';
216
- ctx.font = '12px Nunito';
217
- ctx.textAlign = 'right';
218
- for (let i = 0; i <= 4; i++) {
219
- const y = height - padding - (chartHeight * i / 4);
220
- const value = (maxScore * i / 4 * 100).toFixed(0);
221
- ctx.fillText(value + '%', padding - 10, y + 4);
222
-
223
- // Draw horizontal grid lines.
224
- ctx.strokeStyle = '#F0F0F0';
225
- ctx.lineWidth = 1;
226
- ctx.beginPath();
227
- ctx.moveTo(padding, y);
228
- ctx.lineTo(width - padding, y);
229
- ctx.stroke();
230
- }
231
-
232
- // Draw the bar chart.
233
- displayData.forEach((session, index) => {
234
- const barHeight = (session.focus_score / maxScore) * chartHeight;
235
- const x = padding + index * barWidth + barWidth * 0.1;
236
- const y = height - padding - barHeight;
237
- const barActualWidth = barWidth * 0.8;
238
-
239
- // Map each score to a blue-toned color band.
240
- const score = session.focus_score;
241
- let color;
242
- if (score >= 0.8) color = '#4A90E2';
243
- else if (score >= 0.6) color = '#5DADE2';
244
- else if (score >= 0.4) color = '#85C1E9';
245
- else color = '#AED6F1';
246
-
247
- ctx.fillStyle = color;
248
- ctx.fillRect(x, y, barActualWidth, barHeight);
249
-
250
- // Draw a matching outline around each bar.
251
- ctx.strokeStyle = color;
252
- ctx.lineWidth = 1;
253
- ctx.strokeRect(x, y, barActualWidth, barHeight);
254
- });
255
-
256
- // Draw the chart title.
257
- ctx.textAlign = 'left';
258
- ctx.font = 'bold 14px Nunito';
259
- ctx.fillStyle = '#4A90E2';
260
- ctx.fillText('Focus Score by Session', padding, 30);
261
- };
262
-
263
- // Initial load.
264
- useEffect(() => {
265
- loadSessions(filter);
266
- }, [filter]);
267
-
268
- useEffect(() => {
269
- if (!detailState.open) return undefined;
270
-
271
- const previousOverflow = document.body.style.overflow;
272
- document.body.style.overflow = 'hidden';
273
-
274
- const handleKeyDown = (event) => {
275
- if (event.key === 'Escape') {
276
- closeDetails();
277
- }
278
- };
279
-
280
- window.addEventListener('keydown', handleKeyDown);
281
-
282
- return () => {
283
- document.body.style.overflow = previousOverflow;
284
- window.removeEventListener('keydown', handleKeyDown);
285
- };
286
- }, [detailState.open]);
287
-
288
- // Filter button handler.
289
- const handleFilterClick = (filterType) => {
290
- setFilter(filterType);
291
- };
292
-
293
- // Open the detail modal for one session.
294
- const handleViewDetails = async (sessionId) => {
295
- setDetailState({
296
- open: true,
297
- loading: true,
298
- error: '',
299
- session: null
300
- });
301
-
302
- try {
303
- const response = await fetch(`/api/sessions/${sessionId}`);
304
- if (!response.ok) {
305
- throw new Error('Failed to load session details.');
306
- }
307
-
308
- const data = await response.json();
309
- setDetailState({
310
- open: true,
311
- loading: false,
312
- error: '',
313
- session: data
314
- });
315
- } catch (error) {
316
- setDetailState({
317
- open: true,
318
- loading: false,
319
- error: error.message || 'Failed to load session details.',
320
- session: null
321
- });
322
- }
323
- };
324
-
325
- const detailView = buildDetailView(detailState.session);
326
-
327
- return (
328
- <main id="page-d" className="page">
329
- <h1 className="page-title">My Records</h1>
330
-
331
- <div className="records-controls" style={{ display: 'flex', justifyContent: 'center', gap: '10px', marginBottom: '30px' }}>
332
- <button
333
- id="filter-today"
334
- onClick={() => handleFilterClick('today')}
335
- style={{
336
- padding: '10px 30px',
337
- borderRadius: '8px',
338
- border: filter === 'today' ? 'none' : '2px solid #4A90E2',
339
- background: filter === 'today' ? '#4A90E2' : 'transparent',
340
- color: filter === 'today' ? 'white' : '#4A90E2',
341
- fontSize: '14px',
342
- fontWeight: '500',
343
- cursor: 'pointer',
344
- transition: 'all 0.3s'
345
- }}
346
- >
347
- Today
348
- </button>
349
- <button
350
- id="filter-week"
351
- onClick={() => handleFilterClick('week')}
352
- style={{
353
- padding: '10px 30px',
354
- borderRadius: '8px',
355
- border: filter === 'week' ? 'none' : '2px solid #4A90E2',
356
- background: filter === 'week' ? '#4A90E2' : 'transparent',
357
- color: filter === 'week' ? 'white' : '#4A90E2',
358
- fontSize: '14px',
359
- fontWeight: '500',
360
- cursor: 'pointer',
361
- transition: 'all 0.3s'
362
- }}
363
- >
364
- This Week
365
- </button>
366
- <button
367
- id="filter-month"
368
- onClick={() => handleFilterClick('month')}
369
- style={{
370
- padding: '10px 30px',
371
- borderRadius: '8px',
372
- border: filter === 'month' ? 'none' : '2px solid #4A90E2',
373
- background: filter === 'month' ? '#4A90E2' : 'transparent',
374
- color: filter === 'month' ? 'white' : '#4A90E2',
375
- fontSize: '14px',
376
- fontWeight: '500',
377
- cursor: 'pointer',
378
- transition: 'all 0.3s'
379
- }}
380
- >
381
- This Month
382
- </button>
383
- <button
384
- id="filter-all"
385
- onClick={() => handleFilterClick('all')}
386
- style={{
387
- padding: '10px 30px',
388
- borderRadius: '8px',
389
- border: filter === 'all' ? 'none' : '2px solid #4A90E2',
390
- background: filter === 'all' ? '#4A90E2' : 'transparent',
391
- color: filter === 'all' ? 'white' : '#4A90E2',
392
- fontSize: '14px',
393
- fontWeight: '500',
394
- cursor: 'pointer',
395
- transition: 'all 0.3s'
396
- }}
397
- >
398
- All Time
399
- </button>
400
- </div>
401
-
402
- <div className="chart-container" style={{
403
- background: 'white',
404
- padding: '20px',
405
- borderRadius: '10px',
406
- marginBottom: '30px',
407
- boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
408
- }}>
409
- <canvas ref={chartRef} id="focus-chart" style={{ width: '100%', height: '300px' }}></canvas>
410
- </div>
411
-
412
- <div className="sessions-list" style={{
413
- background: 'white',
414
- padding: '20px',
415
- borderRadius: '10px',
416
- boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
417
- }}>
418
- <h2 style={{ color: '#333', marginBottom: '20px', fontSize: '18px', fontWeight: '600' }}>Recent Sessions</h2>
419
- {loading ? (
420
- <div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
421
- Loading sessions...
422
- </div>
423
- ) : sessions.length === 0 ? (
424
- <div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
425
- No sessions found for this period.
426
- </div>
427
- ) : (
428
- <table id="sessions-table" style={{ width: '100%', borderCollapse: 'collapse', borderRadius: '10px', overflow: 'hidden' }}>
429
- <thead>
430
- <tr style={{ background: '#4A90E2' }}>
431
- <th style={{ padding: '15px', textAlign: 'left', color: 'white', fontWeight: '600', fontSize: '14px' }}>Date</th>
432
- <th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Duration</th>
433
- <th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Focus Score</th>
434
- <th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Action</th>
435
- </tr>
436
- </thead>
437
- <tbody id="sessions-tbody">
438
- {sessions.map((session, index) => (
439
- <tr key={session.id} style={{
440
- background: index % 2 === 0 ? '#f8f9fa' : 'white',
441
- borderBottom: '1px solid #e9ecef'
442
- }}>
443
- <td style={{ padding: '15px', color: '#333', fontSize: '13px' }}>{formatDate(session.start_time)}</td>
444
- <td style={{ padding: '15px', textAlign: 'center', color: '#333', fontSize: '13px' }}>{formatDuration(session.duration_seconds)}</td>
445
- <td style={{ padding: '15px', textAlign: 'center' }}>
446
- <span
447
- style={{
448
- color:
449
- session.focus_score >= 0.8
450
- ? '#28a745'
451
- : session.focus_score >= 0.6
452
- ? '#ffc107'
453
- : session.focus_score >= 0.4
454
- ? '#fd7e14'
455
- : '#dc3545',
456
- fontWeight: '600',
457
- fontSize: '13px'
458
- }}
459
- >
460
- {(session.focus_score * 100).toFixed(1)}%
461
- </span>
462
- </td>
463
- <td style={{ padding: '15px', textAlign: 'center' }}>
464
- <button
465
- onClick={() => handleViewDetails(session.id)}
466
- className="btn-view"
467
- >
468
- View
469
- </button>
470
- </td>
471
- </tr>
472
- ))}
473
- </tbody>
474
- </table>
475
- )}
476
- </div>
477
-
478
- {detailState.open ? (
479
- <div className="modal-overlay" onClick={closeDetails}>
480
- <div className="modal-content records-detail-modal" onClick={(event) => event.stopPropagation()}>
481
- <div className="records-detail-header">
482
- <div>
483
- <div className="records-detail-kicker">Session Detail</div>
484
- <h2>
485
- {detailState.session ? formatDateTime(detailState.session.start_time) : 'Loading session'}
486
- </h2>
487
- <p className="records-detail-subtitle">
488
- Review score, capture quality, and a condensed event timeline for this session.
489
- </p>
490
- </div>
491
- <button type="button" className="records-detail-close" onClick={closeDetails}>
492
- Close
493
- </button>
494
- </div>
495
-
496
- {detailState.loading ? (
497
- <div className="records-detail-feedback">Loading session details...</div>
498
- ) : detailState.error ? (
499
- <div className="records-detail-feedback records-detail-feedback-error">{detailState.error}</div>
500
- ) : detailState.session && detailView ? (
501
- <>
502
- <section className="records-detail-summary">
503
- <article className={`records-detail-stat ${getScoreTone(detailState.session.focus_score)}`}>
504
- <span className="records-detail-stat-label">Focus Score</span>
505
- <strong className="records-detail-stat-value">
506
- {(detailState.session.focus_score * 100).toFixed(1)}%
507
- </strong>
508
- </article>
509
- <article className="records-detail-stat">
510
- <span className="records-detail-stat-label">Duration</span>
511
- <strong className="records-detail-stat-value">
512
- {formatDuration(detailState.session.duration_seconds)}
513
- </strong>
514
- </article>
515
- <article className="records-detail-stat">
516
- <span className="records-detail-stat-label">Frames Analysed</span>
517
- <strong className="records-detail-stat-value">{detailState.session.total_frames}</strong>
518
- </article>
519
- <article className="records-detail-stat">
520
- <span className="records-detail-stat-label">Focused Frames</span>
521
- <strong className="records-detail-stat-value">
522
- {(detailView.focusRatio * 100).toFixed(1)}%
523
- </strong>
524
- </article>
525
- </section>
526
-
527
- <section className="records-detail-grid">
528
- <article className="records-detail-card">
529
- <h3>Session Info</h3>
530
- <div className="records-detail-list">
531
- <div className="records-detail-item">
532
- <span className="records-detail-item-label">Started</span>
533
- <span className="records-detail-item-value">{formatDateTime(detailState.session.start_time)}</span>
534
- </div>
535
- <div className="records-detail-item">
536
- <span className="records-detail-item-label">Ended</span>
537
- <span className="records-detail-item-value">{formatDateTime(detailState.session.end_time)}</span>
538
- </div>
539
- <div className="records-detail-item">
540
- <span className="records-detail-item-label">Dominant Model</span>
541
- <span className="records-detail-item-value">{detailView.dominantModel}</span>
542
- </div>
543
- <div className="records-detail-item">
544
- <span className="records-detail-item-label">Event Samples</span>
545
- <span className="records-detail-item-value">{detailView.parsedEvents.length}</span>
546
- </div>
547
- </div>
548
- </article>
549
-
550
- <article className="records-detail-card">
551
- <h3>Signal Quality</h3>
552
- <div className="records-detail-list">
553
- <div className="records-detail-item">
554
- <span className="records-detail-item-label">Avg Confidence</span>
555
- <span className="records-detail-item-value">
556
- {detailView.avgConfidence !== null ? `${(detailView.avgConfidence * 100).toFixed(1)}%` : '--'}
557
- </span>
558
- </div>
559
- <div className="records-detail-item">
560
- <span className="records-detail-item-label">Avg Face Score</span>
561
- <span className="records-detail-item-value">
562
- {detailView.avgFaceScore !== null ? detailView.avgFaceScore.toFixed(3) : '--'}
563
- </span>
564
- </div>
565
- <div className="records-detail-item">
566
- <span className="records-detail-item-label">Avg Eye Score</span>
567
- <span className="records-detail-item-value">
568
- {detailView.avgEyeScore !== null ? detailView.avgEyeScore.toFixed(3) : '--'}
569
- </span>
570
- </div>
571
- <div className="records-detail-item">
572
- <span className="records-detail-item-label">Avg MAR</span>
573
- <span className="records-detail-item-value">
574
- {detailView.avgMar !== null ? detailView.avgMar.toFixed(3) : '--'}
575
- </span>
576
- </div>
577
- </div>
578
- </article>
579
- </section>
580
-
581
- <section className="records-detail-card">
582
- <div className="records-detail-section-head">
583
- <h3>Focus Timeline</h3>
584
- <span>{detailView.parsedEvents.length} events condensed</span>
585
- </div>
586
- {detailView.timeline.length > 0 ? (
587
- <>
588
- <div className="records-detail-timeline">
589
- {detailView.timeline.map((segment, index) => (
590
- <div
591
- key={`${segment.tone}-${index}`}
592
- className={`records-detail-segment ${segment.tone}`}
593
- title={`${(segment.focusRatio * 100).toFixed(0)}% focused, ${segment.count} events`}
594
- />
595
- ))}
596
- </div>
597
- <div className="records-detail-legend">
598
- <span><i className="records-detail-dot focused" />Focused</span>
599
- <span><i className="records-detail-dot mixed" />Mixed</span>
600
- <span><i className="records-detail-dot distracted" />Distracted</span>
601
- </div>
602
- </>
603
- ) : (
604
- <div className="records-detail-empty">No event timeline was recorded for this session.</div>
605
- )}
606
- </section>
607
-
608
- <section className="records-detail-card">
609
- <div className="records-detail-section-head">
610
- <h3>Recent Events</h3>
611
- <span>Last {detailView.recentEvents.length} samples</span>
612
- </div>
613
- {detailView.recentEvents.length > 0 ? (
614
- <div className="records-detail-events">
615
- {detailView.recentEvents.map((event) => (
616
- <article key={event.id} className="records-detail-event">
617
- <div className="records-detail-event-time">{detailView.formatOffset(event.timestamp)}</div>
618
- <div className="records-detail-event-copy">
619
- <div className="records-detail-event-status">
620
- {event.isFocused ? 'Focused' : 'Distracted'}
621
- </div>
622
- <div className="records-detail-event-meta">
623
- {event.metadata?.model || 'model n/a'} · confidence {(event.confidence * 100).toFixed(1)}%
624
- </div>
625
- </div>
626
- <div className={`records-detail-event-badge ${event.isFocused ? 'focused' : 'distracted'}`}>
627
- {event.isFocused ? 'OK' : 'Alert'}
628
- </div>
629
- </article>
630
- ))}
631
- </div>
632
- ) : (
633
- <div className="records-detail-empty">No individual event samples are available.</div>
634
- )}
635
- </section>
636
- </>
637
- ) : null}
638
- </div>
639
- </div>
640
- ) : null}
641
- </main>
642
- );
643
- }
644
-
645
- export default Records;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+
3
+ function Records() {
4
+ const [filter, setFilter] = useState('all');
5
+ const [sessions, setSessions] = useState([]);
6
+ const [loading, setLoading] = useState(false);
7
+ const [detailState, setDetailState] = useState({
8
+ open: false,
9
+ loading: false,
10
+ error: '',
11
+ session: null
12
+ });
13
+ const chartRef = useRef(null);
14
+
15
+ const fileInputRef = useRef(null);
16
+
17
+ // Format a session duration.
18
+ const formatDuration = (seconds) => {
19
+ const safeSeconds = Math.max(0, Number(seconds) || 0);
20
+ const mins = Math.floor(safeSeconds / 60);
21
+ const secs = safeSeconds % 60;
22
+ return `${mins}m ${secs}s`;
23
+ };
24
+
25
+ // Format a session timestamp for table display.
26
+ const formatDate = (dateString) => {
27
+ const date = new Date(dateString);
28
+ return date.toLocaleDateString('en-US', {
29
+ month: 'short',
30
+ day: 'numeric',
31
+ hour: '2-digit',
32
+ minute: '2-digit'
33
+ });
34
+ };
35
+
36
+ const formatDateTime = (dateString) => {
37
+ if (!dateString) return 'Not available';
38
+ const date = new Date(dateString);
39
+ return date.toLocaleString('en-US', {
40
+ month: 'short',
41
+ day: 'numeric',
42
+ year: 'numeric',
43
+ hour: '2-digit',
44
+ minute: '2-digit'
45
+ });
46
+ };
47
+
48
+ const parseMetadata = (detectionData) => {
49
+ if (!detectionData) return {};
50
+ if (typeof detectionData === 'object') return detectionData;
51
+ try {
52
+ return JSON.parse(detectionData);
53
+ } catch (_) {
54
+ return {};
55
+ }
56
+ };
57
+
58
+ const averageOf = (values) => {
59
+ const valid = values.filter((value) => Number.isFinite(value));
60
+ if (valid.length === 0) return null;
61
+ return valid.reduce((sum, value) => sum + value, 0) / valid.length;
62
+ };
63
+
64
+ const buildTimelineSegments = (events, maxSegments = 48) => {
65
+ if (!events.length) return [];
66
+
67
+ const segmentSize = Math.ceil(events.length / maxSegments);
68
+ const segments = [];
69
+
70
+ for (let i = 0; i < events.length; i += segmentSize) {
71
+ const slice = events.slice(i, i + segmentSize);
72
+ const focusedCount = slice.filter((event) => event.isFocused).length;
73
+ const focusRatio = focusedCount / slice.length;
74
+ const confidence = averageOf(slice.map((event) => event.confidence));
75
+
76
+ let tone = 'distracted';
77
+ if (focusRatio >= 0.75) tone = 'focused';
78
+ else if (focusRatio >= 0.35) tone = 'mixed';
79
+
80
+ segments.push({
81
+ tone,
82
+ focusRatio,
83
+ confidence,
84
+ count: slice.length
85
+ });
86
+ }
87
+
88
+ return segments;
89
+ };
90
+
91
+ const buildDetailView = (session) => {
92
+ if (!session) return null;
93
+
94
+ const parsedEvents = (session.events || []).map((event) => {
95
+ const metadata = parseMetadata(event.detection_data);
96
+ return {
97
+ ...event,
98
+ metadata,
99
+ isFocused: Boolean(event.is_focused),
100
+ confidence: Number(event.confidence) || 0
101
+ };
102
+ });
103
+
104
+ const focusRatio = session.total_frames
105
+ ? session.focused_frames / session.total_frames
106
+ : parsedEvents.length
107
+ ? parsedEvents.filter((event) => event.isFocused).length / parsedEvents.length
108
+ : 0;
109
+
110
+ const modelCounts = parsedEvents.reduce((counts, event) => {
111
+ const model = event.metadata?.model;
112
+ if (model) counts[model] = (counts[model] || 0) + 1;
113
+ return counts;
114
+ }, {});
115
+
116
+ const dominantModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Unavailable';
117
+ const avgConfidence = averageOf(parsedEvents.map((event) => event.confidence));
118
+ const avgFaceScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_face)));
119
+ const avgEyeScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_eye)));
120
+ const avgMar = averageOf(parsedEvents.map((event) => Number(event.metadata?.mar)));
121
+
122
+ const startTime = session.start_time ? new Date(session.start_time) : null;
123
+ const timeline = buildTimelineSegments(parsedEvents);
124
+ const recentEvents = parsedEvents.slice(-10).reverse();
125
+
126
+ return {
127
+ parsedEvents,
128
+ focusRatio,
129
+ dominantModel,
130
+ avgConfidence,
131
+ avgFaceScore,
132
+ avgEyeScore,
133
+ avgMar,
134
+ timeline,
135
+ recentEvents,
136
+ formatOffset(timestamp) {
137
+ if (!startTime || !timestamp) return '--';
138
+ const offsetSeconds = Math.max(0, Math.round((new Date(timestamp) - startTime) / 1000));
139
+ const mins = Math.floor(offsetSeconds / 60);
140
+ const secs = offsetSeconds % 60;
141
+ return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
142
+ }
143
+ };
144
+ };
145
+
146
+ const getScoreTone = (score) => {
147
+ if (score >= 0.8) return 'excellent';
148
+ if (score >= 0.6) return 'good';
149
+ if (score >= 0.4) return 'fair';
150
+ return 'low';
151
+ };
152
+
153
+ const closeDetails = () => {
154
+ setDetailState({
155
+ open: false,
156
+ loading: false,
157
+ error: '',
158
+ session: null
159
+ });
160
+ };
161
+
162
+ // Load session rows for the selected filter.
163
+ const loadSessions = async (filterType) => {
164
+ setLoading(true);
165
+ try {
166
+ const response = await fetch(`/api/sessions?filter=${filterType}&limit=50`);
167
+ const data = await response.json();
168
+ setSessions(data);
169
+ drawChart(data);
170
+ } catch (error) {
171
+ console.error('Failed to load sessions:', error);
172
+ } finally {
173
+ setLoading(false);
174
+ }
175
+ };
176
+
177
+ // Draw the session score chart.
178
+ const drawChart = (data) => {
179
+ const canvas = chartRef.current;
180
+ if (!canvas) return;
181
+
182
+ const ctx = canvas.getContext('2d');
183
+ const width = canvas.width = canvas.offsetWidth;
184
+ const height = canvas.height = 300;
185
+
186
+ // Clear the canvas before each redraw.
187
+ ctx.clearRect(0, 0, width, height);
188
+
189
+ if (data.length === 0) {
190
+ ctx.fillStyle = '#999';
191
+ ctx.font = '16px Nunito';
192
+ ctx.textAlign = 'center';
193
+ ctx.fillText('No data available', width / 2, height / 2);
194
+ return;
195
+ }
196
+
197
+ // Use at most the latest 20 sessions in the chart.
198
+ const displayData = data.slice(0, 20).reverse();
199
+ const padding = 50;
200
+ const chartWidth = width - padding * 2;
201
+ const chartHeight = height - padding * 2;
202
+ const barWidth = chartWidth / displayData.length;
203
+
204
+ // Use a normalized max score for chart scaling.
205
+ const maxScore = 1.0;
206
+
207
+ // Draw the chart axes.
208
+ ctx.strokeStyle = '#E0E0E0';
209
+ ctx.lineWidth = 2;
210
+ ctx.beginPath();
211
+ ctx.moveTo(padding, padding);
212
+ ctx.lineTo(padding, height - padding);
213
+ ctx.lineTo(width - padding, height - padding);
214
+ ctx.stroke();
215
+
216
+ // Draw Y-axis labels.
217
+ ctx.fillStyle = '#666';
218
+ ctx.font = '12px Nunito';
219
+ ctx.textAlign = 'right';
220
+ for (let i = 0; i <= 4; i++) {
221
+ const y = height - padding - (chartHeight * i / 4);
222
+ const value = (maxScore * i / 4 * 100).toFixed(0);
223
+ ctx.fillText(value + '%', padding - 10, y + 4);
224
+
225
+ // Draw horizontal grid lines.
226
+ ctx.strokeStyle = '#F0F0F0';
227
+ ctx.lineWidth = 1;
228
+ ctx.beginPath();
229
+ ctx.moveTo(padding, y);
230
+ ctx.lineTo(width - padding, y);
231
+ ctx.stroke();
232
+ }
233
+
234
+ // Draw the bar chart.
235
+ displayData.forEach((session, index) => {
236
+ const barHeight = (session.focus_score / maxScore) * chartHeight;
237
+ const x = padding + index * barWidth + barWidth * 0.1;
238
+ const y = height - padding - barHeight;
239
+ const barActualWidth = barWidth * 0.8;
240
+
241
+ // Map each score to a blue-toned color band.
242
+ const score = session.focus_score;
243
+ let color;
244
+ if (score >= 0.8) color = '#4A90E2';
245
+ else if (score >= 0.6) color = '#5DADE2';
246
+ else if (score >= 0.4) color = '#85C1E9';
247
+ else color = '#AED6F1';
248
+
249
+ ctx.fillStyle = color;
250
+ ctx.fillRect(x, y, barActualWidth, barHeight);
251
+
252
+ // Draw a matching outline around each bar.
253
+ ctx.strokeStyle = color;
254
+ ctx.lineWidth = 1;
255
+ ctx.strokeRect(x, y, barActualWidth, barHeight);
256
+ });
257
+
258
+ // Draw the chart title.
259
+ ctx.textAlign = 'left';
260
+ ctx.font = 'bold 14px Nunito';
261
+ ctx.fillStyle = '#4A90E2';
262
+ ctx.fillText('Focus Score by Session', padding, 30);
263
+ };
264
+
265
+ // Initial load.
266
+ useEffect(() => {
267
+ loadSessions(filter);
268
+ }, [filter]);
269
+
270
+ useEffect(() => {
271
+ if (!detailState.open) return undefined;
272
+
273
+ const previousOverflow = document.body.style.overflow;
274
+ document.body.style.overflow = 'hidden';
275
+
276
+ const handleKeyDown = (event) => {
277
+ if (event.key === 'Escape') {
278
+ closeDetails();
279
+ }
280
+ };
281
+
282
+ window.addEventListener('keydown', handleKeyDown);
283
+
284
+ return () => {
285
+ document.body.style.overflow = previousOverflow;
286
+ window.removeEventListener('keydown', handleKeyDown);
287
+ };
288
+ }, [detailState.open]);
289
+
290
+ // Filter button handler.
291
+ const handleFilterClick = (filterType) => {
292
+ setFilter(filterType);
293
+ };
294
+
295
+ // Open the detail modal for one session.
296
+ const handleViewDetails = async (sessionId) => {
297
+ setDetailState({
298
+ open: true,
299
+ loading: true,
300
+ error: '',
301
+ session: null
302
+ });
303
+
304
+ try {
305
+ const response = await fetch(`/api/sessions/${sessionId}`);
306
+ if (!response.ok) {
307
+ throw new Error('Failed to load session details.');
308
+ }
309
+
310
+ const data = await response.json();
311
+ setDetailState({
312
+ open: true,
313
+ loading: false,
314
+ error: '',
315
+ session: data
316
+ });
317
+ } catch (error) {
318
+ setDetailState({
319
+ open: true,
320
+ loading: false,
321
+ error: error.message || 'Failed to load session details.',
322
+ session: null
323
+ });
324
+ }
325
+ };
326
+
327
+ const handleExport = async () => {
328
+ try {
329
+ const response = await fetch('/api/sessions?filter=all');
330
+ if (!response.ok) throw new Error("Failed to fetch data");
331
+ const data = await response.json();
332
+ const jsonString = JSON.stringify(data, null, 2);
333
+ localStorage.setItem('focus_magic_backup', jsonString);
334
+
335
+ const blob = new Blob([jsonString], { type: 'application/json' });
336
+ const url = URL.createObjectURL(blob);
337
+ const link = document.createElement('a');
338
+ link.href = url;
339
+ link.download = `focus-guard-backup-${new Date().toISOString().slice(0, 10)}.json`;
340
+ document.body.appendChild(link);
341
+ link.click();
342
+ document.body.removeChild(link);
343
+ URL.revokeObjectURL(url);
344
+ } catch (error) {
345
+ console.error(error);
346
+ alert("Export failed: " + error.message);
347
+ }
348
+ };
349
+
350
+ const triggerImport = () => {
351
+ if (fileInputRef.current) fileInputRef.current.click();
352
+ };
353
+
354
+ const handleFileChange = async (event) => {
355
+ const file = event.target.files[0];
356
+ if (!file) return;
357
+
358
+ const reader = new FileReader();
359
+ reader.onload = async (e) => {
360
+ try {
361
+ const content = e.target.result;
362
+ const sessions = JSON.parse(content);
363
+ if (!Array.isArray(sessions)) {
364
+ throw new Error("Invalid file format: Expected a list of sessions.");
365
+ }
366
+ const response = await fetch('/api/import', {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify(sessions)
370
+ });
371
+ if (response.ok) {
372
+ const result = await response.json();
373
+ alert(`Success! Imported ${result.count} sessions.`);
374
+ loadSessions(filter);
375
+ } else {
376
+ alert("Import failed on server side.");
377
+ }
378
+ } catch (err) {
379
+ alert("Error parsing file: " + err.message);
380
+ }
381
+ event.target.value = '';
382
+ };
383
+ reader.readAsText(file);
384
+ };
385
+
386
+ const handleClearHistory = async () => {
387
+ if (!window.confirm("Are you sure? This will delete ALL your session history permanently.")) {
388
+ return;
389
+ }
390
+ try {
391
+ const response = await fetch('/api/history', { method: 'DELETE' });
392
+ if (response.ok) {
393
+ alert("All history has been cleared.");
394
+ loadSessions(filter);
395
+ } else {
396
+ alert("Failed to clear history.");
397
+ }
398
+ } catch (err) {
399
+ alert("Error: " + err.message);
400
+ }
401
+ };
402
+
403
+ const detailView = buildDetailView(detailState.session);
404
+
405
+ return (
406
+ <main id="page-d" className="page">
407
+ <h1 className="page-title" style={{ marginBottom: '10px' }}>My Records</h1>
408
+
409
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '15px', marginBottom: '25px' }}>
410
+ <input
411
+ type="file"
412
+ ref={fileInputRef}
413
+ style={{ display: 'none' }}
414
+ accept=".json"
415
+ onChange={handleFileChange}
416
+ />
417
+ <button
418
+ onClick={handleExport}
419
+ style={{ background: '#eef3f8', border: '1px solid #d9eaff', color: '#4b5a6b', padding: '6px 16px', borderRadius: '20px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s' }}
420
+ onMouseOver={(e) => { e.target.style.background = '#e2eaf3'; }}
421
+ onMouseOut={(e) => { e.target.style.background = '#eef3f8'; }}
422
+ >
423
+ ⬇️ Export
424
+ </button>
425
+ <button
426
+ onClick={triggerImport}
427
+ style={{ background: '#eef3f8', border: '1px solid #d9eaff', color: '#4b5a6b', padding: '6px 16px', borderRadius: '20px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s' }}
428
+ onMouseOver={(e) => { e.target.style.background = '#e2eaf3'; }}
429
+ onMouseOut={(e) => { e.target.style.background = '#eef3f8'; }}
430
+ >
431
+ ⬆️ Import
432
+ </button>
433
+ <button
434
+ onClick={handleClearHistory}
435
+ style={{ background: '#fff1ee', border: '1px solid #f3c7c7', color: '#b54028', padding: '6px 16px', borderRadius: '20px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s' }}
436
+ onMouseOver={(e) => { e.target.style.background = '#fbe5e1'; }}
437
+ onMouseOut={(e) => { e.target.style.background = '#fff1ee'; }}
438
+ >
439
+ 🗑️ Clear
440
+ </button>
441
+ </div>
442
+
443
+ <div className="records-controls" style={{ display: 'flex', justifyContent: 'center', gap: '10px', marginBottom: '30px' }}>
444
+ <button
445
+ id="filter-today"
446
+ onClick={() => handleFilterClick('today')}
447
+ style={{
448
+ padding: '10px 30px',
449
+ borderRadius: '8px',
450
+ border: filter === 'today' ? 'none' : '2px solid #4A90E2',
451
+ background: filter === 'today' ? '#4A90E2' : 'transparent',
452
+ color: filter === 'today' ? 'white' : '#4A90E2',
453
+ fontSize: '14px',
454
+ fontWeight: '500',
455
+ cursor: 'pointer',
456
+ transition: 'all 0.3s'
457
+ }}
458
+ >
459
+ Today
460
+ </button>
461
+ <button
462
+ id="filter-week"
463
+ onClick={() => handleFilterClick('week')}
464
+ style={{
465
+ padding: '10px 30px',
466
+ borderRadius: '8px',
467
+ border: filter === 'week' ? 'none' : '2px solid #4A90E2',
468
+ background: filter === 'week' ? '#4A90E2' : 'transparent',
469
+ color: filter === 'week' ? 'white' : '#4A90E2',
470
+ fontSize: '14px',
471
+ fontWeight: '500',
472
+ cursor: 'pointer',
473
+ transition: 'all 0.3s'
474
+ }}
475
+ >
476
+ This Week
477
+ </button>
478
+ <button
479
+ id="filter-month"
480
+ onClick={() => handleFilterClick('month')}
481
+ style={{
482
+ padding: '10px 30px',
483
+ borderRadius: '8px',
484
+ border: filter === 'month' ? 'none' : '2px solid #4A90E2',
485
+ background: filter === 'month' ? '#4A90E2' : 'transparent',
486
+ color: filter === 'month' ? 'white' : '#4A90E2',
487
+ fontSize: '14px',
488
+ fontWeight: '500',
489
+ cursor: 'pointer',
490
+ transition: 'all 0.3s'
491
+ }}
492
+ >
493
+ This Month
494
+ </button>
495
+ <button
496
+ id="filter-all"
497
+ onClick={() => handleFilterClick('all')}
498
+ style={{
499
+ padding: '10px 30px',
500
+ borderRadius: '8px',
501
+ border: filter === 'all' ? 'none' : '2px solid #4A90E2',
502
+ background: filter === 'all' ? '#4A90E2' : 'transparent',
503
+ color: filter === 'all' ? 'white' : '#4A90E2',
504
+ fontSize: '14px',
505
+ fontWeight: '500',
506
+ cursor: 'pointer',
507
+ transition: 'all 0.3s'
508
+ }}
509
+ >
510
+ All Time
511
+ </button>
512
+ </div>
513
+
514
+ <div className="chart-container" style={{
515
+ background: 'white',
516
+ padding: '20px',
517
+ borderRadius: '10px',
518
+ marginBottom: '30px',
519
+ boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
520
+ }}>
521
+ <canvas ref={chartRef} id="focus-chart" style={{ width: '100%', height: '300px' }}></canvas>
522
+ </div>
523
+
524
+ <div className="sessions-list" style={{
525
+ background: 'white',
526
+ padding: '20px',
527
+ borderRadius: '10px',
528
+ boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
529
+ }}>
530
+ <h2 style={{ color: '#333', marginBottom: '20px', fontSize: '18px', fontWeight: '600' }}>Recent Sessions</h2>
531
+ {loading ? (
532
+ <div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
533
+ Loading sessions...
534
+ </div>
535
+ ) : sessions.length === 0 ? (
536
+ <div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
537
+ No sessions found for this period.
538
+ </div>
539
+ ) : (
540
+ <table id="sessions-table" style={{ width: '100%', borderCollapse: 'collapse', borderRadius: '10px', overflow: 'hidden' }}>
541
+ <thead>
542
+ <tr style={{ background: '#4A90E2' }}>
543
+ <th style={{ padding: '15px', textAlign: 'left', color: 'white', fontWeight: '600', fontSize: '14px' }}>Date</th>
544
+ <th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Duration</th>
545
+ <th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Focus Score</th>
546
+ <th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Action</th>
547
+ </tr>
548
+ </thead>
549
+ <tbody id="sessions-tbody">
550
+ {sessions.map((session, index) => (
551
+ <tr key={session.id} style={{
552
+ background: index % 2 === 0 ? '#f8f9fa' : 'white',
553
+ borderBottom: '1px solid #e9ecef'
554
+ }}>
555
+ <td style={{ padding: '15px', color: '#333', fontSize: '13px' }}>{formatDate(session.start_time)}</td>
556
+ <td style={{ padding: '15px', textAlign: 'center', color: '#333', fontSize: '13px' }}>{formatDuration(session.duration_seconds)}</td>
557
+ <td style={{ padding: '15px', textAlign: 'center' }}>
558
+ <span
559
+ style={{
560
+ color:
561
+ session.focus_score >= 0.8
562
+ ? '#28a745'
563
+ : session.focus_score >= 0.6
564
+ ? '#ffc107'
565
+ : session.focus_score >= 0.4
566
+ ? '#fd7e14'
567
+ : '#dc3545',
568
+ fontWeight: '600',
569
+ fontSize: '13px'
570
+ }}
571
+ >
572
+ {(session.focus_score * 100).toFixed(1)}%
573
+ </span>
574
+ </td>
575
+ <td style={{ padding: '15px', textAlign: 'center' }}>
576
+ <button
577
+ onClick={() => handleViewDetails(session.id)}
578
+ className="btn-view"
579
+ >
580
+ View
581
+ </button>
582
+ </td>
583
+ </tr>
584
+ ))}
585
+ </tbody>
586
+ </table>
587
+ )}
588
+ </div>
589
+
590
+ {detailState.open ? (
591
+ <div className="modal-overlay" onClick={closeDetails}>
592
+ <div className="modal-content records-detail-modal" onClick={(event) => event.stopPropagation()}>
593
+ <div className="records-detail-header">
594
+ <div>
595
+ <div className="records-detail-kicker">Session Detail</div>
596
+ <h2>
597
+ {detailState.session ? formatDateTime(detailState.session.start_time) : 'Loading session'}
598
+ </h2>
599
+ <p className="records-detail-subtitle">
600
+ Review score, capture quality, and a condensed event timeline for this session.
601
+ </p>
602
+ </div>
603
+ <button type="button" className="records-detail-close" onClick={closeDetails}>
604
+ Close
605
+ </button>
606
+ </div>
607
+
608
+ {detailState.loading ? (
609
+ <div className="records-detail-feedback">Loading session details...</div>
610
+ ) : detailState.error ? (
611
+ <div className="records-detail-feedback records-detail-feedback-error">{detailState.error}</div>
612
+ ) : detailState.session && detailView ? (
613
+ <>
614
+ <section className="records-detail-summary">
615
+ <article className={`records-detail-stat ${getScoreTone(detailState.session.focus_score)}`}>
616
+ <span className="records-detail-stat-label">Focus Score</span>
617
+ <strong className="records-detail-stat-value">
618
+ {(detailState.session.focus_score * 100).toFixed(1)}%
619
+ </strong>
620
+ </article>
621
+ <article className="records-detail-stat">
622
+ <span className="records-detail-stat-label">Duration</span>
623
+ <strong className="records-detail-stat-value">
624
+ {formatDuration(detailState.session.duration_seconds)}
625
+ </strong>
626
+ </article>
627
+ <article className="records-detail-stat">
628
+ <span className="records-detail-stat-label">Frames Analysed</span>
629
+ <strong className="records-detail-stat-value">{detailState.session.total_frames}</strong>
630
+ </article>
631
+ <article className="records-detail-stat">
632
+ <span className="records-detail-stat-label">Focused Frames</span>
633
+ <strong className="records-detail-stat-value">
634
+ {(detailView.focusRatio * 100).toFixed(1)}%
635
+ </strong>
636
+ </article>
637
+ </section>
638
+
639
+ <section className="records-detail-grid">
640
+ <article className="records-detail-card">
641
+ <h3>Session Info</h3>
642
+ <div className="records-detail-list">
643
+ <div className="records-detail-item">
644
+ <span className="records-detail-item-label">Started</span>
645
+ <span className="records-detail-item-value">{formatDateTime(detailState.session.start_time)}</span>
646
+ </div>
647
+ <div className="records-detail-item">
648
+ <span className="records-detail-item-label">Ended</span>
649
+ <span className="records-detail-item-value">{formatDateTime(detailState.session.end_time)}</span>
650
+ </div>
651
+ <div className="records-detail-item">
652
+ <span className="records-detail-item-label">Dominant Model</span>
653
+ <span className="records-detail-item-value">{detailView.dominantModel}</span>
654
+ </div>
655
+ <div className="records-detail-item">
656
+ <span className="records-detail-item-label">Event Samples</span>
657
+ <span className="records-detail-item-value">{detailView.parsedEvents.length}</span>
658
+ </div>
659
+ </div>
660
+ </article>
661
+
662
+ <article className="records-detail-card">
663
+ <h3>Signal Quality</h3>
664
+ <div className="records-detail-list">
665
+ <div className="records-detail-item">
666
+ <span className="records-detail-item-label">Avg Confidence</span>
667
+ <span className="records-detail-item-value">
668
+ {detailView.avgConfidence !== null ? `${(detailView.avgConfidence * 100).toFixed(1)}%` : '--'}
669
+ </span>
670
+ </div>
671
+ <div className="records-detail-item">
672
+ <span className="records-detail-item-label">Avg Face Score</span>
673
+ <span className="records-detail-item-value">
674
+ {detailView.avgFaceScore !== null ? detailView.avgFaceScore.toFixed(3) : '--'}
675
+ </span>
676
+ </div>
677
+ <div className="records-detail-item">
678
+ <span className="records-detail-item-label">Avg Eye Score</span>
679
+ <span className="records-detail-item-value">
680
+ {detailView.avgEyeScore !== null ? detailView.avgEyeScore.toFixed(3) : '--'}
681
+ </span>
682
+ </div>
683
+ <div className="records-detail-item">
684
+ <span className="records-detail-item-label">Avg MAR</span>
685
+ <span className="records-detail-item-value">
686
+ {detailView.avgMar !== null ? detailView.avgMar.toFixed(3) : '--'}
687
+ </span>
688
+ </div>
689
+ </div>
690
+ </article>
691
+ </section>
692
+
693
+ <section className="records-detail-card">
694
+ <div className="records-detail-section-head">
695
+ <h3>Focus Timeline</h3>
696
+ <span>{detailView.parsedEvents.length} events condensed</span>
697
+ </div>
698
+ {detailView.timeline.length > 0 ? (
699
+ <>
700
+ <div className="records-detail-timeline">
701
+ {detailView.timeline.map((segment, index) => (
702
+ <div
703
+ key={`${segment.tone}-${index}`}
704
+ className={`records-detail-segment ${segment.tone}`}
705
+ title={`${(segment.focusRatio * 100).toFixed(0)}% focused, ${segment.count} events`}
706
+ />
707
+ ))}
708
+ </div>
709
+ <div className="records-detail-legend">
710
+ <span><i className="records-detail-dot focused" />Focused</span>
711
+ <span><i className="records-detail-dot mixed" />Mixed</span>
712
+ <span><i className="records-detail-dot distracted" />Distracted</span>
713
+ </div>
714
+ </>
715
+ ) : (
716
+ <div className="records-detail-empty">No event timeline was recorded for this session.</div>
717
+ )}
718
+ </section>
719
+
720
+ <section className="records-detail-card">
721
+ <div className="records-detail-section-head">
722
+ <h3>Recent Events</h3>
723
+ <span>Last {detailView.recentEvents.length} samples</span>
724
+ </div>
725
+ {detailView.recentEvents.length > 0 ? (
726
+ <div className="records-detail-events">
727
+ {detailView.recentEvents.map((event) => (
728
+ <article key={event.id} className="records-detail-event">
729
+ <div className="records-detail-event-time">{detailView.formatOffset(event.timestamp)}</div>
730
+ <div className="records-detail-event-copy">
731
+ <div className="records-detail-event-status">
732
+ {event.isFocused ? 'Focused' : 'Distracted'}
733
+ </div>
734
+ <div className="records-detail-event-meta">
735
+ {event.metadata?.model || 'model n/a'} · confidence {(event.confidence * 100).toFixed(1)}%
736
+ </div>
737
+ </div>
738
+ <div className={`records-detail-event-badge ${event.isFocused ? 'focused' : 'distracted'}`}>
739
+ {event.isFocused ? 'OK' : 'Alert'}
740
+ </div>
741
+ </article>
742
+ ))}
743
+ </div>
744
+ ) : (
745
+ <div className="records-detail-empty">No individual event samples are available.</div>
746
+ )}
747
+ </section>
748
+ </>
749
+ ) : null}
750
+ </div>
751
+ </div>
752
+ ) : null}
753
+ </main>
754
+ );
755
+ }
756
+
757
+ export default Records;