tiena2cva commited on
Commit
bb2c905
·
1 Parent(s): 02f8ffd

feat(web): improve movement report review UI

Browse files
Files changed (3) hide show
  1. web/app.js +733 -100
  2. web/index.html +2 -2
  3. web/styles.css +850 -1
web/app.js CHANGED
@@ -1,6 +1,7 @@
1
  import React, {
2
  useEffect,
3
  useMemo,
 
4
  useState,
5
  } from "https://esm.sh/react@18.2.0";
6
  import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
@@ -339,6 +340,247 @@ function ReviewInsights({ result }) {
339
  );
340
  }
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  function SummaryTab({ result }) {
343
  if (!result) {
344
  return h(
@@ -352,43 +594,107 @@ function SummaryTab({ result }) {
352
  const report = result.report;
353
  const summary = report.coach_summary;
354
  const warnings = report.video_manifest.quality_warnings || [];
355
- const issues = report.issue_markers?.issues || [];
356
- const stats = [
357
- ["Exercise", report.exercise.exercise],
358
- ["Confidence", percent(report.exercise.confidence)],
359
- ["Variation", report.variation.detected_variation],
360
- ["Reps", String(report.reps.reps.length)],
361
- ["Issues", String(issues.length)],
362
- ];
 
 
 
 
 
 
363
 
364
  return h(
365
  "section",
366
- { className: "summary" },
367
- h("h2", null, "Scan summary"),
368
- h("p", null, summary.summary),
369
  h(
370
  "div",
371
- { className: "stat-grid" },
372
- stats.map(([name, value]) =>
 
 
 
 
 
373
  h(
374
  "div",
375
- { className: "stat", key: name },
376
- h("span", null, name),
377
- h("strong", null, value),
 
 
 
 
 
 
 
378
  ),
379
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  ),
381
  h(
382
  "div",
383
- { className: "note-grid" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  h(NoteList, { title: "What you did", items: summary.what_you_did }),
385
  h(NoteList, { title: "What looked good", items: summary.what_looked_good }),
386
- h(NoteList, {
387
- title: "What changed across reps",
388
- items: summary.what_changed_across_reps,
389
- }),
390
- h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
391
- h(NoteList, { title: "Next session", items: summary.next_session_plan }),
392
  ),
393
  warnings.length
394
  ? h(
@@ -419,23 +725,54 @@ function MetricsTab({ result }) {
419
  report.rep_analysis?.aggregate_metrics || {},
420
  );
421
  const reps = report.rep_analysis?.items || [];
 
422
  return h(
423
  "section",
424
- { className: "summary" },
425
  h("h2", null, "Movement metrics"),
 
426
  h(
427
  "div",
428
- { className: "metric-grid" },
429
- aggregate.length
430
- ? aggregate.map(([name, value]) =>
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  h(
432
- "div",
433
- { className: "stat", key: name },
434
- h("span", null, label(name)),
435
- h("strong", null, formatValue(value)),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  ),
437
  )
438
- : h("p", null, "No aggregate metrics available."),
439
  ),
440
  h(
441
  "div",
@@ -474,7 +811,205 @@ function MetricsTab({ result }) {
474
  );
475
  }
476
 
477
- function RepsTab({ result }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  if (!result)
479
  return h(
480
  "section",
@@ -482,29 +1017,103 @@ function RepsTab({ result }) {
482
  h("h2", null, "Rep review"),
483
  h("p", null, "Rep segments appear after analysis."),
484
  );
485
- const reps = result.report.reps?.reps || [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  return h(
487
  "section",
488
- { className: "summary" },
489
- h("h2", null, "Rep review"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  reps.length
491
  ? h(
492
  "div",
493
  { className: "rep-grid" },
494
- reps.map((rep) =>
495
- h(
 
 
 
496
  "article",
497
- { className: "rep-card", key: rep.rep_id },
498
- h("strong", null, `Rep ${rep.rep_id}`),
499
  h(
500
- "span",
501
- null,
502
- `${rep.start_sec.toFixed(2)}s-${rep.end_sec.toFixed(2)}s`,
 
503
  ),
 
504
  h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`),
505
  h("span", null, `midpoint ${rep.mid_sec.toFixed(2)}s`),
506
- ),
507
- ),
 
 
 
 
 
 
 
 
 
508
  )
509
  : h("p", null, "No complete reps were detected."),
510
  );
@@ -632,11 +1241,11 @@ function IssuesTab({ result }) {
632
  const issues = result?.report?.issue_markers?.issues || [];
633
  return h(
634
  "section",
635
- { className: "summary", "aria-label": "Issue timeline" },
636
  h(
637
  "div",
638
  { className: "timeline-head" },
639
- h("h3", null, "Issue timeline"),
640
  h(
641
  "span",
642
  null,
@@ -653,36 +1262,47 @@ function IssuesTab({ result }) {
653
  h(
654
  "article",
655
  {
656
- className: "issue-card",
657
  key: `${issue.rep_id}-${issue.issue}-${index}`,
658
  },
659
  h(IssueMedia, { result, issue, index }),
660
  h(
661
  "div",
662
- { className: "timeline-main" },
663
- h("strong", null, issueTitle(issue)),
664
- h("span", null, issueClipText(result, issue, index)),
665
  h(
666
- "span",
667
- null,
668
- `issue ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`,
669
- ),
670
- ),
671
- h(
672
- "div",
673
- { className: "timeline-meta" },
674
- h(
675
- "span",
676
- { className: `severity-chip ${severityLevel(issue)}` },
677
- severityText(issue),
678
  ),
 
679
  h(
680
- "span",
681
- null,
682
- `evidence confidence ${percent(issue.evidence?.confidence)}`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  ),
684
- h("span", null, issueEvidence(issue)),
685
- h("span", null, issueFocus(issue)),
686
  ),
687
  ),
688
  ),
@@ -707,51 +1327,55 @@ function CoachTab({ result }) {
707
  const verifierBypassed = Boolean(
708
  coachArtifacts.coach_summary_verifier_bypassed,
709
  );
 
 
710
  return h(
711
  "section",
712
- { className: "summary" },
713
- h("h2", null, "Coach summary"),
 
714
  h(
715
  "div",
716
- { className: "stat-grid" },
717
  [
718
  ["Provider", coachProvider],
719
  ["Model", coachModel],
720
  ["Source", coachSource || "n/a"],
721
- ].map(([name, value]) =>
722
- h(
723
- "div",
724
- { className: "stat", key: name },
725
- h("span", null, name),
726
- h("strong", null, value),
727
- ),
728
- ),
729
  ),
730
- coachSource.startsWith("fallback")
731
- ? h("p", null, "A conservative fallback summary was used because the generated summary was unavailable or did not pass verification.")
732
  : null,
733
  verifierBypassed
734
- ? h("p", null, "Verifier bypass is active, so the model summary is shown even though verification did not pass.")
735
  : null,
736
- h("p", null, summary.summary),
737
  h(
738
  "div",
739
- { className: "note-grid" },
740
- h(NoteList, { title: "What you did", items: summary.what_you_did }),
 
 
 
 
 
 
 
741
  h(NoteList, {
742
  title: "Variation vs issue",
743
  items: summary.valid_variation_vs_issue,
 
 
 
 
 
 
744
  }),
745
- h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
746
  h(NoteList, {
747
  title: "Confidence notes",
748
  items: summary.confidence_notes,
 
749
  }),
750
  ),
751
- h(NoteList, {
752
- title: "Next session plan",
753
- items: summary.next_session_plan,
754
- }),
755
  );
756
  }
757
 
@@ -787,27 +1411,31 @@ function ArtifactsTab({ result }) {
787
  )
788
  : h("p", null, "Artifacts appear after analysis."),
789
  ),
 
 
 
 
 
 
790
  );
791
  }
792
 
793
  const reportTabs = [
794
- ["summary", "Summary"],
 
795
  ["metrics", "Metrics"],
796
- ["reps", "Reps"],
797
  ["issues", "Issues"],
798
- ["coach", "Coach"],
799
- ["json", "JSON"],
800
  ["artifacts", "Artifacts"],
801
  ];
802
 
803
- function ReportPanel({ result, activeTab, onTabChange }) {
804
  const content = {
805
  summary: h(SummaryTab, { result }),
806
  metrics: h(MetricsTab, { result }),
807
- reps: h(RepsTab, { result }),
808
  issues: h(IssuesTab, { result }),
809
  coach: h(CoachTab, { result }),
810
- json: h(JsonTab, { result }),
811
  artifacts: h(ArtifactsTab, { result }),
812
  }[activeTab];
813
 
@@ -836,10 +1464,10 @@ function ReportPanel({ result, activeTab, onTabChange }) {
836
  );
837
  }
838
 
839
- function NoteList({ title, items }) {
840
  return h(
841
  "article",
842
- { className: "note" },
843
  h("h3", null, title),
844
  h(
845
  "ul",
@@ -1127,7 +1755,12 @@ function App() {
1127
  h(ReviewInsights, { result }),
1128
  ),
1129
  ),
1130
- h(ReportPanel, { result, activeTab, onTabChange: setActiveTab }),
 
 
 
 
 
1131
  );
1132
  }
1133
 
 
1
  import React, {
2
  useEffect,
3
  useMemo,
4
+ useRef,
5
  useState,
6
  } from "https://esm.sh/react@18.2.0";
7
  import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
 
340
  );
341
  }
342
 
343
+ function metricScore(value) {
344
+ return typeof value === "number" && Number.isFinite(value)
345
+ ? Math.max(0, Math.min(1, value))
346
+ : null;
347
+ }
348
+
349
+ function metricScoreRows(report) {
350
+ const aggregate = report.rep_analysis?.aggregate_metrics || {};
351
+ return [
352
+ ["ROM", aggregate.avg_rom_score],
353
+ ["Stability", aggregate.avg_stability_score],
354
+ ["Symmetry", aggregate.avg_symmetry_score],
355
+ ["Pose coverage", aggregate.pose_valid_ratio],
356
+ ]
357
+ .map(([name, value]) => ({ name, value: metricScore(value) }))
358
+ .filter((item) => item.value !== null);
359
+ }
360
+
361
+ function scoreTone(score) {
362
+ if (score === null) return "neutral";
363
+ if (score >= 0.8) return "strong";
364
+ if (score >= 0.62) return "watch";
365
+ return "risk";
366
+ }
367
+
368
+ function repScore(item) {
369
+ if (!item) return null;
370
+ const values = [
371
+ item.range_of_motion_score,
372
+ item.stability_score,
373
+ item.symmetry_score,
374
+ ]
375
+ .map(metricScore)
376
+ .filter((value) => value !== null);
377
+ if (!values.length) return null;
378
+ return values.reduce((total, value) => total + value, 0) / values.length;
379
+ }
380
+
381
+ function repAnalysisById(report) {
382
+ return new Map(
383
+ (report.rep_analysis?.items || []).map((item) => [item.rep_id, item]),
384
+ );
385
+ }
386
+
387
+ function issueList(report) {
388
+ return report.issue_markers?.issues || [];
389
+ }
390
+
391
+ function issuesForRep(report, repId) {
392
+ return issueList(report).filter((issue) => issue.rep_id === repId);
393
+ }
394
+
395
+ function topIssue(report) {
396
+ return [...issueList(report)].sort((a, b) => b.severity - a.severity)[0] || null;
397
+ }
398
+
399
+ function bestRep(report) {
400
+ const analysisById = repAnalysisById(report);
401
+ return (report.reps?.reps || [])
402
+ .map((rep) => {
403
+ const score = repScore(analysisById.get(rep.rep_id));
404
+ return { rep, score };
405
+ })
406
+ .filter((entry) => entry.score !== null)
407
+ .sort((a, b) => b.score - a.score)[0] || null;
408
+ }
409
+
410
+ function reportDuration(report) {
411
+ const manifestDuration = report.video_manifest?.duration_sec;
412
+ const repEnd = Math.max(
413
+ 0,
414
+ ...(report.reps?.reps || []).map((rep) => rep.end_sec || 0),
415
+ ...issueList(report).map((issue) => issue.end_sec || 0),
416
+ );
417
+ return Math.max(manifestDuration || 0, repEnd, 1);
418
+ }
419
+
420
+ function repAtTime(report, playbackTime) {
421
+ return (
422
+ (report.reps?.reps || []).find(
423
+ (rep) => playbackTime >= rep.start_sec && playbackTime <= rep.end_sec,
424
+ ) || null
425
+ );
426
+ }
427
+
428
+ function qualityVerdict(report) {
429
+ const scores = metricScoreRows(report)
430
+ .filter((row) => row.name !== "Pose coverage")
431
+ .map((row) => row.value);
432
+ const average = scores.length
433
+ ? scores.reduce((total, score) => total + score, 0) / scores.length
434
+ : null;
435
+ const issue = topIssue(report);
436
+ if (average === null) {
437
+ return {
438
+ title: "Report ready",
439
+ detail: "Coach notes are grounded in the scan.",
440
+ score: null,
441
+ };
442
+ }
443
+ const adjusted = Math.max(0, average - (issue?.severity || 0) * 0.08);
444
+ if (adjusted >= 0.82) {
445
+ return {
446
+ title: issue ? "Strong set, one watchpoint" : "Clean training set",
447
+ detail: issue ? `${issueTitle(issue)} is the main limiter.` : "No clear form issue dominated the set.",
448
+ score: adjusted,
449
+ };
450
+ }
451
+ if (adjusted >= 0.66) {
452
+ return {
453
+ title: "Usable reps, focused fix",
454
+ detail: issue ? `${issueTitle(issue)} should be fixed first.` : "Quality is workable, but keep reps controlled.",
455
+ score: adjusted,
456
+ };
457
+ }
458
+ return {
459
+ title: "Needs cleaner reps",
460
+ detail: issue ? `${issueTitle(issue)} is limiting this set.` : "Repeat with slower reps before progressing.",
461
+ score: adjusted,
462
+ };
463
+ }
464
+
465
+ function timeRange(start, end) {
466
+ return `${Number(start || 0).toFixed(2)}s-${Number(end || 0).toFixed(2)}s`;
467
+ }
468
+
469
+ function metaChip(text, tone = "", key = undefined) {
470
+ const props = { className: `meta-chip${tone ? ` ${tone}` : ""}` };
471
+ if (key !== undefined) props.key = key;
472
+ return h("span", props, text);
473
+ }
474
+
475
+ function ScoreBar({ labelText, value, detail }) {
476
+ const score = metricScore(value);
477
+ const width = score === null ? "0%" : `${Math.round(score * 100)}%`;
478
+ return h(
479
+ "div",
480
+ { className: `score-card ${scoreTone(score)}` },
481
+ h(
482
+ "div",
483
+ { className: "score-card-head" },
484
+ h("span", null, labelText),
485
+ h("strong", null, score === null ? "n/a" : percent(score)),
486
+ ),
487
+ h(
488
+ "span",
489
+ { className: "score-track", "aria-hidden": "true" },
490
+ h("span", { className: "score-fill", style: { width } }),
491
+ ),
492
+ detail ? h("small", null, detail) : null,
493
+ );
494
+ }
495
+
496
+ function BriefTile({ eyebrow, title, body, tone = "" }) {
497
+ return h(
498
+ "article",
499
+ { className: `brief-tile${tone ? ` ${tone}` : ""}` },
500
+ h("span", null, eyebrow),
501
+ h("strong", null, title),
502
+ h("p", null, body),
503
+ );
504
+ }
505
+
506
+ function RepTimeline({
507
+ report,
508
+ compact = false,
509
+ activeRepId = null,
510
+ playbackTime = 0,
511
+ onRepSelect = null,
512
+ }) {
513
+ const reps = report.reps?.reps || [];
514
+ const duration = reportDuration(report);
515
+ const analysisById = repAnalysisById(report);
516
+ const isPlayback = typeof onRepSelect === "function";
517
+ const playheadLeft = `${Math.max(0, Math.min(100, (playbackTime / duration) * 100))}%`;
518
+ if (!reps.length) {
519
+ return h("p", { className: "empty-copy" }, "No complete reps were detected.");
520
+ }
521
+
522
+ return h(
523
+ "div",
524
+ {
525
+ className: `rep-timeline${compact ? " compact" : ""}${isPlayback ? " playback" : ""}`,
526
+ },
527
+ h(
528
+ "div",
529
+ { className: "timeline-ruler", "aria-hidden": "true" },
530
+ h("span", null, "0s"),
531
+ h("span", null, `${duration.toFixed(1)}s`),
532
+ ),
533
+ h(
534
+ "div",
535
+ {
536
+ className: `rep-track${isPlayback ? " interactive" : ""}`,
537
+ "aria-label": isPlayback ? "Interactive rep timeline" : "Rep timeline",
538
+ },
539
+ isPlayback
540
+ ? h("span", {
541
+ className: "timeline-playhead",
542
+ style: { left: playheadLeft },
543
+ "aria-hidden": "true",
544
+ })
545
+ : null,
546
+ reps.map((rep) => {
547
+ const left = `${Math.max(0, Math.min(98, (rep.start_sec / duration) * 100))}%`;
548
+ const width = `${Math.max(6, Math.min(100, ((rep.end_sec - rep.start_sec) / duration) * 100))}%`;
549
+ const score = repScore(analysisById.get(rep.rep_id));
550
+ const repIssues = issuesForRep(report, rep.rep_id);
551
+ const tagName = isPlayback ? "button" : "div";
552
+ const segmentProps = {
553
+ className: `rep-segment ${scoreTone(score)}${repIssues.length ? " has-issue" : ""}${activeRepId === rep.rep_id ? " active" : ""}`,
554
+ key: rep.rep_id,
555
+ style: { left, width },
556
+ title: `Rep ${rep.rep_id} ${timeRange(rep.start_sec, rep.end_sec)}`,
557
+ };
558
+ if (isPlayback) {
559
+ segmentProps.type = "button";
560
+ segmentProps.onClick = () => onRepSelect(rep);
561
+ segmentProps["aria-label"] = `Play from rep ${rep.rep_id}, ${timeRange(rep.start_sec, rep.end_sec)}`;
562
+ segmentProps["aria-pressed"] = activeRepId === rep.rep_id;
563
+ }
564
+ return h(
565
+ tagName,
566
+ segmentProps,
567
+ h("strong", null, `R${rep.rep_id}`),
568
+ repIssues.map((issue, index) =>
569
+ h("span", {
570
+ className: `issue-pin ${severityLevel(issue)}`,
571
+ key: `${issue.issue}-${index}`,
572
+ style: {
573
+ left: `${Math.max(8, Math.min(92, ((issue.start_sec - rep.start_sec) / Math.max(0.01, rep.end_sec - rep.start_sec)) * 100))}%`,
574
+ },
575
+ title: issueTitle(issue),
576
+ }),
577
+ ),
578
+ );
579
+ }),
580
+ ),
581
+ );
582
+ }
583
+
584
  function SummaryTab({ result }) {
585
  if (!result) {
586
  return h(
 
594
  const report = result.report;
595
  const summary = report.coach_summary;
596
  const warnings = report.video_manifest.quality_warnings || [];
597
+ const issues = issueList(report);
598
+ const issue = topIssue(report);
599
+ const best = bestRep(report);
600
+ const verdict = qualityVerdict(report);
601
+ const scores = metricScoreRows(report);
602
+ const provider = report.artifacts?.coach_summary_provider || "n/a";
603
+ const model = report.artifacts?.coach_summary_model || "n/a";
604
+ const repCount = report.reps?.reps?.length || 0;
605
+ const cueNow =
606
+ summary.top_fixes?.[0] ||
607
+ (issue
608
+ ? issueEvidence(issue)
609
+ : "Repeat the next set with the same controlled tempo.");
610
+ const nextSession = summary.next_session_plan?.[0] || "Repeat the set with controlled reps.";
611
 
612
  return h(
613
  "section",
614
+ { className: "summary overview-panel" },
 
 
615
  h(
616
  "div",
617
+ { className: "report-hero" },
618
+ h(
619
+ "div",
620
+ { className: "report-copy" },
621
+ h("p", { className: "eyebrow" }, "Movement report"),
622
+ h("h2", null, `${label(report.exercise.exercise)} review`),
623
+ h("p", { className: "report-lede" }, summary.summary),
624
  h(
625
  "div",
626
+ { className: "pill-row" },
627
+ metaChip(`confidence ${percent(report.exercise.confidence)}`, "blue"),
628
+ metaChip(`${repCount} rep${repCount === 1 ? "" : "s"}`),
629
+ metaChip(
630
+ issues.length
631
+ ? `${issues.length} coaching moment${issues.length === 1 ? "" : "s"}`
632
+ : "no clear issue",
633
+ issues.length ? severityLevel(issue) : "strong",
634
+ ),
635
+ metaChip(provider === "local_transformers" ? "local coach" : provider),
636
  ),
637
  ),
638
+ h(
639
+ "aside",
640
+ { className: `verdict-card ${scoreTone(verdict.score)}` },
641
+ h("span", null, "Set verdict"),
642
+ h("strong", null, verdict.title),
643
+ h("p", null, verdict.detail),
644
+ h("small", null, model),
645
+ ),
646
+ ),
647
+ h(
648
+ "div",
649
+ { className: "brief-grid" },
650
+ h(BriefTile, {
651
+ eyebrow: "Best rep",
652
+ title: best ? `Rep ${best.rep.rep_id}` : "Not enough data",
653
+ body: best
654
+ ? `Most consistent rep at ${timeRange(best.rep.start_sec, best.rep.end_sec)} with ${percent(best.score)} quality.`
655
+ : "Complete reps are needed before the app can pick a best rep.",
656
+ tone: "mint",
657
+ }),
658
+ h(BriefTile, {
659
+ eyebrow: "Main limiter",
660
+ title: issue ? issueTitle(issue) : "Nothing major",
661
+ body: issue
662
+ ? `${issueClipText(result, issue, 0)}. ${issueEvidence(issue)}.`
663
+ : "No sustained threshold violations were found in this set.",
664
+ tone: issue ? severityLevel(issue) : "mint",
665
+ }),
666
+ h(BriefTile, {
667
+ eyebrow: "Cue now",
668
+ title: "Try this first",
669
+ body: cueNow,
670
+ tone: "volt",
671
+ }),
672
+ h(BriefTile, {
673
+ eyebrow: "Next session",
674
+ title: "Keep it simple",
675
+ body: nextSession,
676
+ }),
677
  ),
678
  h(
679
  "div",
680
+ { className: "score-board" },
681
+ scores.length
682
+ ? scores.map((row) =>
683
+ h(ScoreBar, {
684
+ key: row.name,
685
+ labelText: row.name,
686
+ value: row.value,
687
+ }),
688
+ )
689
+ : h("p", { className: "empty-copy" }, "No movement scores available."),
690
+ ),
691
+ h(RepTimeline, { report, compact: true }),
692
+ h(
693
+ "div",
694
+ { className: "note-grid report-notes" },
695
  h(NoteList, { title: "What you did", items: summary.what_you_did }),
696
  h(NoteList, { title: "What looked good", items: summary.what_looked_good }),
697
+ h(NoteList, { title: "What changed", items: summary.what_changed_across_reps }),
 
 
 
 
 
698
  ),
699
  warnings.length
700
  ? h(
 
725
  report.rep_analysis?.aggregate_metrics || {},
726
  );
727
  const reps = report.rep_analysis?.items || [];
728
+ const scoreRows = metricScoreRows(report);
729
  return h(
730
  "section",
731
+ { className: "summary metrics-panel" },
732
  h("h2", null, "Movement metrics"),
733
+ h("p", null, "Score bars show the movement qualities that most directly shape the coach note."),
734
  h(
735
  "div",
736
+ { className: "score-board metric-score-board" },
737
+ scoreRows.length
738
+ ? scoreRows.map((row) =>
739
+ h(ScoreBar, {
740
+ key: row.name,
741
+ labelText: row.name,
742
+ value: row.value,
743
+ }),
744
+ )
745
+ : h("p", { className: "empty-copy" }, "No aggregate scores available."),
746
+ ),
747
+ h(
748
+ "div",
749
+ { className: "rep-score-list" },
750
+ reps.length
751
+ ? reps.map((rep) =>
752
  h(
753
+ "article",
754
+ { className: `rep-score-row ${scoreTone(repScore(rep))}`, key: rep.rep_id },
755
+ h(
756
+ "div",
757
+ { className: "rep-score-title" },
758
+ h("strong", null, `Rep ${rep.rep_id}`),
759
+ h("span", null, `${formatValue(rep.duration_sec)}s`),
760
+ ),
761
+ h(ScoreBar, {
762
+ labelText: "ROM",
763
+ value: rep.range_of_motion_score,
764
+ }),
765
+ h(ScoreBar, {
766
+ labelText: "Stability",
767
+ value: rep.stability_score,
768
+ }),
769
+ h(ScoreBar, {
770
+ labelText: "Symmetry",
771
+ value: rep.symmetry_score,
772
+ }),
773
  ),
774
  )
775
+ : h("p", { className: "empty-copy" }, "No per-rep metrics available."),
776
  ),
777
  h(
778
  "div",
 
811
  );
812
  }
813
 
814
+ function RepDetailPanel({ report, rep, result, playbackTime }) {
815
+ if (!rep) {
816
+ return h(
817
+ "aside",
818
+ { className: "rep-detail-panel" },
819
+ h("span", { className: "rep-detail-kicker" }, "Current rep"),
820
+ h("strong", null, "No rep selected"),
821
+ h("p", null, "Play the video or click a rep segment to inspect its metrics."),
822
+ );
823
+ }
824
+
825
+ const analysis = repAnalysisById(report).get(rep.rep_id);
826
+ const repIssues = issuesForRep(report, rep.rep_id);
827
+ const score = repScore(analysis);
828
+ const duration = Math.max(0, (rep.end_sec || 0) - (rep.start_sec || 0));
829
+ const firstHalfDuration = Math.max(0, (rep.mid_sec || 0) - (rep.start_sec || 0));
830
+ const secondHalfDuration = Math.max(0, (rep.end_sec || 0) - (rep.mid_sec || 0));
831
+ const metricRows = [
832
+ { name: "ROM", value: metricScore(analysis?.range_of_motion_score) },
833
+ { name: "Stability", value: metricScore(analysis?.stability_score) },
834
+ { name: "Symmetry", value: metricScore(analysis?.symmetry_score) },
835
+ ].filter((item) => item.value !== null);
836
+ const weakestMetric = metricRows.length
837
+ ? [...metricRows].sort((a, b) => a.value - b.value)[0]
838
+ : null;
839
+ const repProgress =
840
+ duration > 0
841
+ ? Math.max(0, Math.min(1, (playbackTime - rep.start_sec) / duration))
842
+ : 0;
843
+ const phase =
844
+ playbackTime < rep.start_sec
845
+ ? "setup"
846
+ : playbackTime > rep.end_sec
847
+ ? "review"
848
+ : playbackTime <= rep.mid_sec
849
+ ? "half 1"
850
+ : "half 2";
851
+ const focusText = weakestMetric
852
+ ? `${weakestMetric.name} is the main review cue at ${percent(weakestMetric.value)}.`
853
+ : "Metric detail is not available for this rep.";
854
+ const scoreText =
855
+ score === null
856
+ ? "Metric data is limited for this rep."
857
+ : score >= 0.8
858
+ ? "This rep is one of the steadier parts of the set."
859
+ : score >= 0.62
860
+ ? "This rep is usable, but one metric deserves attention."
861
+ : "This rep is the kind to slow down and review before progressing.";
862
+ const issueText = repIssues.length
863
+ ? `${repIssues.length} issue marker${repIssues.length === 1 ? "" : "s"} attached to this rep.`
864
+ : "No issue marker is attached, so use the lowest metric as the review cue.";
865
+
866
+ return h(
867
+ "aside",
868
+ { className: `rep-detail-panel ${scoreTone(score)}` },
869
+ h(
870
+ "div",
871
+ { className: "rep-detail-head" },
872
+ h(
873
+ "div",
874
+ null,
875
+ h("span", { className: "rep-detail-kicker" }, "Current rep"),
876
+ h("strong", null, `Rep ${rep.rep_id}`),
877
+ h("small", null, timeRange(rep.start_sec, rep.end_sec)),
878
+ ),
879
+ metaChip(score === null ? "no score" : percent(score), scoreTone(score)),
880
+ ),
881
+ h(
882
+ "div",
883
+ { className: "rep-fact-grid" },
884
+ h(
885
+ "div",
886
+ { className: "rep-fact" },
887
+ h("span", null, "Duration"),
888
+ h("strong", null, `${duration.toFixed(2)}s`),
889
+ ),
890
+ h(
891
+ "div",
892
+ { className: "rep-fact" },
893
+ h("span", null, "Frames"),
894
+ h("strong", null, `${rep.start_frame}-${rep.end_frame}`),
895
+ ),
896
+ h(
897
+ "div",
898
+ { className: "rep-fact" },
899
+ h("span", null, "Tempo split"),
900
+ h("strong", null, `${firstHalfDuration.toFixed(2)}/${secondHalfDuration.toFixed(2)}s`),
901
+ ),
902
+ h(
903
+ "div",
904
+ { className: "rep-fact" },
905
+ h("span", null, "Phase"),
906
+ h("strong", null, phase),
907
+ ),
908
+ h(
909
+ "div",
910
+ { className: "rep-fact" },
911
+ h("span", null, "Focus"),
912
+ h("strong", null, weakestMetric ? `${weakestMetric.name} ${percent(weakestMetric.value)}` : "n/a"),
913
+ ),
914
+ h(
915
+ "div",
916
+ { className: "rep-fact" },
917
+ h("span", null, "Issues"),
918
+ h("strong", null, repIssues.length ? String(repIssues.length) : "clear"),
919
+ ),
920
+ ),
921
+ h(
922
+ "div",
923
+ { className: "rep-progress-card" },
924
+ h(
925
+ "div",
926
+ { className: "rep-now" },
927
+ h("span", null, "playhead"),
928
+ h("strong", null, `${playbackTime.toFixed(2)}s`),
929
+ ),
930
+ h(
931
+ "div",
932
+ { className: "rep-progress-track", "aria-hidden": "true" },
933
+ h("span", {
934
+ className: "rep-progress-fill",
935
+ style: { width: `${Math.round(repProgress * 100)}%` },
936
+ }),
937
+ h("span", { className: "rep-progress-mid" }),
938
+ ),
939
+ h(
940
+ "div",
941
+ { className: "rep-progress-labels" },
942
+ h("span", null, `${rep.start_sec.toFixed(2)}s`),
943
+ h("span", null, `${rep.mid_sec.toFixed(2)}s mid`),
944
+ h("span", null, `${rep.end_sec.toFixed(2)}s`),
945
+ ),
946
+ ),
947
+ h(
948
+ "article",
949
+ { className: "rep-readout" },
950
+ h("span", null, "Movement read"),
951
+ h("strong", null, scoreText),
952
+ h("p", null, `${focusText} ${issueText}`),
953
+ ),
954
+ h(
955
+ "div",
956
+ { className: "rep-mini-metrics" },
957
+ h(ScoreBar, {
958
+ labelText: "ROM",
959
+ value: analysis?.range_of_motion_score,
960
+ }),
961
+ h(ScoreBar, {
962
+ labelText: "Stability",
963
+ value: analysis?.stability_score,
964
+ }),
965
+ h(ScoreBar, {
966
+ labelText: "Symmetry",
967
+ value: analysis?.symmetry_score,
968
+ }),
969
+ ),
970
+ h(
971
+ "div",
972
+ { className: "issue-mini-list" },
973
+ h("h3", null, repIssues.length ? "Issue in this rep" : "Issue check"),
974
+ repIssues.length
975
+ ? repIssues.map((issue, index) =>
976
+ h(
977
+ "article",
978
+ { className: `issue-mini ${severityLevel(issue)}`, key: `${issue.issue}-${index}` },
979
+ h(
980
+ "div",
981
+ { className: "issue-mini-head" },
982
+ h("strong", null, issueTitle(issue)),
983
+ h(
984
+ "span",
985
+ { className: `severity-chip ${severityLevel(issue)}` },
986
+ severityText(issue),
987
+ ),
988
+ ),
989
+ h("p", null, issueEvidence(issue)),
990
+ h(
991
+ "div",
992
+ { className: "timeline-meta" },
993
+ h("span", null, issueClipText(result, issue, index)),
994
+ h("span", null, `evidence ${percent(issue.evidence?.confidence)}`),
995
+ ),
996
+ ),
997
+ )
998
+ : h("p", null, "No issue marker is attached to this rep."),
999
+ ),
1000
+ );
1001
+ }
1002
+
1003
+ function RepsTab({ result, videoSrc }) {
1004
+ const [playbackTime, setPlaybackTime] = useState(0);
1005
+ const [selectedRepId, setSelectedRepId] = useState(null);
1006
+ const replayVideoRef = useRef(null);
1007
+
1008
+ useEffect(() => {
1009
+ setPlaybackTime(0);
1010
+ setSelectedRepId(null);
1011
+ }, [result?.run_id, videoSrc]);
1012
+
1013
  if (!result)
1014
  return h(
1015
  "section",
 
1017
  h("h2", null, "Rep review"),
1018
  h("p", null, "Rep segments appear after analysis."),
1019
  );
1020
+ const report = result.report;
1021
+ const reps = report.reps?.reps || [];
1022
+ const analysisById = repAnalysisById(report);
1023
+ const timedRep = repAtTime(report, playbackTime);
1024
+ const selectedRep = reps.find((rep) => rep.rep_id === selectedRepId) || null;
1025
+ const activeRep = timedRep || selectedRep || reps[0] || null;
1026
+
1027
+ function playFromRep(rep) {
1028
+ const startTime = Math.max(0, (rep.start_sec || 0) - 0.04);
1029
+ setSelectedRepId(rep.rep_id);
1030
+ setPlaybackTime(startTime);
1031
+ if (!replayVideoRef.current) return;
1032
+ replayVideoRef.current.currentTime = startTime;
1033
+ const playPromise = replayVideoRef.current.play();
1034
+ if (playPromise?.catch) playPromise.catch(() => undefined);
1035
+ }
1036
+
1037
  return h(
1038
  "section",
1039
+ { className: "summary timeline-panel" },
1040
+ h("h2", null, "Rep replay timeline"),
1041
+ h(
1042
+ "p",
1043
+ null,
1044
+ "Play the set to follow each rep live, or click a rep segment to jump the video to that moment.",
1045
+ ),
1046
+ h(
1047
+ "div",
1048
+ { className: "replay-layout" },
1049
+ h(
1050
+ "div",
1051
+ { className: "replay-video-card" },
1052
+ videoSrc
1053
+ ? h("video", {
1054
+ className: "timeline-video",
1055
+ controls: true,
1056
+ muted: true,
1057
+ playsInline: true,
1058
+ preload: "metadata",
1059
+ ref: replayVideoRef,
1060
+ src: videoSrc,
1061
+ onLoadedMetadata: (event) => {
1062
+ setPlaybackTime(event.currentTarget.currentTime || 0);
1063
+ },
1064
+ onTimeUpdate: (event) => {
1065
+ setPlaybackTime(event.currentTarget.currentTime || 0);
1066
+ },
1067
+ onSeeked: (event) => {
1068
+ setPlaybackTime(event.currentTarget.currentTime || 0);
1069
+ },
1070
+ })
1071
+ : h(
1072
+ "div",
1073
+ { className: "timeline-video-placeholder" },
1074
+ h("strong", null, "No replay video available"),
1075
+ h("span", null, "Upload a clip or use a run with an annotated video to sync the timeline."),
1076
+ ),
1077
+ h(RepTimeline, {
1078
+ report,
1079
+ activeRepId: activeRep?.rep_id || null,
1080
+ playbackTime,
1081
+ onRepSelect: playFromRep,
1082
+ }),
1083
+ ),
1084
+ h(RepDetailPanel, { report, rep: activeRep, result, playbackTime }),
1085
+ ),
1086
  reps.length
1087
  ? h(
1088
  "div",
1089
  { className: "rep-grid" },
1090
+ reps.map((rep) => {
1091
+ const analysis = analysisById.get(rep.rep_id);
1092
+ const repIssues = issuesForRep(report, rep.rep_id);
1093
+ const score = repScore(analysis);
1094
+ return h(
1095
  "article",
1096
+ { className: `rep-card ${scoreTone(score)}`, key: rep.rep_id },
 
1097
  h(
1098
+ "div",
1099
+ { className: "rep-card-head" },
1100
+ h("strong", null, `Rep ${rep.rep_id}`),
1101
+ metaChip(score === null ? "no score" : percent(score), scoreTone(score)),
1102
  ),
1103
+ h("span", null, timeRange(rep.start_sec, rep.end_sec)),
1104
  h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`),
1105
  h("span", null, `midpoint ${rep.mid_sec.toFixed(2)}s`),
1106
+ h(
1107
+ "div",
1108
+ { className: "pill-row compact" },
1109
+ repIssues.length
1110
+ ? repIssues.map((issue, index) =>
1111
+ metaChip(issueTitle(issue), `${severityLevel(issue)}`, `${issue.issue}-${index}`),
1112
+ )
1113
+ : metaChip("no issue marker", "strong"),
1114
+ ),
1115
+ );
1116
+ }),
1117
  )
1118
  : h("p", null, "No complete reps were detected."),
1119
  );
 
1241
  const issues = result?.report?.issue_markers?.issues || [];
1242
  return h(
1243
  "section",
1244
+ { className: "summary issue-board", "aria-label": "Issue timeline" },
1245
  h(
1246
  "div",
1247
  { className: "timeline-head" },
1248
+ h("h3", null, "Coaching moments"),
1249
  h(
1250
  "span",
1251
  null,
 
1262
  h(
1263
  "article",
1264
  {
1265
+ className: `issue-card issue-card-feature ${severityLevel(issue)}`,
1266
  key: `${issue.rep_id}-${issue.issue}-${index}`,
1267
  },
1268
  h(IssueMedia, { result, issue, index }),
1269
  h(
1270
  "div",
1271
+ { className: "issue-body" },
 
 
1272
  h(
1273
+ "div",
1274
+ { className: "issue-card-head" },
1275
+ h(
1276
+ "span",
1277
+ { className: `severity-chip ${severityLevel(issue)}` },
1278
+ severityText(issue),
1279
+ ),
1280
+ h("strong", null, issueTitle(issue)),
1281
+ h("small", null, issueClipText(result, issue, index)),
 
 
 
1282
  ),
1283
+ h("p", null, issueEvidence(issue)),
1284
  h(
1285
+ "div",
1286
+ { className: "timeline-meta" },
1287
+ h(
1288
+ "span",
1289
+ null,
1290
+ `issue ${timeRange(issue.start_sec, issue.end_sec)}`,
1291
+ ),
1292
+ h(
1293
+ "span",
1294
+ null,
1295
+ `evidence ${percent(issue.evidence?.confidence)}`,
1296
+ ),
1297
+ h("span", null, issueFocus(issue)),
1298
+ issue.affected_joints.length
1299
+ ? h(
1300
+ "span",
1301
+ null,
1302
+ `joints ${issue.affected_joints.map(label).join(", ")}`,
1303
+ )
1304
+ : null,
1305
  ),
 
 
1306
  ),
1307
  ),
1308
  ),
 
1327
  const verifierBypassed = Boolean(
1328
  coachArtifacts.coach_summary_verifier_bypassed,
1329
  );
1330
+ const isFallback = coachSource.startsWith("fallback");
1331
+ const cueNow = summary.top_fixes?.[0] || summary.next_session_plan?.[0] || summary.summary;
1332
  return h(
1333
  "section",
1334
+ { className: "summary coach-panel" },
1335
+ h("h2", null, "Coach plan"),
1336
+ h("p", null, summary.summary),
1337
  h(
1338
  "div",
1339
+ { className: "coach-meta pill-row" },
1340
  [
1341
  ["Provider", coachProvider],
1342
  ["Model", coachModel],
1343
  ["Source", coachSource || "n/a"],
1344
+ ].map(([name, value]) => metaChip(`${name}: ${value}`, "", name)),
 
 
 
 
 
 
 
1345
  ),
1346
+ isFallback
1347
+ ? h("p", { className: "system-note" }, "A conservative fallback summary was used because the generated summary was unavailable or did not pass verification.")
1348
  : null,
1349
  verifierBypassed
1350
+ ? h("p", { className: "system-note warning" }, "Verifier bypass is active, so the model summary is shown even though verification did not pass.")
1351
  : null,
 
1352
  h(
1353
  "div",
1354
+ { className: "coach-plan-grid" },
1355
+ h(BriefTile, {
1356
+ eyebrow: "Cue now",
1357
+ title: "First rep of the next set",
1358
+ body: cueNow,
1359
+ tone: "volt",
1360
+ }),
1361
+ h(NoteList, { title: "Keep", items: summary.what_looked_good, className: "coach-note" }),
1362
+ h(NoteList, { title: "Fix first", items: summary.top_fixes, className: "coach-note" }),
1363
  h(NoteList, {
1364
  title: "Variation vs issue",
1365
  items: summary.valid_variation_vs_issue,
1366
+ className: "coach-note",
1367
+ }),
1368
+ h(NoteList, {
1369
+ title: "Next session",
1370
+ items: summary.next_session_plan,
1371
+ className: "coach-note",
1372
  }),
 
1373
  h(NoteList, {
1374
  title: "Confidence notes",
1375
  items: summary.confidence_notes,
1376
+ className: "coach-note muted",
1377
  }),
1378
  ),
 
 
 
 
1379
  );
1380
  }
1381
 
 
1411
  )
1412
  : h("p", null, "Artifacts appear after analysis."),
1413
  ),
1414
+ h(
1415
+ "details",
1416
+ { className: "json-details" },
1417
+ h("summary", null, "Raw report JSON"),
1418
+ h(JsonTab, { result }),
1419
+ ),
1420
  );
1421
  }
1422
 
1423
  const reportTabs = [
1424
+ ["summary", "Overview"],
1425
+ ["reps", "Timeline"],
1426
  ["metrics", "Metrics"],
 
1427
  ["issues", "Issues"],
1428
+ ["coach", "Coach Plan"],
 
1429
  ["artifacts", "Artifacts"],
1430
  ];
1431
 
1432
+ function ReportPanel({ result, activeTab, onTabChange, videoSrc }) {
1433
  const content = {
1434
  summary: h(SummaryTab, { result }),
1435
  metrics: h(MetricsTab, { result }),
1436
+ reps: h(RepsTab, { result, videoSrc }),
1437
  issues: h(IssuesTab, { result }),
1438
  coach: h(CoachTab, { result }),
 
1439
  artifacts: h(ArtifactsTab, { result }),
1440
  }[activeTab];
1441
 
 
1464
  );
1465
  }
1466
 
1467
+ function NoteList({ title, items, className = "" }) {
1468
  return h(
1469
  "article",
1470
+ { className: `note${className ? ` ${className}` : ""}` },
1471
  h("h3", null, title),
1472
  h(
1473
  "ul",
 
1755
  h(ReviewInsights, { result }),
1756
  ),
1757
  ),
1758
+ h(ReportPanel, {
1759
+ result,
1760
+ activeTab,
1761
+ onTabChange: setActiveTab,
1762
+ videoSrc: result?.annotated_video_url || previewUrl,
1763
+ }),
1764
  );
1765
  }
1766
 
web/index.html CHANGED
@@ -11,10 +11,10 @@
11
  href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
12
  rel="stylesheet"
13
  />
14
- <link rel="stylesheet" href="/static/styles.css?v=20260614-video-maxheight" />
15
  </head>
16
  <body>
17
  <div id="root"></div>
18
- <script type="module" src="/static/app.js?v=20260614-video-maxheight"></script>
19
  </body>
20
  </html>
 
11
  href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
12
  rel="stylesheet"
13
  />
14
+ <link rel="stylesheet" href="/static/styles.css?v=20260614-rep-detail-readout" />
15
  </head>
16
  <body>
17
  <div id="root"></div>
18
+ <script type="module" src="/static/app.js?v=20260614-rep-detail-readout"></script>
19
  </body>
20
  </html>
web/styles.css CHANGED
@@ -25,11 +25,14 @@
25
  }
26
 
27
  html {
 
28
  background: var(--page);
 
29
  }
30
 
31
  body {
32
  min-width: 320px;
 
33
  margin: 0;
34
  color: var(--ink);
35
  background:
@@ -39,9 +42,16 @@ body {
39
  background-position: center;
40
  background-size: cover;
41
  overflow-x: hidden;
 
 
 
42
  -webkit-tap-highlight-color: rgba(212, 255, 91, 0.18);
43
  }
44
 
 
 
 
 
45
  body::before {
46
  content: "";
47
  position: fixed;
@@ -730,6 +740,649 @@ input[type="text"]:focus-visible,
730
  padding: clamp(16px, 2vw, 24px);
731
  }
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  .stat-grid {
734
  display: grid;
735
  grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -842,6 +1495,10 @@ td {
842
  margin-top: 14px;
843
  }
844
 
 
 
 
 
845
  .rep-card,
846
  .issue-card {
847
  min-width: 0;
@@ -856,6 +1513,30 @@ td {
856
  padding: 14px;
857
  }
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  .rep-card strong {
860
  color: var(--paper);
861
  }
@@ -870,6 +1551,37 @@ td {
870
  overflow: hidden;
871
  }
872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  .issue-card .timeline-main,
874
  .issue-card .timeline-meta {
875
  padding: 10px 12px;
@@ -879,10 +1591,50 @@ td {
879
  padding-top: 0;
880
  }
881
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
882
  .issue-thumb,
883
  .issue-clip {
884
  display: block;
885
  width: 100%;
 
 
886
  aspect-ratio: 16 / 10;
887
  object-fit: contain;
888
  background: #090a08;
@@ -995,6 +1747,24 @@ td {
995
  background: rgba(255, 139, 116, 0.18);
996
  }
997
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
998
  .quality-list span {
999
  border-radius: 999px;
1000
  padding: 7px 10px;
@@ -1008,6 +1778,7 @@ td {
1008
  gap: 8px;
1009
  margin-top: 18px;
1010
  overflow-x: auto;
 
1011
  scrollbar-width: thin;
1012
  }
1013
 
@@ -1039,10 +1810,42 @@ td {
1039
  overflow: hidden;
1040
  }
1041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1042
  .json-block {
1043
  min-height: 360px;
1044
  max-height: 620px;
1045
- margin: 18px 0 0;
1046
  overflow: auto;
1047
  padding: 18px;
1048
  color: var(--paper);
@@ -1081,6 +1884,17 @@ td {
1081
  font-size: 0.78rem;
1082
  }
1083
 
 
 
 
 
 
 
 
 
 
 
 
1084
  .error {
1085
  border: 1px solid rgba(255, 139, 116, 0.38);
1086
  border-radius: var(--radius);
@@ -1138,6 +1952,10 @@ td {
1138
  }
1139
 
1140
  @media (max-width: 980px) {
 
 
 
 
1141
  .note-grid,
1142
  .stat-grid,
1143
  .metric-grid,
@@ -1146,6 +1964,12 @@ td {
1146
  .issue-card-grid {
1147
  grid-template-columns: 1fr 1fr;
1148
  }
 
 
 
 
 
 
1149
  }
1150
 
1151
  @media (max-width: 720px) {
@@ -1175,6 +1999,10 @@ td {
1175
  .hero-lab,
1176
  .hero-metrics,
1177
  .form-grid,
 
 
 
 
1178
  .note-grid,
1179
  .stat-grid,
1180
  .metric-grid,
@@ -1184,6 +2012,27 @@ td {
1184
  grid-template-columns: 1fr;
1185
  }
1186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1187
  .hero-lab {
1188
  display: none;
1189
  }
 
25
  }
26
 
27
  html {
28
+ min-height: 100%;
29
  background: var(--page);
30
+ overflow-y: auto;
31
  }
32
 
33
  body {
34
  min-width: 320px;
35
+ min-height: 100vh;
36
  margin: 0;
37
  color: var(--ink);
38
  background:
 
42
  background-position: center;
43
  background-size: cover;
44
  overflow-x: hidden;
45
+ overflow-y: auto;
46
+ overscroll-behavior-y: auto;
47
+ touch-action: pan-y;
48
  -webkit-tap-highlight-color: rgba(212, 255, 91, 0.18);
49
  }
50
 
51
+ #root {
52
+ min-height: 100%;
53
+ }
54
+
55
  body::before {
56
  content: "";
57
  position: fixed;
 
740
  padding: clamp(16px, 2vw, 24px);
741
  }
742
 
743
+ .overview-panel,
744
+ .metrics-panel,
745
+ .timeline-panel,
746
+ .issue-board,
747
+ .coach-panel {
748
+ position: relative;
749
+ overflow: hidden;
750
+ }
751
+
752
+ .overview-panel::before,
753
+ .metrics-panel::before,
754
+ .timeline-panel::before,
755
+ .issue-board::before,
756
+ .coach-panel::before {
757
+ content: "";
758
+ position: absolute;
759
+ inset: 0;
760
+ border-top: 2px solid rgba(212, 255, 91, 0.2);
761
+ background: linear-gradient(120deg, rgba(212, 255, 91, 0.06), transparent 36%, rgba(95, 183, 255, 0.05));
762
+ pointer-events: none;
763
+ }
764
+
765
+ .report-hero,
766
+ .brief-grid,
767
+ .score-board,
768
+ .rep-timeline,
769
+ .report-notes,
770
+ .metrics-panel > h2,
771
+ .metrics-panel > p,
772
+ .timeline-panel > h2,
773
+ .timeline-panel > p,
774
+ .issue-board > .timeline-head,
775
+ .coach-panel > h2,
776
+ .coach-panel > p,
777
+ .coach-meta,
778
+ .coach-plan-grid,
779
+ .system-note {
780
+ position: relative;
781
+ z-index: 1;
782
+ }
783
+
784
+ .report-hero {
785
+ display: grid;
786
+ grid-template-columns: minmax(0, 1fr) minmax(260px, 0.34fr);
787
+ gap: 16px;
788
+ align-items: stretch;
789
+ }
790
+
791
+ .report-copy {
792
+ min-width: 0;
793
+ }
794
+
795
+ .report-copy .eyebrow {
796
+ margin-bottom: 10px;
797
+ }
798
+
799
+ .report-lede {
800
+ max-width: 780px;
801
+ color: rgba(255, 247, 223, 0.88);
802
+ font-size: 1.08rem;
803
+ }
804
+
805
+ .pill-row {
806
+ display: flex;
807
+ flex-wrap: wrap;
808
+ gap: 8px;
809
+ margin-top: 14px;
810
+ }
811
+
812
+ .pill-row.compact {
813
+ margin-top: 8px;
814
+ }
815
+
816
+ .meta-chip,
817
+ .severity-chip {
818
+ display: inline-flex;
819
+ align-items: center;
820
+ min-width: 0;
821
+ border: 1px solid rgba(245, 242, 232, 0.13);
822
+ border-radius: 999px;
823
+ padding: 7px 9px;
824
+ color: var(--paper);
825
+ background: rgba(245, 242, 232, 0.08);
826
+ font-size: 0.78rem;
827
+ line-height: 1.1;
828
+ }
829
+
830
+ .meta-chip.strong,
831
+ .meta-chip.mint,
832
+ .meta-chip.volt {
833
+ border-color: rgba(87, 217, 163, 0.28);
834
+ color: #c8f7df;
835
+ background: rgba(87, 217, 163, 0.12);
836
+ }
837
+
838
+ .meta-chip.watch,
839
+ .meta-chip.moderate {
840
+ border-color: rgba(255, 129, 87, 0.32);
841
+ color: #ffd0bf;
842
+ background: rgba(255, 129, 87, 0.14);
843
+ }
844
+
845
+ .meta-chip.risk,
846
+ .meta-chip.high {
847
+ border-color: rgba(255, 139, 116, 0.42);
848
+ color: #ffd0c7;
849
+ background: rgba(255, 139, 116, 0.18);
850
+ }
851
+
852
+ .meta-chip.minor,
853
+ .meta-chip.blue {
854
+ border-color: rgba(95, 183, 255, 0.26);
855
+ color: #c6e5ff;
856
+ background: rgba(95, 183, 255, 0.12);
857
+ }
858
+
859
+ .verdict-card {
860
+ display: grid;
861
+ align-content: space-between;
862
+ min-height: 180px;
863
+ border: 1px solid rgba(212, 255, 91, 0.22);
864
+ border-radius: var(--radius);
865
+ padding: 18px;
866
+ background:
867
+ linear-gradient(150deg, rgba(212, 255, 91, 0.12), transparent 44%),
868
+ rgba(245, 242, 232, 0.07);
869
+ }
870
+
871
+ .verdict-card span,
872
+ .brief-tile span,
873
+ .score-card span,
874
+ .rep-score-title span,
875
+ .issue-card-head small {
876
+ color: var(--muted);
877
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
878
+ font-size: 0.76rem;
879
+ font-weight: 600;
880
+ text-transform: uppercase;
881
+ }
882
+
883
+ .verdict-card strong {
884
+ color: var(--paper);
885
+ font-size: 1.45rem;
886
+ line-height: 1.05;
887
+ }
888
+
889
+ .verdict-card p,
890
+ .brief-tile p,
891
+ .issue-body p {
892
+ margin: 8px 0 0;
893
+ color: rgba(245, 242, 232, 0.82);
894
+ }
895
+
896
+ .verdict-card small {
897
+ overflow-wrap: anywhere;
898
+ color: var(--dim);
899
+ font-size: 0.74rem;
900
+ }
901
+
902
+ .brief-grid {
903
+ display: grid;
904
+ grid-template-columns: repeat(4, minmax(0, 1fr));
905
+ gap: 12px;
906
+ margin-top: 16px;
907
+ }
908
+
909
+ .brief-tile {
910
+ min-width: 0;
911
+ border: 1px solid rgba(245, 242, 232, 0.12);
912
+ border-radius: var(--radius);
913
+ padding: 15px;
914
+ background: rgba(245, 242, 232, 0.065);
915
+ }
916
+
917
+ .brief-tile strong {
918
+ display: block;
919
+ margin-top: 8px;
920
+ color: var(--paper);
921
+ font-size: 1.05rem;
922
+ }
923
+
924
+ .brief-tile.mint {
925
+ border-color: rgba(87, 217, 163, 0.24);
926
+ background: rgba(87, 217, 163, 0.08);
927
+ }
928
+
929
+ .brief-tile.volt {
930
+ border-color: rgba(212, 255, 91, 0.28);
931
+ background: rgba(212, 255, 91, 0.08);
932
+ }
933
+
934
+ .brief-tile.high,
935
+ .brief-tile.risk {
936
+ border-color: rgba(255, 139, 116, 0.38);
937
+ background: rgba(255, 139, 116, 0.1);
938
+ }
939
+
940
+ .brief-tile.moderate,
941
+ .brief-tile.watch {
942
+ border-color: rgba(255, 129, 87, 0.32);
943
+ background: rgba(255, 129, 87, 0.09);
944
+ }
945
+
946
+ .score-board {
947
+ display: grid;
948
+ grid-template-columns: repeat(4, minmax(0, 1fr));
949
+ gap: 10px;
950
+ margin-top: 16px;
951
+ }
952
+
953
+ .score-card {
954
+ display: grid;
955
+ gap: 9px;
956
+ min-width: 0;
957
+ border: 1px solid rgba(245, 242, 232, 0.12);
958
+ border-radius: var(--radius);
959
+ padding: 13px;
960
+ background: rgba(245, 242, 232, 0.065);
961
+ }
962
+
963
+ .score-card-head {
964
+ display: flex;
965
+ justify-content: space-between;
966
+ gap: 10px;
967
+ align-items: center;
968
+ }
969
+
970
+ .score-card strong {
971
+ color: var(--paper);
972
+ font-variant-numeric: tabular-nums;
973
+ }
974
+
975
+ .score-track {
976
+ display: block;
977
+ height: 8px;
978
+ overflow: hidden;
979
+ border-radius: 999px;
980
+ background: rgba(245, 242, 232, 0.1);
981
+ }
982
+
983
+ .score-fill {
984
+ display: block;
985
+ width: 0%;
986
+ height: 100%;
987
+ border-radius: inherit;
988
+ background: linear-gradient(90deg, var(--volt), var(--mint));
989
+ }
990
+
991
+ .score-card.watch .score-fill {
992
+ background: linear-gradient(90deg, var(--ember), var(--volt));
993
+ }
994
+
995
+ .score-card.risk .score-fill {
996
+ background: linear-gradient(90deg, var(--danger), var(--ember));
997
+ }
998
+
999
+ .score-card small,
1000
+ .empty-copy {
1001
+ color: var(--muted);
1002
+ }
1003
+
1004
+ .rep-timeline {
1005
+ display: grid;
1006
+ gap: 8px;
1007
+ margin-top: 18px;
1008
+ }
1009
+
1010
+ .replay-layout {
1011
+ position: relative;
1012
+ z-index: 1;
1013
+ display: grid;
1014
+ grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.55fr);
1015
+ gap: 14px;
1016
+ align-items: start;
1017
+ margin-top: 16px;
1018
+ }
1019
+
1020
+ .replay-video-card,
1021
+ .rep-detail-panel {
1022
+ border: 1px solid rgba(245, 242, 232, 0.12);
1023
+ border-radius: var(--radius);
1024
+ background: rgba(9, 10, 8, 0.64);
1025
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
1026
+ }
1027
+
1028
+ .replay-video-card {
1029
+ display: grid;
1030
+ gap: 12px;
1031
+ padding: 12px;
1032
+ }
1033
+
1034
+ .timeline-video,
1035
+ .timeline-video-placeholder {
1036
+ display: block;
1037
+ width: 100%;
1038
+ min-height: 320px;
1039
+ max-height: min(58vh, 560px);
1040
+ border: 1px solid rgba(245, 242, 232, 0.1);
1041
+ border-radius: var(--radius);
1042
+ background: #090a08;
1043
+ }
1044
+
1045
+ .timeline-video {
1046
+ object-fit: contain;
1047
+ }
1048
+
1049
+ .timeline-video-placeholder {
1050
+ display: grid;
1051
+ place-items: center;
1052
+ align-content: center;
1053
+ gap: 8px;
1054
+ padding: 24px;
1055
+ color: var(--muted);
1056
+ text-align: center;
1057
+ }
1058
+
1059
+ .timeline-video-placeholder strong {
1060
+ color: var(--paper);
1061
+ }
1062
+
1063
+ .timeline-ruler {
1064
+ display: flex;
1065
+ justify-content: space-between;
1066
+ color: var(--dim);
1067
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
1068
+ font-size: 0.74rem;
1069
+ }
1070
+
1071
+ .rep-track {
1072
+ position: relative;
1073
+ height: 96px;
1074
+ overflow: hidden;
1075
+ border: 1px solid rgba(245, 242, 232, 0.12);
1076
+ border-radius: var(--radius);
1077
+ background:
1078
+ linear-gradient(90deg, rgba(245, 242, 232, 0.08) 1px, transparent 1px),
1079
+ rgba(9, 10, 8, 0.72);
1080
+ background-size: 10% 100%;
1081
+ }
1082
+
1083
+ .rep-track.interactive {
1084
+ height: 104px;
1085
+ background:
1086
+ linear-gradient(90deg, rgba(245, 242, 232, 0.1) 1px, transparent 1px),
1087
+ linear-gradient(180deg, rgba(245, 242, 232, 0.06), rgba(245, 242, 232, 0.02)),
1088
+ #0b0c0a;
1089
+ background-size: 10% 100%, auto, auto;
1090
+ }
1091
+
1092
+ .rep-timeline.compact .rep-track {
1093
+ height: 76px;
1094
+ }
1095
+
1096
+ .timeline-playhead {
1097
+ position: absolute;
1098
+ top: 8px;
1099
+ bottom: 8px;
1100
+ z-index: 5;
1101
+ width: 2px;
1102
+ border-radius: 999px;
1103
+ background: var(--paper);
1104
+ box-shadow: 0 0 0 4px rgba(255, 247, 223, 0.1), 0 0 22px rgba(255, 247, 223, 0.44);
1105
+ transform: translateX(-1px);
1106
+ pointer-events: none;
1107
+ }
1108
+
1109
+ .rep-segment {
1110
+ position: absolute;
1111
+ top: 22px;
1112
+ display: grid;
1113
+ place-items: center;
1114
+ min-width: 54px;
1115
+ height: 42px;
1116
+ border: 1px solid rgba(87, 217, 163, 0.34);
1117
+ border-radius: var(--radius);
1118
+ color: #11120e;
1119
+ background: linear-gradient(90deg, var(--volt), var(--mint));
1120
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24);
1121
+ }
1122
+
1123
+ .rep-timeline.playback .rep-segment {
1124
+ top: 32px;
1125
+ height: 46px;
1126
+ appearance: none;
1127
+ border-color: rgba(255, 129, 87, 0.42);
1128
+ background: linear-gradient(90deg, #ff7e66, #ff6f52);
1129
+ cursor: pointer;
1130
+ font: inherit;
1131
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
1132
+ }
1133
+
1134
+ .rep-timeline.playback .rep-segment:hover {
1135
+ border-color: rgba(255, 247, 223, 0.44);
1136
+ box-shadow: 0 18px 42px rgba(255, 129, 87, 0.22);
1137
+ }
1138
+
1139
+ .rep-timeline.playback .rep-segment.active {
1140
+ border-color: rgba(212, 255, 91, 0.7);
1141
+ background: linear-gradient(90deg, var(--volt), var(--mint));
1142
+ box-shadow: 0 20px 52px rgba(212, 255, 91, 0.26);
1143
+ transform: translateY(-7px);
1144
+ }
1145
+
1146
+ .rep-segment.watch {
1147
+ border-color: rgba(255, 129, 87, 0.44);
1148
+ background: linear-gradient(90deg, var(--ember), var(--volt));
1149
+ }
1150
+
1151
+ .rep-segment.risk {
1152
+ border-color: rgba(255, 139, 116, 0.48);
1153
+ background: linear-gradient(90deg, var(--danger), var(--ember));
1154
+ }
1155
+
1156
+ .rep-segment strong {
1157
+ position: relative;
1158
+ z-index: 2;
1159
+ font-size: 0.86rem;
1160
+ font-variant-numeric: tabular-nums;
1161
+ pointer-events: none;
1162
+ }
1163
+
1164
+ .issue-pin {
1165
+ position: absolute;
1166
+ top: -11px;
1167
+ width: 14px;
1168
+ height: 14px;
1169
+ border: 2px solid #090a08;
1170
+ border-radius: 999px;
1171
+ background: var(--blue);
1172
+ box-shadow: 0 0 0 4px rgba(95, 183, 255, 0.18);
1173
+ pointer-events: none;
1174
+ }
1175
+
1176
+ .issue-pin.moderate {
1177
+ background: var(--ember);
1178
+ box-shadow: 0 0 0 4px rgba(255, 129, 87, 0.2);
1179
+ }
1180
+
1181
+ .issue-pin.high {
1182
+ background: var(--danger);
1183
+ box-shadow: 0 0 0 4px rgba(255, 139, 116, 0.22);
1184
+ }
1185
+
1186
+ .rep-detail-panel {
1187
+ display: grid;
1188
+ grid-auto-rows: max-content;
1189
+ gap: 14px;
1190
+ align-content: start;
1191
+ padding: 16px;
1192
+ align-self: stretch;
1193
+ }
1194
+
1195
+ .rep-detail-panel.strong {
1196
+ border-color: rgba(87, 217, 163, 0.26);
1197
+ }
1198
+
1199
+ .rep-detail-panel.watch {
1200
+ border-color: rgba(255, 129, 87, 0.34);
1201
+ }
1202
+
1203
+ .rep-detail-panel.risk {
1204
+ border-color: rgba(255, 139, 116, 0.42);
1205
+ }
1206
+
1207
+ .rep-detail-head,
1208
+ .issue-mini-head {
1209
+ display: flex;
1210
+ justify-content: space-between;
1211
+ gap: 10px;
1212
+ align-items: flex-start;
1213
+ }
1214
+
1215
+ .rep-detail-kicker,
1216
+ .rep-now span,
1217
+ .rep-fact span,
1218
+ .rep-readout span,
1219
+ .issue-mini-list h3 {
1220
+ color: var(--muted);
1221
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
1222
+ font-size: 0.74rem;
1223
+ font-weight: 600;
1224
+ text-transform: uppercase;
1225
+ }
1226
+
1227
+ .rep-detail-head strong {
1228
+ display: block;
1229
+ margin-top: 5px;
1230
+ color: var(--paper);
1231
+ font-size: 1.7rem;
1232
+ line-height: 1;
1233
+ }
1234
+
1235
+ .rep-detail-head small {
1236
+ display: block;
1237
+ margin-top: 7px;
1238
+ color: var(--muted);
1239
+ font-variant-numeric: tabular-nums;
1240
+ }
1241
+
1242
+ .rep-fact-grid {
1243
+ display: grid;
1244
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1245
+ gap: 8px;
1246
+ }
1247
+
1248
+ .rep-fact,
1249
+ .rep-progress-card,
1250
+ .rep-readout {
1251
+ border: 1px solid rgba(245, 242, 232, 0.1);
1252
+ border-radius: var(--radius);
1253
+ background: rgba(245, 242, 232, 0.06);
1254
+ }
1255
+
1256
+ .rep-fact {
1257
+ display: grid;
1258
+ gap: 6px;
1259
+ min-width: 0;
1260
+ padding: 11px;
1261
+ }
1262
+
1263
+ .rep-fact strong {
1264
+ overflow-wrap: anywhere;
1265
+ color: var(--paper);
1266
+ font-size: 0.98rem;
1267
+ font-variant-numeric: tabular-nums;
1268
+ }
1269
+
1270
+ .rep-progress-card {
1271
+ display: grid;
1272
+ gap: 11px;
1273
+ padding: 12px;
1274
+ }
1275
+
1276
+ .rep-now {
1277
+ display: flex;
1278
+ justify-content: space-between;
1279
+ gap: 10px;
1280
+ align-items: center;
1281
+ }
1282
+
1283
+ .rep-now strong {
1284
+ color: var(--volt);
1285
+ font-variant-numeric: tabular-nums;
1286
+ }
1287
+
1288
+ .rep-progress-track {
1289
+ position: relative;
1290
+ height: 10px;
1291
+ overflow: hidden;
1292
+ border-radius: 999px;
1293
+ background: rgba(245, 242, 232, 0.13);
1294
+ }
1295
+
1296
+ .rep-progress-fill {
1297
+ display: block;
1298
+ height: 100%;
1299
+ border-radius: inherit;
1300
+ background: linear-gradient(90deg, var(--ember), var(--volt));
1301
+ }
1302
+
1303
+ .rep-progress-mid {
1304
+ position: absolute;
1305
+ top: -4px;
1306
+ bottom: -4px;
1307
+ left: 50%;
1308
+ width: 2px;
1309
+ background: rgba(255, 247, 223, 0.62);
1310
+ }
1311
+
1312
+ .rep-progress-labels {
1313
+ display: flex;
1314
+ justify-content: space-between;
1315
+ gap: 8px;
1316
+ color: var(--dim);
1317
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
1318
+ font-size: 0.72rem;
1319
+ font-variant-numeric: tabular-nums;
1320
+ }
1321
+
1322
+ .rep-readout {
1323
+ display: grid;
1324
+ gap: 7px;
1325
+ padding: 12px;
1326
+ background:
1327
+ linear-gradient(135deg, rgba(212, 255, 91, 0.08), transparent 56%),
1328
+ rgba(245, 242, 232, 0.06);
1329
+ }
1330
+
1331
+ .rep-readout strong {
1332
+ color: var(--paper);
1333
+ line-height: 1.25;
1334
+ }
1335
+
1336
+ .rep-readout p {
1337
+ margin: 0;
1338
+ color: var(--muted);
1339
+ line-height: 1.38;
1340
+ }
1341
+
1342
+ .rep-mini-metrics {
1343
+ display: grid;
1344
+ gap: 8px;
1345
+ }
1346
+
1347
+ .rep-mini-metrics .score-card {
1348
+ padding: 10px;
1349
+ }
1350
+
1351
+ .issue-mini-list {
1352
+ display: grid;
1353
+ gap: 9px;
1354
+ }
1355
+
1356
+ .issue-mini-list h3 {
1357
+ margin: 0;
1358
+ }
1359
+
1360
+ .issue-mini {
1361
+ border: 1px solid rgba(245, 242, 232, 0.11);
1362
+ border-radius: var(--radius);
1363
+ padding: 11px;
1364
+ background: rgba(245, 242, 232, 0.06);
1365
+ }
1366
+
1367
+ .issue-mini.high {
1368
+ border-color: rgba(255, 139, 116, 0.36);
1369
+ background: rgba(255, 139, 116, 0.09);
1370
+ }
1371
+
1372
+ .issue-mini.moderate {
1373
+ border-color: rgba(255, 129, 87, 0.3);
1374
+ background: rgba(255, 129, 87, 0.08);
1375
+ }
1376
+
1377
+ .issue-mini strong {
1378
+ color: var(--paper);
1379
+ }
1380
+
1381
+ .issue-mini p {
1382
+ margin: 8px 0;
1383
+ color: rgba(245, 242, 232, 0.82);
1384
+ }
1385
+
1386
  .stat-grid {
1387
  display: grid;
1388
  grid-template-columns: repeat(5, minmax(0, 1fr));
 
1495
  margin-top: 14px;
1496
  }
1497
 
1498
+ .issue-board .issue-card-grid {
1499
+ grid-template-columns: 1fr;
1500
+ }
1501
+
1502
  .rep-card,
1503
  .issue-card {
1504
  min-width: 0;
 
1513
  padding: 14px;
1514
  }
1515
 
1516
+ .rep-card.strong,
1517
+ .rep-score-row.strong {
1518
+ border-color: rgba(87, 217, 163, 0.24);
1519
+ }
1520
+
1521
+ .rep-card.watch,
1522
+ .rep-score-row.watch {
1523
+ border-color: rgba(255, 129, 87, 0.32);
1524
+ }
1525
+
1526
+ .rep-card.risk,
1527
+ .rep-score-row.risk {
1528
+ border-color: rgba(255, 139, 116, 0.38);
1529
+ }
1530
+
1531
+ .rep-card-head,
1532
+ .rep-score-title,
1533
+ .issue-card-head {
1534
+ display: flex;
1535
+ justify-content: space-between;
1536
+ gap: 10px;
1537
+ align-items: flex-start;
1538
+ }
1539
+
1540
  .rep-card strong {
1541
  color: var(--paper);
1542
  }
 
1551
  overflow: hidden;
1552
  }
1553
 
1554
+ .rep-score-list {
1555
+ display: grid;
1556
+ gap: 10px;
1557
+ margin: 16px 0;
1558
+ }
1559
+
1560
+ .rep-score-row {
1561
+ display: grid;
1562
+ grid-template-columns: minmax(90px, 0.4fr) repeat(3, minmax(0, 1fr));
1563
+ gap: 10px;
1564
+ align-items: stretch;
1565
+ border: 1px solid rgba(245, 242, 232, 0.12);
1566
+ border-radius: var(--radius);
1567
+ padding: 10px;
1568
+ background: rgba(245, 242, 232, 0.055);
1569
+ }
1570
+
1571
+ .rep-score-row .score-card {
1572
+ padding: 10px;
1573
+ background: rgba(9, 10, 8, 0.24);
1574
+ }
1575
+
1576
+ .rep-score-title {
1577
+ display: grid;
1578
+ align-content: center;
1579
+ }
1580
+
1581
+ .rep-score-title strong {
1582
+ color: var(--paper);
1583
+ }
1584
+
1585
  .issue-card .timeline-main,
1586
  .issue-card .timeline-meta {
1587
  padding: 10px 12px;
 
1591
  padding-top: 0;
1592
  }
1593
 
1594
+ .issue-card-feature {
1595
+ display: grid;
1596
+ grid-template-columns: minmax(260px, 0.46fr) minmax(0, 0.54fr);
1597
+ }
1598
+
1599
+ .issue-card-feature.high {
1600
+ border-color: rgba(255, 139, 116, 0.36);
1601
+ }
1602
+
1603
+ .issue-card-feature.moderate {
1604
+ border-color: rgba(255, 129, 87, 0.3);
1605
+ }
1606
+
1607
+ .issue-card-feature.minor {
1608
+ border-color: rgba(95, 183, 255, 0.24);
1609
+ }
1610
+
1611
+ .issue-body {
1612
+ display: grid;
1613
+ gap: 12px;
1614
+ align-content: start;
1615
+ padding: 14px;
1616
+ }
1617
+
1618
+ .issue-card-head {
1619
+ display: grid;
1620
+ gap: 6px;
1621
+ }
1622
+
1623
+ .issue-card-head strong {
1624
+ color: var(--paper);
1625
+ font-size: 1.05rem;
1626
+ }
1627
+
1628
+ .issue-card-head small {
1629
+ text-transform: none;
1630
+ }
1631
+
1632
  .issue-thumb,
1633
  .issue-clip {
1634
  display: block;
1635
  width: 100%;
1636
+ height: 100%;
1637
+ min-height: 220px;
1638
  aspect-ratio: 16 / 10;
1639
  object-fit: contain;
1640
  background: #090a08;
 
1747
  background: rgba(255, 139, 116, 0.18);
1748
  }
1749
 
1750
+ .severity-chip.minor {
1751
+ border-color: rgba(95, 183, 255, 0.26);
1752
+ color: #c6e5ff;
1753
+ background: rgba(95, 183, 255, 0.12);
1754
+ }
1755
+
1756
+ .severity-chip.moderate {
1757
+ border-color: rgba(255, 129, 87, 0.32);
1758
+ color: #ffd0bf;
1759
+ background: rgba(255, 129, 87, 0.14);
1760
+ }
1761
+
1762
+ .severity-chip.high {
1763
+ border-color: rgba(255, 139, 116, 0.42);
1764
+ color: #ffd0c7;
1765
+ background: rgba(255, 139, 116, 0.18);
1766
+ }
1767
+
1768
  .quality-list span {
1769
  border-radius: 999px;
1770
  padding: 7px 10px;
 
1778
  gap: 8px;
1779
  margin-top: 18px;
1780
  overflow-x: auto;
1781
+ overscroll-behavior-x: contain;
1782
  scrollbar-width: thin;
1783
  }
1784
 
 
1810
  overflow: hidden;
1811
  }
1812
 
1813
+ .coach-meta {
1814
+ margin-bottom: 14px;
1815
+ }
1816
+
1817
+ .system-note {
1818
+ border: 1px solid rgba(95, 183, 255, 0.22);
1819
+ border-radius: var(--radius);
1820
+ padding: 11px 12px;
1821
+ background: rgba(95, 183, 255, 0.09);
1822
+ }
1823
+
1824
+ .system-note.warning {
1825
+ border-color: rgba(255, 129, 87, 0.3);
1826
+ background: rgba(255, 129, 87, 0.1);
1827
+ }
1828
+
1829
+ .coach-plan-grid {
1830
+ display: grid;
1831
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1832
+ gap: 12px;
1833
+ margin-top: 16px;
1834
+ }
1835
+
1836
+ .coach-plan-grid .brief-tile {
1837
+ grid-row: span 2;
1838
+ min-height: 100%;
1839
+ }
1840
+
1841
+ .coach-note.muted {
1842
+ opacity: 0.86;
1843
+ }
1844
+
1845
  .json-block {
1846
  min-height: 360px;
1847
  max-height: 620px;
1848
+ margin: 0;
1849
  overflow: auto;
1850
  padding: 18px;
1851
  color: var(--paper);
 
1884
  font-size: 0.78rem;
1885
  }
1886
 
1887
+ .json-details {
1888
+ border-top: 1px solid rgba(245, 242, 232, 0.12);
1889
+ }
1890
+
1891
+ .json-details summary {
1892
+ cursor: pointer;
1893
+ padding: 16px 18px;
1894
+ color: var(--volt);
1895
+ font-weight: 700;
1896
+ }
1897
+
1898
  .error {
1899
  border: 1px solid rgba(255, 139, 116, 0.38);
1900
  border-radius: var(--radius);
 
1952
  }
1953
 
1954
  @media (max-width: 980px) {
1955
+ .brief-grid,
1956
+ .score-board,
1957
+ .coach-plan-grid,
1958
+ .rep-score-row,
1959
  .note-grid,
1960
  .stat-grid,
1961
  .metric-grid,
 
1964
  .issue-card-grid {
1965
  grid-template-columns: 1fr 1fr;
1966
  }
1967
+
1968
+ .report-hero,
1969
+ .replay-layout,
1970
+ .issue-card-feature {
1971
+ grid-template-columns: 1fr;
1972
+ }
1973
  }
1974
 
1975
  @media (max-width: 720px) {
 
1999
  .hero-lab,
2000
  .hero-metrics,
2001
  .form-grid,
2002
+ .brief-grid,
2003
+ .score-board,
2004
+ .coach-plan-grid,
2005
+ .rep-score-row,
2006
  .note-grid,
2007
  .stat-grid,
2008
  .metric-grid,
 
2012
  grid-template-columns: 1fr;
2013
  }
2014
 
2015
+ .verdict-card {
2016
+ min-height: 0;
2017
+ }
2018
+
2019
+ .rep-track {
2020
+ height: 118px;
2021
+ }
2022
+
2023
+ .rep-track.interactive {
2024
+ height: 126px;
2025
+ }
2026
+
2027
+ .rep-segment {
2028
+ min-width: 46px;
2029
+ }
2030
+
2031
+ .timeline-video,
2032
+ .timeline-video-placeholder {
2033
+ min-height: 240px;
2034
+ }
2035
+
2036
  .hero-lab {
2037
  display: none;
2038
  }