react-doctor-cli-dev 1.0.7 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/backend/dist/db.js +33 -11
  2. package/backend/dist/index.js +29 -3
  3. package/backend/dist/routes/reports.js +106 -55
  4. package/backend/public/assets/index-BpODc0fS.css +1 -0
  5. package/backend/public/assets/index-zKyZPsv1.js +118 -0
  6. package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  7. package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  8. package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  9. package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  10. package/backend/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  11. package/backend/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  12. package/backend/public/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  13. package/backend/public/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  14. package/backend/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  15. package/backend/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  16. package/backend/public/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  17. package/backend/public/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  18. package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  19. package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  20. package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  21. package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  22. package/backend/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  23. package/backend/public/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  24. package/backend/public/assets/tajawal-arabic-300-normal-Bq0yWa0Z.woff +0 -0
  25. package/backend/public/assets/tajawal-arabic-300-normal-By07C9pa.woff2 +0 -0
  26. package/backend/public/assets/tajawal-arabic-400-normal-CyCXRvzh.woff2 +0 -0
  27. package/backend/public/assets/tajawal-arabic-400-normal-DCQxawbB.woff +0 -0
  28. package/backend/public/assets/tajawal-arabic-500-normal-BZ8ojJNu.woff2 +0 -0
  29. package/backend/public/assets/tajawal-arabic-500-normal-CbVEaYEW.woff +0 -0
  30. package/backend/public/assets/tajawal-arabic-700-normal-9L7Zusdl.woff +0 -0
  31. package/backend/public/assets/tajawal-arabic-700-normal-D2-eand5.woff2 +0 -0
  32. package/backend/public/assets/tajawal-latin-300-normal-C0-xR3ms.woff +0 -0
  33. package/backend/public/assets/tajawal-latin-300-normal-CeEKeOxZ.woff2 +0 -0
  34. package/backend/public/assets/tajawal-latin-400-normal-BVNSOH3d.woff2 +0 -0
  35. package/backend/public/assets/tajawal-latin-400-normal-BdYcZznU.woff +0 -0
  36. package/backend/public/assets/tajawal-latin-500-normal-CoYeBiSI.woff2 +0 -0
  37. package/backend/public/assets/tajawal-latin-500-normal-DU9v6xgj.woff +0 -0
  38. package/backend/public/assets/tajawal-latin-700-normal-BypgxfGb.woff2 +0 -0
  39. package/backend/public/assets/tajawal-latin-700-normal-CV3bxpHe.woff +0 -0
  40. package/backend/public/favicon.svg +1 -0
  41. package/backend/public/icons.svg +24 -0
  42. package/backend/public/index.html +254 -0
  43. package/backend/src/db.ts +42 -18
  44. package/backend/src/index.ts +31 -3
  45. package/backend/src/routes/reports.ts +141 -52
  46. package/cli/dist/commands/full.js +82 -48
  47. package/cli/src/commands/full.ts +161 -115
  48. package/dashboard/index.html +253 -0
  49. package/dashboard/package-lock.json +913 -0
  50. package/dashboard/package.json +19 -0
  51. package/dashboard/public/favicon.svg +1 -0
  52. package/dashboard/public/icons.svg +24 -0
  53. package/dashboard/src/api.js +107 -0
  54. package/dashboard/src/assets/hero.png +0 -0
  55. package/dashboard/src/assets/javascript.svg +1 -0
  56. package/dashboard/src/assets/vite.svg +1 -0
  57. package/dashboard/src/css/main.css +441 -0
  58. package/dashboard/src/data.js +202 -0
  59. package/dashboard/src/main.js +53 -0
  60. package/dashboard/src/pages.js +797 -0
  61. package/dashboard/src/router.js +77 -0
  62. package/dashboard/src/utils.js +163 -0
  63. package/dashboard/vite.config.js +19 -0
  64. package/data/screenshots/5--fcp.png +0 -0
  65. package/data/screenshots/5--fullLoad.png +0 -0
  66. package/data/screenshots/5-docs-fullLoad.png +0 -0
  67. package/data/screenshots/5-white-fullLoad.png +0 -0
  68. package/data/screenshots/6--fcp.png +0 -0
  69. package/data/screenshots/6--fullLoad.png +0 -0
  70. package/data/screenshots/6-docs-fullLoad.png +0 -0
  71. package/data/screenshots/6-white-fullLoad.png +0 -0
  72. package/package.json +4 -1
@@ -11,18 +11,7 @@
11
11
  // 4. RuleEngine → combines both reports into suggestions
12
12
  // 5. ReportCompiler → merges everything into finalreport.json
13
13
  // 6. Upload → sends the report to the backend API
14
- //
15
- // HOW IMPORTS WORK:
16
- // The CLI imports core modules directly as TypeScript classes.
17
- // No shell commands, no spawning child processes, no ts-node
18
- // inside ts-node. Everything runs in the same Node.js process,
19
- // which means objects are passed between steps in memory —
20
- // fast, clean, and no disk reads between steps.
21
- //
22
- // The core folder is 2 levels up from cli/src/commands/:
23
- // cli/src/commands/full.ts
24
- // → ../../.. = react-tool root
25
- // → ../../../core = core folder
14
+ // and opens the dashboard to that report
26
15
  // ─────────────────────────────────────────────────────────────
27
16
 
28
17
  import { Command } from "commander";
@@ -45,14 +34,7 @@ import {
45
34
  } from "../ui";
46
35
 
47
36
  // ── Core imports ──────────────────────────────────────────────
48
- // These are the actual classes from the core folder.
49
- // We use require() with a resolved path so they work whether
50
- // the CLI is run from its own folder or from the project root.
51
-
52
37
  function getCoreModule(relativePath: string) {
53
- // __dirname = cli/src/commands/
54
- // 3 levels up = react-tool root
55
- // then into core/
56
38
  return require(
57
39
  path.resolve(__dirname, "..", "..", "..", "core", relativePath),
58
40
  );
@@ -104,21 +86,19 @@ export function registerFullCommand(program: Command): void {
104
86
  "Backend API URL to upload to",
105
87
  "http://localhost:3000",
106
88
  )
107
- // ✅ Single --api-key option (defined BEFORE .action)
108
89
  .option(
109
90
  "--api-key <key>",
110
91
  "API key for backend authentication (overrides REACT_DOCTOR_API_KEY env var)",
111
92
  process.env.REACT_DOCTOR_API_KEY || "react-doctor-secret-key-change-this",
112
93
  )
113
94
  .option("--no-banner", "Skip the banner")
114
- // ✅ .action() comes LAST, with no trailing semicolon/comment
115
95
  .action(async (projectPath: string, options) => {
116
96
  await runFullCommand(projectPath, options);
117
97
  });
118
98
  }
99
+
119
100
  // ─────────────────────────────────────────────────────────────
120
101
  // MAIN RUNNER
121
- // Exported so other commands (analyze --full) can call it too.
122
102
  // ─────────────────────────────────────────────────────────────
123
103
 
124
104
  export async function runFullCommand(
@@ -149,10 +129,6 @@ export async function runFullCommand(
149
129
  }
150
130
 
151
131
  // ── Determine device configuration ──────────────────────────
152
- // --desktop and --mobile are independent flags.
153
- // If neither is passed, desktop is the default.
154
- // If only --mobile is passed, only mobile runs.
155
- // If both are passed, both run in one pass.
156
132
  const wantDesktop = options.desktop || (!options.desktop && !options.mobile);
157
133
  const wantMobile = options.mobile ?? false;
158
134
 
@@ -180,8 +156,6 @@ export async function runFullCommand(
180
156
  printInfo("Network", throttleLabel);
181
157
 
182
158
  // ── Output directory ───────────────────────────────────────
183
- // Reports are saved inside the user's project in a hidden
184
- // .react-doctor/ folder — easy to find, easy to gitignore.
185
159
  const outputDir = path.join(resolvedPath, ".react-doctor");
186
160
  fs.mkdirSync(outputDir, { recursive: true });
187
161
 
@@ -275,7 +249,6 @@ export async function runFullCommand(
275
249
  ),
276
250
  );
277
251
 
278
- // Print results for each route
279
252
  for (const [key, report] of Object.entries(runtimeReports)) {
280
253
  const [route, device] = key.includes("::")
281
254
  ? key.split("::")
@@ -285,7 +258,6 @@ export async function runFullCommand(
285
258
  console.log(
286
259
  ` ${chalk.bold(route)} ${chalk.gray(`[${device}]`)} Score: ${scoreBadge(report.performanceScore)}`,
287
260
  );
288
- // ── Device / CPU / Network line ──────────────────────────
289
261
  console.log(
290
262
  ` ${chalk.gray("Device:")} ${device} ` +
291
263
  `${chalk.gray("CPU:")} ${report.cpuThrottling ?? cpuLabel}x ` +
@@ -346,7 +318,6 @@ export async function runFullCommand(
346
318
  } catch (err: any) {
347
319
  profilingSpin.fail(chalk.red("Runtime profiling failed"));
348
320
  console.log(chalk.red(`\n ${err.message}\n`));
349
- // Profiling failure is not fatal — rule engine can still run on static data
350
321
  }
351
322
 
352
323
  // ════════════════════════════════════════════════════════════
@@ -452,103 +423,178 @@ export async function runFullCommand(
452
423
  }
453
424
 
454
425
  // ════════════════════════════════════════════════════════════
455
- // OPTIONAL — UPLOAD TO BACKEND API
426
+ // OPTIONAL — UPLOAD TO BACKEND API (WITH SCREENSHOTS)
456
427
  // ════════════════════════════════════════════════════════════
457
428
 
458
429
  if (options.upload && finalReport) {
459
- printSection("Uploading to Backend");
430
+ printSection("Uploading to Backend");
460
431
 
461
- const uploadSpin = spinner(`Connecting to ${options.apiUrl}...`);
462
-
463
- try {
464
- // Ensure apiUrl has a default
465
- const apiUrl = options.apiUrl ?? "http://localhost:3000";
466
-
467
- // 1. Check if backend is already running
468
- try {
469
- await axios.get(`${apiUrl}/health`, { timeout: 2000 });
470
- uploadSpin.text = "Backend detected. Preparing upload...";
471
- } catch (err) {
472
- // 2. If not running, start it automatically
473
-
474
- // Determine the project root directory (where cli/ and backend/ are siblings)
475
- const projectRoot = path.resolve(__dirname, "..", "..", "..");
476
-
477
- // Backend is a sibling folder to cli at projectRoot
478
- const backendRoot = path.resolve(projectRoot, "backend");
479
-
480
- // Check for compiled JS first (for installed packages)
481
- const backendDist = path.join(backendRoot, "dist", "index.js");
482
- // Check for TS source (for local dev)
483
- const backendSrc = path.join(backendRoot, "src", "index.ts");
484
-
485
- let command: string;
486
- let args: string[];
487
-
488
- if (fs.existsSync(backendDist)) {
489
- command = "node";
490
- args = [backendDist];
491
- } else if (fs.existsSync(backendSrc)) {
492
- command = "npx";
493
- args = ["ts-node", backendSrc];
494
- } else {
495
- throw new Error(`Cannot find backend at: ${backendRoot}. Ensure 'backend' folder exists next to 'cli'.`);
496
- }
497
-
498
- uploadSpin.text = "Backend not found. Starting local server automatically...";
432
+ const uploadSpin = spinner(`Connecting to ${options.apiUrl}...`);
499
433
 
500
- // Extract port safely
501
- const port = new URL(apiUrl).port || "3000";
434
+ try {
435
+ const apiUrl = options.apiUrl ?? "http://localhost:3000";
436
+
437
+ // 1. Check if backend is already running
438
+ try {
439
+ await axios.get(`${apiUrl}/health`, { timeout: 2000 });
440
+ uploadSpin.text = "Backend detected. Preparing upload...";
441
+ } catch (err) {
442
+ // 2. If not running, start it automatically
443
+ const projectRoot = path.resolve(__dirname, "..", "..", "..");
444
+ const backendRoot = path.resolve(projectRoot, "backend");
445
+ const backendDist = path.join(backendRoot, "dist", "index.js");
446
+ const backendSrc = path.join(backendRoot, "src", "index.ts");
447
+
448
+ let command: string;
449
+ let args: string[];
450
+
451
+ if (fs.existsSync(backendDist)) {
452
+ command = "node";
453
+ args = [backendDist];
454
+ } else if (fs.existsSync(backendSrc)) {
455
+ command = "npx";
456
+ args = ["ts-node", backendSrc];
457
+ } else {
458
+ throw new Error(
459
+ `Cannot find backend at: ${backendRoot}. Ensure 'backend' folder exists next to 'cli'.`,
460
+ );
461
+ }
502
462
 
503
- // Spawn the backend process
504
- // Create backend data directory in the target project
505
- const backendDataDir = path.join(outputDir, "backend-data");
506
- fs.mkdirSync(backendDataDir, { recursive: true });
463
+ uploadSpin.text =
464
+ "Backend not found. Starting local server automatically...";
465
+
466
+ const port = new URL(apiUrl).port || "3000";
467
+ const backendDataDir = path.join(outputDir, "backend-data");
468
+ fs.mkdirSync(backendDataDir, { recursive: true });
469
+
470
+ spawn(command, args, {
471
+ stdio: "inherit",
472
+ env: {
473
+ ...process.env,
474
+ API_KEY: options.apiKey || "react-doctor-secret-key-change-this",
475
+ PORT: port,
476
+ DB_PATH: path.join(backendDataDir, "reports.db"),
477
+ },
478
+ cwd: backendRoot,
479
+ });
480
+
481
+ // 3. Wait for backend to be ready (up to 15s)
482
+ let isReady = false;
483
+ let retries = 0;
484
+ while (!isReady && retries < 15) {
485
+ try {
486
+ await axios.get(`${apiUrl}/health`, { timeout: 1000 });
487
+ isReady = true;
488
+ } catch {
489
+ await new Promise((r) => setTimeout(r, 1000));
490
+ retries++;
491
+ }
492
+ }
507
493
 
508
- const backendProcess = spawn(command, args, {
509
- stdio: "inherit",
510
- env: {
511
- ...process.env,
512
- API_KEY: options.apiKey || "react-doctor-secret-key-change-this",
513
- PORT: port,
514
- DB_PATH: path.join(backendDataDir, "reports.db"),
515
- },
516
- cwd: backendRoot
517
- });
494
+ if (!isReady)
495
+ throw new Error("Backend failed to start after 15 seconds.");
496
+ uploadSpin.text = "Backend started successfully!";
497
+ }
518
498
 
519
- // 3. Wait for backend to be ready
520
- let isReady = false;
521
- let retries = 0;
522
- while (!isReady && retries < 15) {
523
- try {
524
- await axios.get(`${apiUrl}/health`, { timeout: 1000 });
525
- isReady = true;
526
- } catch {
527
- await new Promise((r) => setTimeout(r, 1000));
528
- retries++;
499
+ // ── 4. Read and encode screenshots ──────────────────────
500
+ uploadSpin.text = "Processing screenshots...";
501
+ const screenshots: any[] = [];
502
+ const runtime = finalReport.runtime || {};
503
+
504
+ for (const [routeKey, routeData] of Object.entries(runtime)) {
505
+ if ((routeData as any).screenshots && (routeData as any).screenshots.length > 0) {
506
+ for (const screenshot of (routeData as any).screenshots) {
507
+ try {
508
+ // Check if this is a file path or already a data URL
509
+ if (screenshot.dataUrl && !screenshot.dataUrl.startsWith('data:')) {
510
+ // It's a file path - read and encode it
511
+ const screenshotPath = path.join(outputDir, screenshot.dataUrl);
512
+ if (fs.existsSync(screenshotPath)) {
513
+ const imageBuffer = fs.readFileSync(screenshotPath);
514
+ const base64Image = imageBuffer.toString('base64');
515
+ screenshots.push({
516
+ route: routeKey,
517
+ label: screenshot.label || 'screenshot',
518
+ takenAt: screenshot.takenAt || 0,
519
+ dataUrl: `data:image/png;base64,${base64Image}`,
520
+ });
521
+ } else {
522
+ // File doesn't exist - store as placeholder
523
+ console.log(chalk.yellow(` ⚠️ Screenshot not found: ${screenshotPath}`));
524
+ screenshots.push({
525
+ route: routeKey,
526
+ label: screenshot.label || 'screenshot',
527
+ takenAt: screenshot.takenAt || 0,
528
+ dataUrl: null,
529
+ });
530
+ }
531
+ } else if (screenshot.dataUrl && screenshot.dataUrl.startsWith('data:')) {
532
+ // Already a data URL - use it directly
533
+ screenshots.push({
534
+ route: routeKey,
535
+ label: screenshot.label || 'screenshot',
536
+ takenAt: screenshot.takenAt || 0,
537
+ dataUrl: screenshot.dataUrl,
538
+ });
539
+ }
540
+ } catch (err: any) {
541
+ console.log(chalk.yellow(` ⚠️ Failed to process screenshot: ${err.message}`));
542
+ }
543
+ }
529
544
  }
530
545
  }
531
546
 
532
- if (!isReady) throw new Error("Backend failed to start after 15 seconds.");
533
- uploadSpin.text = "Backend started successfully!";
534
- }
547
+ // ── 5. Prepare upload data with screenshots ─────────────
548
+ const uploadData = {
549
+ ...finalReport,
550
+ screenshots: screenshots,
551
+ };
552
+
553
+ uploadSpin.text = `Uploading report and ${screenshots.length} screenshots...`;
554
+
555
+ // ── 6. Perform the upload ──────────────────────────────
556
+ const response = await axios.post(
557
+ `${apiUrl}/api/reports/upload`,
558
+ uploadData,
559
+ {
560
+ headers: {
561
+ "Content-Type": "application/json",
562
+ "x-api-key":
563
+ options.apiKey || "react-doctor-secret-key-change-this",
564
+ } as Record<string, string>,
565
+ timeout: 30000, // Longer timeout for images
566
+ },
567
+ );
535
568
 
536
- // 4. Perform the actual upload
537
- uploadSpin.text = "Uploading report...";
538
- await axios.post(`${apiUrl}/api/reports/upload`, finalReport, {
539
- headers: {
540
- "Content-Type": "application/json",
541
- "x-api-key": options.apiKey || "react-doctor-secret-key-change-this",
542
- } as Record<string, string>,
543
- timeout: 10000,
544
- });
569
+ const reportId = response.data?.id;
570
+ const uploadedCount = response.data?.screenshots || 0;
571
+ uploadSpin.succeed(
572
+ chalk.green(`Report uploaded successfully (${uploadedCount} screenshots)`)
573
+ );
545
574
 
546
- uploadSpin.succeed(chalk.green("Report uploaded successfully"));
547
- } catch (err: any) {
548
- uploadSpin.fail(chalk.yellow("Upload failed report saved locally"));
549
- console.log(chalk.gray(` ${err.message}`));
575
+ // 7. Open the dashboard directly to this report
576
+ if (reportId) {
577
+ const dashboardUrl = `${apiUrl}/report/${reportId}`;
578
+ printInfo("Opening dashboard", dashboardUrl);
579
+
580
+ const openCmd: [string, string[]] =
581
+ process.platform === "win32"
582
+ ? ["cmd", ["/c", "start", "", dashboardUrl]]
583
+ : process.platform === "darwin"
584
+ ? ["open", [dashboardUrl]]
585
+ : ["xdg-open", [dashboardUrl]];
586
+
587
+ spawn(openCmd[0], openCmd[1], {
588
+ stdio: "ignore",
589
+ detached: true,
590
+ }).unref();
591
+ }
592
+ } catch (err: any) {
593
+ uploadSpin.fail(chalk.yellow("Upload failed — report saved locally"));
594
+ console.log(chalk.gray(` ${err.message}`));
595
+ }
550
596
  }
551
- }
597
+
552
598
  // ════════════════════════════════════════════════════════════
553
599
  // FINAL SUMMARY
554
600
  // ════════════════════════════════════════════════════════════
@@ -571,4 +617,4 @@ export async function runFullCommand(
571
617
  }
572
618
 
573
619
  printDone("Full diagnostic finished.");
574
- }
620
+ }
@@ -0,0 +1,253 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>React Doctor — Dashboard</title>
7
+ <meta name="theme-color" content="#0B0C10" />
8
+ <!-- main.css is imported by src/main.js via Vite — no link tag needed here -->
9
+ </head>
10
+ <body>
11
+
12
+ <!-- ── Sidebar ─────────────────────────────────────────────── -->
13
+ <aside id="sidebar">
14
+ <div class="sidebar-logo">
15
+ <div>
16
+ <div class="logo-text">React Doctor</div>
17
+ <div class="logo-sub">Performance Dashboard</div>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="sidebar-section">Analysis</div>
22
+
23
+ <button class="nav-item" data-route="overview">
24
+ Overview
25
+ <span class="nav-badge" id="nav-score"></span>
26
+ </button>
27
+ <button class="nav-item" data-route="vitals">
28
+ Web Vitals
29
+ </button>
30
+ <button class="nav-item" data-route="issues">
31
+ Code Issues
32
+ <span class="nav-badge" id="nav-issues-count"></span>
33
+ </button>
34
+ <button class="nav-item" data-route="suggestions">
35
+ Suggestions
36
+ <span class="nav-badge" id="nav-sug-count"></span>
37
+ </button>
38
+
39
+ <div class="sidebar-section">Reports</div>
40
+
41
+ <button class="nav-item" data-route="history">
42
+ History
43
+ <span class="nav-badge" id="nav-hist-count"></span>
44
+ </button>
45
+
46
+ <div class="sidebar-footer">
47
+ <div class="project-pill">
48
+ <div class="status-dot"></div>
49
+ <div>
50
+ <div class="proj-name" id="sidebar-project">—</div>
51
+ <div class="proj-sub" id="sidebar-date">—</div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </aside>
56
+
57
+ <!-- ── Main ───────────────────────────────────────────────── -->
58
+ <div id="main">
59
+
60
+ <!-- Topbar -->
61
+ <div class="topbar">
62
+ <div>
63
+ <div class="topbar-title" id="topbar-title">Overview</div>
64
+ <div class="topbar-sub" id="topbar-sub">Performance summary</div>
65
+ </div>
66
+ <div class="topbar-right">
67
+ <div class="topbar-time" id="topbar-time"></div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- ══ PAGE: OVERVIEW ═══════════════════════════════════════ -->
72
+ <div class="page-content" id="page-overview">
73
+
74
+ <!-- Score + meta -->
75
+ <div class="card section-gap">
76
+ <div class="score-ring-wrap">
77
+ <div id="ov-score-ring"></div>
78
+ <div class="score-details">
79
+ <h2>Performance Score</h2>
80
+ <p id="ov-summary" style="margin-bottom:12px">Loading…</p>
81
+ <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
82
+ <div id="ov-grade"></div>
83
+ <span style="font-size:.78rem;color:var(--text3)">Project: <strong id="ov-project" style="color:var(--text)">—</strong></span>
84
+ <span style="font-size:.78rem;color:var(--text3)">Analyzed: <span id="ov-analyzed" style="color:var(--text2)">—</span></span>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Vital stat tiles -->
91
+ <div class="grid-4 section-gap">
92
+ <div class="stat-tile" id="tile-lcp">
93
+ <div class="stat-label">Avg LCP</div>
94
+ <div class="stat-value">—</div>
95
+ <div class="stat-meta">Good &lt; 2.5s</div>
96
+ </div>
97
+ <div class="stat-tile" id="tile-fcp">
98
+ <div class="stat-label">Avg FCP</div>
99
+ <div class="stat-value">—</div>
100
+ <div class="stat-meta">Good &lt; 1.8s</div>
101
+ </div>
102
+ <div class="stat-tile" id="tile-cls">
103
+ <div class="stat-label">Avg CLS</div>
104
+ <div class="stat-value">—</div>
105
+ <div class="stat-meta">Good &lt; 0.1</div>
106
+ </div>
107
+ <div class="stat-tile" id="tile-issues">
108
+ <div class="stat-label">Total Issues</div>
109
+ <div class="stat-value">—</div>
110
+ <div class="stat-meta">Static analysis</div>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Routes table -->
115
+ <div class="card section-gap">
116
+ <div class="card-title"> Routes Profiled</div>
117
+ <div id="ov-routes"></div>
118
+ </div>
119
+
120
+ <!-- Top suggestions -->
121
+ <div class="card">
122
+ <div class="card-title" style="margin-bottom:14px">
123
+ Top Suggestions
124
+ <button onclick="window.location.hash='suggestions'" style="margin-left:auto;background:none;border:1px solid var(--border);color:var(--text2);border-radius:var(--radius-sm);padding:3px 12px;cursor:pointer;font-size:.72rem">View all →</button>
125
+ </div>
126
+ <div id="ov-suggestions"></div>
127
+ </div>
128
+
129
+ </div><!-- /page-overview -->
130
+
131
+
132
+ <!-- ══ PAGE: VITALS ══════════════════════════════════════════ -->
133
+ <div class="page-content" id="page-vitals">
134
+
135
+ <!-- Route selector -->
136
+ <div class="route-tabs" id="vitals-tabs"></div>
137
+
138
+ <div class="grid-2 section-gap" style="grid-template-columns:1fr 320px">
139
+
140
+ <!-- Bars -->
141
+ <div class="card">
142
+ <div class="card-title">
143
+ Core Web Vitals
144
+ <div id="vitals-meta" style="display:flex;gap:8px;flex-wrap:wrap;margin-left:8px"></div>
145
+ </div>
146
+ <div id="vitals-bars"></div>
147
+ <div style="margin-top:16px;display:flex;align-items:center;gap:10px">
148
+ <span style="font-size:.75rem;color:var(--text3)">Performance score:</span>
149
+ <div id="vitals-score"></div>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Re-renders -->
154
+ <div class="card">
155
+ <div class="card-title">Component Re-renders</div>
156
+ <div id="vitals-rerenders"></div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Chart: all routes comparison -->
161
+ <div class="card section-gap">
162
+ <div class="card-title">All Routes — LCP / FCP / TTFB Comparison</div>
163
+ <div style="height:240px;position:relative">
164
+ <canvas id="vitals-chart"></canvas>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Errors -->
169
+ <div class="card section-gap">
170
+ <div class="card-title">JS Errors &amp; Console Warnings</div>
171
+ <div id="vitals-errors"></div>
172
+ </div>
173
+
174
+ <!-- Screenshots -->
175
+ <div class="card">
176
+ <div class="card-title">Screenshots</div>
177
+ <div class="filmstrip" id="vitals-filmstrip"></div>
178
+ </div>
179
+
180
+ </div><!-- /page-vitals -->
181
+
182
+
183
+ <!-- ══ PAGE: ISSUES ══════════════════════════════════════════ -->
184
+ <div class="page-content" id="page-issues">
185
+
186
+ <div class="filter-bar" id="issues-filters">
187
+ <button class="filter-btn active" data-filter="all">All</button>
188
+ <button class="filter-btn fc" data-filter="critical">🔴 Critical</button>
189
+ <button class="filter-btn fw" data-filter="warning">🟠 Warning</button>
190
+ <button class="filter-btn fi" data-filter="info">🔵 Info</button>
191
+ </div>
192
+
193
+ <div class="card">
194
+ <div id="issues-table"></div>
195
+ <div id="issues-pagination"></div>
196
+ </div>
197
+
198
+ </div><!-- /page-issues -->
199
+
200
+
201
+ <!-- ══ PAGE: SUGGESTIONS ═════════════════════════════════════ -->
202
+ <div class="page-content" id="page-suggestions">
203
+
204
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap">
205
+ <div class="filter-bar" id="sug-filters" style="margin-bottom:0">
206
+ <button class="filter-btn active" data-filter="all">All</button>
207
+ <button class="filter-btn fc" data-filter="critical">🔴 Critical</button>
208
+ <button class="filter-btn fw" data-filter="warning">🟠 Warning</button>
209
+ <button class="filter-btn fi" data-filter="info">🔵 Info</button>
210
+ </div>
211
+ <div id="sug-counts" style="display:flex;gap:8px;margin-left:auto;flex-wrap:wrap"></div>
212
+ </div>
213
+
214
+ <div id="sug-list"></div>
215
+ <div id="sug-pagination"></div>
216
+
217
+ </div><!-- /page-suggestions -->
218
+
219
+
220
+ <!-- ══ PAGE: HISTORY ══════════════════════════════════════════ -->
221
+ <div class="page-content" id="page-history">
222
+
223
+ <!-- Trend chart -->
224
+ <div class="card section-gap">
225
+ <div class="card-title">Score Trend — my-react-app</div>
226
+ <div style="height:200px;position:relative">
227
+ <canvas id="hist-chart"></canvas>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- Table -->
232
+ <div class="card">
233
+ <div class="card-title"> All Runs</div>
234
+ <div id="hist-table"></div>
235
+ <div id="hist-pagination"></div>
236
+ </div>
237
+
238
+ </div><!-- /page-history -->
239
+
240
+ </div><!-- /main -->
241
+
242
+ <!-- Lightbox -->
243
+ <div id="lightbox">
244
+ <button id="lb-close">✕</button>
245
+ <img src="" alt="screenshot" />
246
+ <div id="lb-caption"></div>
247
+ </div>
248
+
249
+ <!-- Scripts -->
250
+ <script type="module" src="/src/main.js"></script>
251
+
252
+ </body>
253
+ </html>