@hydration-audit/dashboard 0.2.2 → 0.2.3

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 (2) hide show
  1. package/dist/index.html +1400 -232
  2. package/package.json +2 -2
package/dist/index.html CHANGED
@@ -4,19 +4,31 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Hydration Tax Dashboard</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
7
10
  <style>
8
11
  :root {
9
- --bg: #0f1117;
10
- --surface: #1a1d27;
11
- --surface-2: #242833;
12
- --border: #2e3345;
13
- --text: #e1e4ed;
14
- --text-dim: #8b90a0;
15
- --green: #4ade80;
16
- --yellow: #facc15;
17
- --red: #f87171;
18
- --blue: #60a5fa;
19
- --purple: #c084fc;
12
+ --bg: #07090e;
13
+ --surface: #0e111a;
14
+ --surface-hover: #151926;
15
+ --surface-active: #1d2234;
16
+ --border: #1f2538;
17
+ --border-focus: #3b82f6;
18
+ --text: #f1f5f9;
19
+ --text-secondary: #94a3b8;
20
+ --text-muted: #64748b;
21
+ --green: #10b981;
22
+ --green-alpha: rgba(16, 185, 129, 0.15);
23
+ --yellow: #f59e0b;
24
+ --yellow-alpha: rgba(245, 158, 11, 0.15);
25
+ --red: #ef4444;
26
+ --red-alpha: rgba(239, 68, 68, 0.15);
27
+ --blue: #3b82f6;
28
+ --blue-alpha: rgba(59, 130, 246, 0.15);
29
+ --purple: #8b5cf6;
30
+ --purple-alpha: rgba(139, 92, 246, 0.15);
31
+ --sidebar-width: 260px;
20
32
  }
21
33
 
22
34
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -25,119 +37,827 @@
25
37
  background: var(--bg);
26
38
  color: var(--text);
27
39
  line-height: 1.5;
40
+ overflow-x: hidden;
28
41
  }
29
42
 
30
- .header {
31
- padding: 1.5rem 2rem;
32
- border-bottom: 1px solid var(--border);
43
+ /* Layout */
44
+ .container {
45
+ display: flex;
46
+ min-height: 100vh;
47
+ }
48
+
49
+ .sidebar {
50
+ width: var(--sidebar-width);
51
+ background: var(--surface);
52
+ border-right: 1px solid var(--border);
53
+ padding: 2rem 1.5rem;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 2rem;
57
+ position: fixed;
58
+ height: 100vh;
59
+ z-index: 10;
60
+ }
61
+
62
+ .main-content {
63
+ flex: 1;
64
+ margin-left: var(--sidebar-width);
65
+ padding: 2rem 3rem;
66
+ max-width: 1600px;
67
+ width: calc(100% - var(--sidebar-width));
68
+ }
69
+
70
+ /* Brand & Info */
71
+ .brand h1 {
72
+ font-size: 1.2rem;
73
+ font-weight: 700;
74
+ letter-spacing: -0.02em;
75
+ background: linear-gradient(135deg, #fff 0%, var(--text-secondary) 100%);
76
+ -webkit-background-clip: text;
77
+ -webkit-text-fill-color: transparent;
78
+ margin-bottom: 0.25rem;
79
+ }
80
+
81
+ .brand .framework-tag {
82
+ font-size: 0.75rem;
83
+ padding: 2px 8px;
84
+ background: var(--surface-active);
85
+ border-radius: 4px;
86
+ color: var(--blue);
87
+ text-transform: uppercase;
88
+ font-weight: 600;
89
+ letter-spacing: 0.05em;
90
+ display: inline-block;
91
+ }
92
+
93
+ .project-meta {
94
+ font-size: 0.8rem;
95
+ color: var(--text-muted);
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: 0.5rem;
99
+ }
100
+
101
+ .project-meta div span {
102
+ display: block;
103
+ color: var(--text-secondary);
104
+ font-weight: 500;
105
+ }
106
+
107
+ /* Sidebar Navigation */
108
+ .nav {
109
+ display: flex;
110
+ flex-direction: column;
111
+ gap: 0.5rem;
112
+ }
113
+
114
+ .nav-item {
33
115
  display: flex;
34
116
  align-items: center;
35
117
  justify-content: space-between;
118
+ padding: 0.75rem 1rem;
119
+ border-radius: 8px;
120
+ color: var(--text-secondary);
121
+ text-decoration: none;
122
+ font-size: 0.9rem;
123
+ font-weight: 500;
124
+ cursor: pointer;
125
+ transition: all 0.2s ease;
126
+ background: transparent;
127
+ border: none;
128
+ text-align: left;
129
+ width: 100%;
36
130
  }
37
- .header h1 { font-size: 1.25rem; font-weight: 600; }
38
- .header .meta { color: var(--text-dim); font-size: 0.85rem; }
39
- .live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--green); margin-right: 6px; animation: pulse 2s infinite; }
40
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
41
131
 
42
- .dashboard { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem 2rem; }
43
- .card {
132
+ .nav-item:hover {
133
+ background: var(--surface-hover);
134
+ color: var(--text);
135
+ }
136
+
137
+ .nav-item.active {
138
+ background: var(--surface-active);
139
+ color: var(--text);
140
+ border-left: 3px solid var(--blue);
141
+ border-top-left-radius: 4px;
142
+ border-bottom-left-radius: 4px;
143
+ }
144
+
145
+ .nav-badge {
146
+ font-size: 0.75rem;
147
+ padding: 2px 6px;
148
+ border-radius: 20px;
149
+ background: var(--surface-hover);
150
+ color: var(--text-secondary);
151
+ }
152
+
153
+ .nav-item.active .nav-badge {
154
+ background: var(--blue-alpha);
155
+ color: var(--blue);
156
+ }
157
+
158
+ /* Status dot */
159
+ .status {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 0.5rem;
163
+ font-size: 0.8rem;
164
+ color: var(--text-secondary);
165
+ margin-top: auto;
166
+ }
167
+
168
+ .status-dot {
169
+ width: 8px;
170
+ height: 8px;
171
+ border-radius: 50%;
172
+ background: var(--green);
173
+ animation: pulse 2s infinite;
174
+ }
175
+
176
+ @keyframes pulse {
177
+ 0%, 100% { opacity: 1; transform: scale(1); }
178
+ 50% { opacity: 0.4; transform: scale(1.1); }
179
+ }
180
+
181
+ /* Header */
182
+ .header {
183
+ display: flex;
184
+ justify-content: space-between;
185
+ align-items: center;
186
+ margin-bottom: 2rem;
187
+ }
188
+
189
+ .header-title h2 {
190
+ font-size: 1.75rem;
191
+ font-weight: 600;
192
+ letter-spacing: -0.02em;
193
+ }
194
+
195
+ .header-title p {
196
+ color: var(--text-secondary);
197
+ font-size: 0.9rem;
198
+ margin-top: 0.25rem;
199
+ }
200
+
201
+ /* Grid layout for Overview */
202
+ .overview-grid {
203
+ display: grid;
204
+ grid-template-columns: 2fr 1fr;
205
+ gap: 1.5rem;
206
+ margin-top: 1.5rem;
207
+ }
208
+
209
+ /* Stats Grid */
210
+ .stats-grid {
211
+ display: grid;
212
+ grid-template-columns: repeat(4, 1fr);
213
+ gap: 1.25rem;
214
+ margin-bottom: 1.5rem;
215
+ }
216
+
217
+ .stat-card {
44
218
  background: var(--surface);
45
219
  border: 1px solid var(--border);
46
220
  border-radius: 12px;
47
221
  padding: 1.25rem;
222
+ transition: border-color 0.2s ease;
48
223
  }
49
- .card h2 { font-size: 0.9rem; font-weight: 500; color: var(--text-dim); margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
50
- .card.full { grid-column: 1 / -1; }
51
224
 
52
- /* Summary Cards */
53
- .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; grid-column: 1 / -1; }
54
- .stat-card {
225
+ .stat-card:hover {
226
+ border-color: var(--surface-active);
227
+ }
228
+
229
+ .stat-label {
230
+ font-size: 0.8rem;
231
+ font-weight: 600;
232
+ color: var(--text-secondary);
233
+ text-transform: uppercase;
234
+ letter-spacing: 0.05em;
235
+ }
236
+
237
+ .stat-value {
238
+ font-size: 1.8rem;
239
+ font-weight: 700;
240
+ margin-top: 0.5rem;
241
+ letter-spacing: -0.02em;
242
+ }
243
+
244
+ .stat-value.green { color: var(--green); }
245
+ .stat-value.yellow { color: var(--yellow); }
246
+ .stat-value.red { color: var(--red); }
247
+
248
+ /* Card standard */
249
+ .card {
55
250
  background: var(--surface);
56
251
  border: 1px solid var(--border);
57
252
  border-radius: 12px;
58
- padding: 1rem 1.25rem;
253
+ padding: 1.5rem;
254
+ display: flex;
255
+ flex-direction: column;
256
+ }
257
+
258
+ .card h3 {
259
+ font-size: 1rem;
260
+ font-weight: 600;
261
+ margin-bottom: 1.25rem;
262
+ display: flex;
263
+ align-items: center;
264
+ justify-content: space-between;
59
265
  }
60
- .stat-card .label { font-size: 0.8rem; color: var(--text-dim); }
61
- .stat-card .value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; }
62
- .stat-card .value.green { color: var(--green); }
63
- .stat-card .value.yellow { color: var(--yellow); }
64
- .stat-card .value.red { color: var(--red); }
65
266
 
66
267
  /* Treemap */
67
- .treemap-container { width: 100%; aspect-ratio: 2/1; position: relative; }
268
+ .treemap-wrapper {
269
+ position: relative;
270
+ width: 100%;
271
+ height: 420px;
272
+ background: var(--bg);
273
+ border-radius: 8px;
274
+ overflow: hidden;
275
+ border: 1px solid var(--border);
276
+ }
277
+
68
278
  .treemap-node {
69
279
  position: absolute;
70
280
  border: 1px solid var(--bg);
281
+ border-radius: 6px;
282
+ display: flex;
283
+ flex-direction: column;
284
+ align-items: center;
285
+ justify-content: center;
286
+ padding: 8px;
287
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
288
+ cursor: pointer;
289
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
290
+ }
291
+
292
+ .treemap-node:hover {
293
+ transform: scale(0.99);
294
+ filter: brightness(1.15);
295
+ z-index: 2;
296
+ }
297
+
298
+ .treemap-node-name {
299
+ font-size: 0.8rem;
300
+ font-weight: 600;
301
+ white-space: nowrap;
302
+ overflow: hidden;
303
+ text-overflow: ellipsis;
304
+ max-width: 100%;
305
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
306
+ }
307
+
308
+ .treemap-node-size {
309
+ font-size: 0.7rem;
310
+ opacity: 0.85;
311
+ margin-top: 2px;
312
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
313
+ }
314
+
315
+ /* Budget gauges */
316
+ .budget-list {
317
+ display: flex;
318
+ flex-direction: column;
319
+ gap: 1.25rem;
320
+ }
321
+
322
+ .budget-item {
323
+ display: flex;
324
+ flex-direction: column;
325
+ gap: 0.5rem;
326
+ }
327
+
328
+ .budget-meta {
329
+ display: flex;
330
+ justify-content: space-between;
331
+ font-size: 0.85rem;
332
+ }
333
+
334
+ .budget-name {
335
+ font-weight: 500;
336
+ color: var(--text-secondary);
337
+ }
338
+
339
+ .budget-sizes {
340
+ color: var(--text-muted);
341
+ }
342
+
343
+ .budget-sizes strong {
344
+ color: var(--text);
345
+ }
346
+
347
+ .gauge-bar {
348
+ height: 10px;
349
+ background: var(--surface-active);
350
+ border-radius: 5px;
351
+ overflow: hidden;
352
+ position: relative;
353
+ }
354
+
355
+ .gauge-fill {
356
+ height: 100%;
357
+ border-radius: 5px;
358
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
359
+ }
360
+
361
+ /* Filters Bar */
362
+ .filter-bar {
363
+ display: flex;
364
+ gap: 1rem;
365
+ align-items: center;
366
+ margin-bottom: 1.5rem;
367
+ flex-wrap: wrap;
368
+ background: var(--surface);
369
+ border: 1px solid var(--border);
370
+ padding: 1rem;
371
+ border-radius: 8px;
372
+ }
373
+
374
+ .search-input {
375
+ background: var(--bg);
376
+ border: 1px solid var(--border);
377
+ padding: 0.5rem 1rem;
378
+ border-radius: 6px;
379
+ color: var(--text);
380
+ font-family: inherit;
381
+ font-size: 0.9rem;
382
+ width: 260px;
383
+ transition: border-color 0.2s ease;
384
+ }
385
+
386
+ .search-input:focus {
387
+ outline: none;
388
+ border-color: var(--border-focus);
389
+ }
390
+
391
+ .filter-group {
392
+ display: flex;
393
+ gap: 0.35rem;
394
+ align-items: center;
395
+ }
396
+
397
+ .filter-label {
398
+ font-size: 0.8rem;
399
+ color: var(--text-secondary);
400
+ margin-right: 0.5rem;
401
+ font-weight: 500;
402
+ }
403
+
404
+ .filter-pill {
405
+ background: var(--surface-hover);
406
+ border: 1px solid var(--border);
407
+ padding: 0.35rem 0.75rem;
408
+ border-radius: 6px;
409
+ font-size: 0.8rem;
410
+ font-weight: 500;
411
+ cursor: pointer;
412
+ color: var(--text-secondary);
413
+ transition: all 0.15s ease;
414
+ }
415
+
416
+ .filter-pill:hover {
417
+ background: var(--surface-active);
418
+ color: var(--text);
419
+ }
420
+
421
+ .filter-pill.active {
422
+ background: var(--blue-alpha);
423
+ border-color: var(--blue);
424
+ color: var(--blue);
425
+ }
426
+
427
+ /* Table styles */
428
+ .table-wrapper {
429
+ overflow-x: auto;
430
+ border: 1px solid var(--border);
431
+ border-radius: 8px;
432
+ }
433
+
434
+ table {
435
+ width: 100%;
436
+ border-collapse: collapse;
437
+ text-align: left;
438
+ font-size: 0.9rem;
439
+ }
440
+
441
+ th {
442
+ padding: 1rem;
443
+ background: var(--surface-hover);
444
+ color: var(--text-secondary);
445
+ font-weight: 600;
446
+ border-bottom: 1px solid var(--border);
447
+ cursor: pointer;
448
+ user-select: none;
449
+ transition: color 0.15s ease;
450
+ }
451
+
452
+ th:hover {
453
+ color: var(--text);
454
+ }
455
+
456
+ td {
457
+ padding: 1rem;
458
+ border-bottom: 1px solid var(--border);
459
+ transition: background 0.15s ease;
460
+ }
461
+
462
+ tr:last-child td {
463
+ border-bottom: none;
464
+ }
465
+
466
+ tr.clickable-row {
467
+ cursor: pointer;
468
+ }
469
+
470
+ tr.clickable-row:hover td {
471
+ background: var(--surface-hover);
472
+ }
473
+
474
+ .directive-tag {
475
+ display: inline-block;
476
+ padding: 2px 8px;
71
477
  border-radius: 4px;
478
+ font-size: 0.75rem;
479
+ font-weight: 600;
480
+ font-family: 'JetBrains Mono', monospace;
481
+ }
482
+
483
+ .directive-tag.load { background: var(--red-alpha); color: var(--red); }
484
+ .directive-tag.idle { background: var(--yellow-alpha); color: var(--yellow); }
485
+ .directive-tag.visible { background: var(--green-alpha); color: var(--green); }
486
+ .directive-tag.media { background: var(--blue-alpha); color: var(--blue); }
487
+ .directive-tag.only { background: var(--purple-alpha); color: var(--purple); }
488
+ .directive-tag.script { background: var(--blue-alpha); color: var(--blue); }
489
+
490
+ .framework-badge {
491
+ display: inline-flex;
492
+ align-items: center;
493
+ font-size: 0.85rem;
494
+ font-weight: 500;
495
+ }
496
+
497
+ .framework-badge::before {
498
+ content: '';
499
+ display: inline-block;
500
+ width: 8px;
501
+ height: 8px;
502
+ border-radius: 50%;
503
+ margin-right: 8px;
504
+ }
505
+
506
+ .framework-badge.react::before { background: #61dafb; }
507
+ .framework-badge.svelte::before { background: #ff3e00; }
508
+ .framework-badge.vue::before { background: #41b883; }
509
+ .framework-badge.solid::before { background: #446b9f; }
510
+ .framework-badge.qwik::before { background: #00f3cf; }
511
+ .framework-badge.astro::before { background: #ff5a03; }
512
+ .framework-badge.unknown::before { background: var(--text-muted); }
513
+
514
+ .issues-count {
515
+ display: inline-flex;
516
+ align-items: center;
517
+ justify-content: center;
518
+ min-width: 20px;
519
+ height: 20px;
520
+ border-radius: 50%;
521
+ font-size: 0.75rem;
522
+ font-weight: 600;
523
+ padding: 0 4px;
524
+ }
525
+
526
+ .issues-count.zero { background: var(--surface-hover); color: var(--text-muted); }
527
+ .issues-count.warn { background: var(--yellow-alpha); color: var(--yellow); }
528
+ .issues-count.error { background: var(--red-alpha); color: var(--red); }
529
+
530
+ /* Issues list */
531
+ .issues-list {
72
532
  display: flex;
73
533
  flex-direction: column;
534
+ gap: 1rem;
535
+ }
536
+
537
+ .issue-card {
538
+ background: var(--surface-hover);
539
+ border: 1px solid var(--border);
540
+ border-radius: 8px;
541
+ padding: 1.25rem;
542
+ display: flex;
543
+ flex-direction: column;
544
+ gap: 0.5rem;
545
+ position: relative;
546
+ }
547
+
548
+ .issue-card.error { border-left: 4px solid var(--red); }
549
+ .issue-card.warning { border-left: 4px solid var(--yellow); }
550
+ .issue-card.info { border-left: 4px solid var(--blue); }
551
+
552
+ .issue-meta {
553
+ display: flex;
554
+ justify-content: space-between;
555
+ align-items: center;
556
+ font-size: 0.8rem;
557
+ }
558
+
559
+ .issue-component {
560
+ font-weight: 600;
561
+ color: var(--text);
562
+ cursor: pointer;
563
+ text-decoration: underline;
564
+ }
565
+
566
+ .issue-component:hover {
567
+ color: var(--blue);
568
+ }
569
+
570
+ .issue-severity {
571
+ text-transform: uppercase;
572
+ font-weight: 700;
573
+ font-size: 0.75rem;
574
+ letter-spacing: 0.05em;
575
+ }
576
+
577
+ .issue-severity.error { color: var(--red); }
578
+ .issue-severity.warning { color: var(--yellow); }
579
+ .issue-severity.info { color: var(--blue); }
580
+
581
+ .issue-message {
582
+ font-size: 0.95rem;
583
+ font-weight: 500;
584
+ }
585
+
586
+ .issue-recommendation {
587
+ background: var(--bg);
588
+ padding: 0.75rem 1rem;
589
+ border-radius: 6px;
590
+ font-size: 0.85rem;
591
+ color: var(--text-secondary);
592
+ border: 1px solid var(--border);
593
+ }
594
+
595
+ /* Detail Inspector Drawer */
596
+ .drawer-overlay {
597
+ position: fixed;
598
+ top: 0;
599
+ left: 0;
600
+ right: 0;
601
+ bottom: 0;
602
+ background: rgba(0, 0, 0, 0.6);
603
+ backdrop-filter: blur(4px);
604
+ z-index: 99;
605
+ opacity: 0;
606
+ pointer-events: none;
607
+ transition: opacity 0.3s ease;
608
+ }
609
+
610
+ .drawer-overlay.open {
611
+ opacity: 1;
612
+ pointer-events: auto;
613
+ }
614
+
615
+ .drawer {
616
+ position: fixed;
617
+ top: 0;
618
+ right: 0;
619
+ width: 580px;
620
+ height: 100vh;
621
+ background: var(--surface);
622
+ border-left: 1px solid var(--border);
623
+ z-index: 100;
624
+ padding: 2rem;
625
+ display: flex;
626
+ flex-direction: column;
627
+ gap: 1.5rem;
628
+ transform: translateX(100%);
629
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
630
+ box-shadow: -10px 0 30px rgba(0,0,0,0.5);
631
+ overflow-y: auto;
632
+ }
633
+
634
+ .drawer.open {
635
+ transform: translateX(0);
636
+ }
637
+
638
+ .drawer-header {
639
+ display: flex;
640
+ justify-content: space-between;
641
+ align-items: center;
642
+ border-bottom: 1px solid var(--border);
643
+ padding-bottom: 1.25rem;
644
+ }
645
+
646
+ .drawer-title h3 {
647
+ font-size: 1.35rem;
648
+ font-weight: 600;
649
+ letter-spacing: -0.02em;
650
+ }
651
+
652
+ .drawer-close {
653
+ background: var(--surface-hover);
654
+ border: 1px solid var(--border);
655
+ color: var(--text-secondary);
656
+ width: 32px;
657
+ height: 32px;
658
+ border-radius: 50%;
659
+ cursor: pointer;
660
+ display: flex;
74
661
  align-items: center;
75
662
  justify-content: center;
663
+ font-size: 1.25rem;
664
+ transition: all 0.15s ease;
665
+ }
666
+
667
+ .drawer-close:hover {
668
+ background: var(--surface-active);
669
+ color: var(--text);
670
+ }
671
+
672
+ .drawer-section-title {
673
+ font-size: 0.85rem;
674
+ font-weight: 600;
675
+ text-transform: uppercase;
676
+ letter-spacing: 0.05em;
677
+ color: var(--text-muted);
678
+ margin-bottom: 0.75rem;
679
+ }
680
+
681
+ .drawer-meta-grid {
682
+ display: grid;
683
+ grid-template-columns: 1fr 1fr;
684
+ gap: 1rem;
685
+ }
686
+
687
+ .drawer-meta-item {
688
+ background: var(--surface-hover);
689
+ border: 1px solid var(--border);
690
+ padding: 0.75rem 1rem;
691
+ border-radius: 8px;
692
+ }
693
+
694
+ .drawer-meta-label {
76
695
  font-size: 0.75rem;
696
+ color: var(--text-muted);
697
+ margin-bottom: 0.25rem;
698
+ }
699
+
700
+ .drawer-meta-value {
701
+ font-size: 0.9rem;
77
702
  font-weight: 500;
703
+ }
704
+
705
+ .drawer-meta-value a {
706
+ color: var(--blue);
707
+ text-decoration: none;
708
+ }
709
+
710
+ .drawer-meta-value a:hover {
711
+ text-decoration: underline;
712
+ }
713
+
714
+ /* Size breakdown cards */
715
+ .size-breakdown-grid {
716
+ display: grid;
717
+ grid-template-columns: repeat(3, 1fr);
718
+ gap: 0.75rem;
719
+ }
720
+
721
+ .size-breakdown-card {
722
+ background: var(--surface-hover);
723
+ border: 1px solid var(--border);
724
+ border-radius: 8px;
725
+ padding: 0.75rem 1rem;
726
+ text-align: center;
727
+ }
728
+
729
+ .size-value {
730
+ font-size: 1.2rem;
731
+ font-weight: 700;
732
+ color: var(--text);
733
+ margin-top: 0.25rem;
734
+ }
735
+
736
+ .size-value.exclusive { color: var(--blue); }
737
+ .size-value.shared { color: var(--purple); }
738
+
739
+ /* Chunks list */
740
+ .chunks-list {
741
+ display: flex;
742
+ flex-direction: column;
743
+ gap: 0.5rem;
744
+ }
745
+
746
+ .chunk-item {
747
+ background: var(--bg);
748
+ border: 1px solid var(--border);
749
+ border-radius: 6px;
750
+ padding: 0.75rem 1rem;
751
+ display: flex;
752
+ justify-content: space-between;
753
+ align-items: center;
754
+ font-size: 0.85rem;
755
+ }
756
+
757
+ .chunk-name {
758
+ font-family: 'JetBrains Mono', monospace;
759
+ color: var(--text-secondary);
78
760
  overflow: hidden;
79
- transition: opacity 0.2s;
80
- cursor: pointer;
761
+ text-overflow: ellipsis;
762
+ white-space: nowrap;
763
+ max-width: 70%;
764
+ }
765
+
766
+ .chunk-size {
767
+ font-weight: 600;
768
+ }
769
+
770
+ .chunk-badge {
771
+ font-size: 0.7rem;
772
+ padding: 1px 6px;
773
+ border-radius: 4px;
774
+ background: var(--surface-active);
775
+ color: var(--text-secondary);
776
+ margin-left: 8px;
777
+ }
778
+
779
+ .chunk-badge.shared {
780
+ background: var(--purple-alpha);
781
+ color: var(--purple);
782
+ }
783
+
784
+ .loading-container {
785
+ display: flex;
786
+ align-items: center;
787
+ justify-content: center;
788
+ height: 100vh;
789
+ width: 100%;
790
+ flex-direction: column;
791
+ gap: 1rem;
81
792
  }
82
- .treemap-node:hover { opacity: 0.85; }
83
- .treemap-node .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90%; }
84
- .treemap-node .size { font-size: 0.7rem; opacity: 0.8; }
85
-
86
- /* Island Table */
87
- table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
88
- th { text-align: left; padding: 0.6rem 0.75rem; color: var(--text-dim); font-weight: 500; border-bottom: 1px solid var(--border); cursor: pointer; user-select: none; }
89
- th:hover { color: var(--text); }
90
- td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); }
91
- tr:hover td { background: var(--surface-2); }
92
- .directive { padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
93
- .directive-load { background: rgba(248, 113, 113, 0.15); color: var(--red); }
94
- .directive-idle { background: rgba(250, 204, 21, 0.15); color: var(--yellow); }
95
- .directive-visible { background: rgba(74, 222, 128, 0.15); color: var(--green); }
96
- .directive-media { background: rgba(96, 165, 250, 0.15); color: var(--blue); }
97
- .directive-only { background: rgba(192, 132, 252, 0.15); color: var(--purple); }
98
-
99
- /* Issues */
100
- .issue { padding: 0.75rem 1rem; margin-bottom: 0.5rem; border-radius: 8px; font-size: 0.85rem; }
101
- .issue-error { background: rgba(248, 113, 113, 0.1); border-left: 3px solid var(--red); }
102
- .issue-warning { background: rgba(250, 204, 21, 0.1); border-left: 3px solid var(--yellow); }
103
- .issue-info { background: rgba(96, 165, 250, 0.1); border-left: 3px solid var(--blue); }
104
- .issue .rec { color: var(--text-dim); font-size: 0.8rem; margin-top: 0.25rem; }
105
-
106
- /* Budget Gauge */
107
- .gauge { position: relative; height: 24px; background: var(--surface-2); border-radius: 12px; overflow: hidden; margin-top: 0.5rem; }
108
- .gauge-fill { height: 100%; border-radius: 12px; transition: width 0.5s ease; }
109
- .gauge-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 0.75rem; font-weight: 600; }
110
-
111
- .loading { text-align: center; padding: 4rem; color: var(--text-dim); }
112
-
113
- @media (max-width: 768px) {
114
- .dashboard { grid-template-columns: 1fr; }
115
- .stats { grid-template-columns: repeat(2, 1fr); }
793
+
794
+ .spinner {
795
+ width: 40px;
796
+ height: 40px;
797
+ border: 4px solid var(--border);
798
+ border-top-color: var(--blue);
799
+ border-radius: 50%;
800
+ animation: spin 1s infinite linear;
801
+ }
802
+
803
+ @keyframes spin {
804
+ to { transform: rotate(360deg); }
805
+ }
806
+
807
+ /* Views */
808
+ .view {
809
+ display: none;
810
+ }
811
+
812
+ .view.active {
813
+ display: block;
116
814
  }
117
815
  </style>
118
816
  </head>
119
817
  <body>
120
- <div id="app">
121
- <div class="loading">Loading report...</div>
818
+
819
+ <div id="app-root">
820
+ <div class="loading-container">
821
+ <div class="spinner"></div>
822
+ <p style="color:var(--text-secondary); font-weight:500;">Loading Hydration Audit Report...</p>
823
+ </div>
122
824
  </div>
123
825
 
124
826
  <script type="module">
125
827
  let report = null;
828
+ let activeTab = 'overview';
829
+
830
+ // Filtering and search state
831
+ let searchFilter = '';
832
+ let frameworkFilter = 'all';
833
+ let directiveFilter = 'all';
834
+ let severityFilter = 'all';
835
+
836
+ // Sorting state
126
837
  let sortColumn = 'gzip';
127
838
  let sortAsc = false;
128
839
 
129
- // ─── Data Loading ──────────────────────────────────────────
130
- async function loadReport() {
840
+ // Detailed inspector state
841
+ let selectedIsland = null;
842
+
843
+ // Load initial report
844
+ async function init() {
131
845
  try {
132
846
  const res = await fetch('/api/report');
133
847
  report = await res.json();
134
- render();
848
+ renderLayout();
849
+ connectWS();
135
850
  } catch (e) {
136
- document.getElementById('app').innerHTML = '<div class="loading">Failed to load report. Make sure the report file exists.</div>';
851
+ document.getElementById('app-root').innerHTML = `
852
+ <div class="loading-container">
853
+ <h3 style="color:var(--red)">Failed to load report</h3>
854
+ <p style="color:var(--text-secondary); margin-top:0.5rem">Make sure the report JSON file exists and the dashboard server is active.</p>
855
+ </div>
856
+ `;
137
857
  }
138
858
  }
139
859
 
140
- // WebSocket live reload
860
+ // Live reload WebSocket client
141
861
  function connectWS() {
142
862
  try {
143
863
  const ws = new WebSocket(`ws://${location.host}`);
@@ -145,211 +865,421 @@
145
865
  const data = JSON.parse(event.data);
146
866
  if (data.type === 'update') {
147
867
  report = data.report;
148
- render();
868
+ updateMetricsAndViews();
149
869
  }
150
870
  };
151
871
  ws.onclose = () => setTimeout(connectWS, 3000);
152
872
  } catch {}
153
873
  }
154
874
 
155
- // ─── Rendering ─────────────────────────────────────────────
156
- function render() {
157
- if (!report || report.error) {
158
- document.getElementById('app').innerHTML = '<div class="loading">No report data available. Run <code>hydration-audit analyze</code> first.</div>';
159
- return;
160
- }
875
+ // Calculate issues counts
876
+ function getAllIssues() {
877
+ if (!report) return [];
878
+ return [
879
+ ...(report.islands || []).flatMap(i => i.issues.map(iss => ({ ...iss, source: 'island', component: i.component.name }))),
880
+ ...(report.pages || []).flatMap(p => p.issues.map(iss => ({ ...iss, source: 'page', route: p.route }))),
881
+ ...(report.issues || []).map(iss => ({ ...iss, source: 'global' }))
882
+ ];
883
+ }
884
+
885
+ function getIssuesCount(severity) {
886
+ return getAllIssues().filter(i => i.severity === severity).length;
887
+ }
161
888
 
162
- const app = document.getElementById('app');
163
- app.innerHTML = `
164
- ${renderHeader()}
165
- ${renderStats()}
166
- <div class="dashboard">
167
- <div class="card">
168
- <h2>Bundle Size Treemap</h2>
169
- <div class="treemap-container" id="treemap"></div>
889
+ // Render outer workspace structural shell
890
+ function renderLayout() {
891
+ const container = document.createElement('div');
892
+ container.className = 'container';
893
+
894
+ const totalIssues = getAllIssues().length;
895
+
896
+ container.innerHTML = `
897
+ <div class="sidebar">
898
+ <div class="brand">
899
+ <h1>Hydration Tax</h1>
900
+ <span class="framework-tag">${report.framework}</span>
170
901
  </div>
171
- <div class="card">
172
- <h2>Budget Usage</h2>
173
- ${renderBudgetGauges()}
902
+
903
+ <div class="project-meta">
904
+ <div>
905
+ <span>Project</span>
906
+ <strong>${report.projectName}</strong>
907
+ </div>
908
+ <div>
909
+ <span>Generated</span>
910
+ <strong>${new Date(report.timestamp).toLocaleTimeString()}</strong>
911
+ </div>
174
912
  </div>
175
- <div class="card full">
176
- <h2>Islands</h2>
177
- ${renderIslandTable()}
913
+
914
+ <div class="nav">
915
+ <button class="nav-item ${activeTab === 'overview' ? 'active' : ''}" data-tab="overview">
916
+ <span>Overview</span>
917
+ </button>
918
+ <button class="nav-item ${activeTab === 'islands' ? 'active' : ''}" data-tab="islands">
919
+ <span>Islands</span>
920
+ <span class="nav-badge" id="badge-islands-count">${report.islands.length}</span>
921
+ </button>
922
+ <button class="nav-item ${activeTab === 'issues' ? 'active' : ''}" data-tab="issues">
923
+ <span>Issues</span>
924
+ <span class="nav-badge" id="badge-issues-count" style="${totalIssues > 0 ? 'background:var(--yellow-alpha);color:var(--yellow);' : ''}">${totalIssues}</span>
925
+ </button>
178
926
  </div>
179
- <div class="card full">
180
- <h2>Issues (${getAllIssues().length})</h2>
181
- ${renderIssues()}
927
+
928
+ <div class="status">
929
+ <span class="status-dot"></span>
930
+ <span>Live reload active</span>
182
931
  </div>
183
932
  </div>
933
+
934
+ <div class="main-content">
935
+ <!-- Overview View -->
936
+ <div class="view ${activeTab === 'overview' ? 'active' : ''}" id="view-overview">
937
+ <div class="header">
938
+ <div class="header-title">
939
+ <h2>Performance Overview</h2>
940
+ <p>Overall hydration sizes and performance health check metrics.</p>
941
+ </div>
942
+ </div>
943
+ <div class="stats-grid" id="overview-stats"></div>
944
+ <div class="overview-grid">
945
+ <div class="card">
946
+ <h3>Bundle Size Treemap</h3>
947
+ <div class="treemap-wrapper" id="treemap-container"></div>
948
+ </div>
949
+ <div class="card">
950
+ <h3>Budget Usage Status</h3>
951
+ <div class="budget-list" id="budget-gauges"></div>
952
+ </div>
953
+ </div>
954
+ </div>
955
+
956
+ <!-- Islands Table View -->
957
+ <div class="view ${activeTab === 'islands' ? 'active' : ''}" id="view-islands">
958
+ <div class="header">
959
+ <div class="header-title">
960
+ <h2>Hydrated Islands</h2>
961
+ <p>Explore, search, and sort components loaded for client-side hydration.</p>
962
+ </div>
963
+ </div>
964
+
965
+ <div class="filter-bar">
966
+ <input type="text" class="search-input" id="search-input" placeholder="Search components..." value="${searchFilter}">
967
+
968
+ <div class="filter-group">
969
+ <span class="filter-label">Framework:</span>
970
+ <button class="filter-pill ${frameworkFilter === 'all' ? 'active' : ''}" data-framework="all">All</button>
971
+ <button class="filter-pill ${frameworkFilter === 'react' ? 'active' : ''}" data-framework="react">React</button>
972
+ <button class="filter-pill ${frameworkFilter === 'svelte' ? 'active' : ''}" data-framework="svelte">Svelte</button>
973
+ <button class="filter-pill ${frameworkFilter === 'vue' ? 'active' : ''}" data-framework="vue">Vue</button>
974
+ </div>
975
+
976
+ <div class="filter-group">
977
+ <span class="filter-label">Directive:</span>
978
+ <button class="filter-pill ${directiveFilter === 'all' ? 'active' : ''}" data-directive="all">All</button>
979
+ <button class="filter-pill ${directiveFilter === 'client:load' ? 'active' : ''}" data-directive="client:load">load</button>
980
+ <button class="filter-pill ${directiveFilter === 'client:visible' ? 'active' : ''}" data-directive="client:visible">visible</button>
981
+ <button class="filter-pill ${directiveFilter === 'client:idle' ? 'active' : ''}" data-directive="client:idle">idle</button>
982
+ </div>
983
+ </div>
984
+
985
+ <div class="table-wrapper">
986
+ <table id="islands-table">
987
+ <thead>
988
+ <tr>
989
+ <th data-col="name">Component Name</th>
990
+ <th data-col="directive">Directive</th>
991
+ <th data-col="framework">Framework</th>
992
+ <th data-col="gzip">Gzip Cost</th>
993
+ <th data-col="brotli">Brotli Cost</th>
994
+ <th data-col="issues">Issues</th>
995
+ </tr>
996
+ </thead>
997
+ <tbody id="islands-table-body"></tbody>
998
+ </table>
999
+ </div>
1000
+ </div>
1001
+
1002
+ <!-- Issues Warnings View -->
1003
+ <div class="view ${activeTab === 'issues' ? 'active' : ''}" id="view-issues">
1004
+ <div class="header">
1005
+ <div class="header-title">
1006
+ <h2>Hydration Diagnostics</h2>
1007
+ <p>Issues, warnings, and optimization recommendations flagged for your code.</p>
1008
+ </div>
1009
+ </div>
1010
+
1011
+ <div class="filter-bar">
1012
+ <div class="filter-group">
1013
+ <span class="filter-label">Severity:</span>
1014
+ <button class="filter-pill ${severityFilter === 'all' ? 'active' : ''}" data-severity="all">All</button>
1015
+ <button class="filter-pill ${severityFilter === 'error' ? 'active' : ''}" data-severity="error">Errors (${getIssuesCount('error')})</button>
1016
+ <button class="filter-pill ${severityFilter === 'warning' ? 'active' : ''}" data-severity="warning">Warnings (${getIssuesCount('warning')})</button>
1017
+ <button class="filter-pill ${severityFilter === 'info' ? 'active' : ''}" data-severity="info">Info (${getIssuesCount('info')})</button>
1018
+ </div>
1019
+ </div>
1020
+
1021
+ <div class="issues-list" id="issues-list-container"></div>
1022
+ </div>
1023
+ </div>
1024
+
1025
+ <!-- Detail Inspector Slide-out Drawer -->
1026
+ <div class="drawer-overlay" id="drawer-overlay"></div>
1027
+ <div class="drawer" id="detail-drawer">
1028
+ <div class="drawer-header">
1029
+ <div class="drawer-title" id="drawer-title-container">
1030
+ <h3>Island Details</h3>
1031
+ </div>
1032
+ <button class="drawer-close" id="drawer-close-btn">&times;</button>
1033
+ </div>
1034
+ <div id="drawer-content-container"></div>
1035
+ </div>
184
1036
  `;
185
1037
 
186
- renderTreemap();
187
- attachSortHandlers();
1038
+ document.getElementById('app-root').replaceChildren(container);
1039
+
1040
+ // Wire sidebar tabs switching
1041
+ document.querySelectorAll('.sidebar .nav-item').forEach(btn => {
1042
+ btn.addEventListener('click', () => {
1043
+ activeTab = btn.dataset.tab;
1044
+ document.querySelectorAll('.sidebar .nav-item').forEach(b => b.classList.remove('active'));
1045
+ btn.classList.add('active');
1046
+
1047
+ document.querySelectorAll('.main-content .view').forEach(view => view.classList.remove('active'));
1048
+ document.getElementById(`view-${activeTab}`).classList.add('active');
1049
+
1050
+ // Re-trigger layout sizing on tab changes (specifically for Treemap resizing)
1051
+ if (activeTab === 'overview') {
1052
+ setTimeout(renderTreemap, 50);
1053
+ }
1054
+ });
1055
+ });
1056
+
1057
+ // Wire search and table filter events
1058
+ document.getElementById('search-input').addEventListener('input', (e) => {
1059
+ searchFilter = e.target.value;
1060
+ renderIslandsTable();
1061
+ });
1062
+
1063
+ document.querySelectorAll('[data-framework]').forEach(btn => {
1064
+ btn.addEventListener('click', () => {
1065
+ frameworkFilter = btn.dataset.framework;
1066
+ document.querySelectorAll('[data-framework]').forEach(b => b.classList.remove('active'));
1067
+ btn.classList.add('active');
1068
+ renderIslandsTable();
1069
+ });
1070
+ });
1071
+
1072
+ document.querySelectorAll('[data-directive]').forEach(btn => {
1073
+ btn.addEventListener('click', () => {
1074
+ directiveFilter = btn.dataset.directive;
1075
+ document.querySelectorAll('[data-directive]').forEach(b => b.classList.remove('active'));
1076
+ btn.classList.add('active');
1077
+ renderIslandsTable();
1078
+ });
1079
+ });
1080
+
1081
+ document.querySelectorAll('[data-severity]').forEach(btn => {
1082
+ btn.addEventListener('click', () => {
1083
+ severityFilter = btn.dataset.severity;
1084
+ document.querySelectorAll('[data-severity]').forEach(b => b.classList.remove('active'));
1085
+ btn.classList.add('active');
1086
+ renderIssuesList();
1087
+ });
1088
+ });
1089
+
1090
+ // Sort handlers
1091
+ document.querySelectorAll('#islands-table th').forEach(th => {
1092
+ th.addEventListener('click', () => {
1093
+ const col = th.dataset.col;
1094
+ if (sortColumn === col) {
1095
+ sortAsc = !sortAsc;
1096
+ } else {
1097
+ sortColumn = col;
1098
+ sortAsc = false;
1099
+ }
1100
+ renderIslandsTable();
1101
+ });
1102
+ });
1103
+
1104
+ // Close drawer handler
1105
+ document.getElementById('drawer-close-btn').addEventListener('click', closeInspector);
1106
+ document.getElementById('drawer-overlay').addEventListener('click', closeInspector);
1107
+
1108
+ // Populate views initially
1109
+ updateMetricsAndViews();
1110
+
1111
+ // Listen to resize events for treemap squarify calculations
1112
+ window.addEventListener('resize', () => {
1113
+ if (activeTab === 'overview') renderTreemap();
1114
+ });
188
1115
  }
189
1116
 
190
- function renderHeader() {
191
- return `<div class="header">
192
- <div>
193
- <h1>Hydration Tax Dashboard</h1>
194
- <div class="meta">${report.projectName} &middot; ${report.framework} &middot; ${new Date(report.timestamp).toLocaleString()}</div>
195
- </div>
196
- <div class="meta"><span class="live-dot"></span>Live</div>
197
- </div>`;
1117
+ // Sync metrics and populate views on initial load or WebSocket update
1118
+ function updateMetricsAndViews() {
1119
+ if (!report) return;
1120
+
1121
+ // Update sidebar tab counts
1122
+ document.getElementById('badge-islands-count').textContent = report.islands.length;
1123
+
1124
+ const totalIssues = getAllIssues().length;
1125
+ const issuesBadge = document.getElementById('badge-issues-count');
1126
+ issuesBadge.textContent = totalIssues;
1127
+ if (totalIssues > 0) {
1128
+ issuesBadge.style.background = 'var(--yellow-alpha)';
1129
+ issuesBadge.style.color = 'var(--yellow)';
1130
+ } else {
1131
+ issuesBadge.style.background = 'var(--surface-hover)';
1132
+ issuesBadge.style.color = 'var(--text-secondary)';
1133
+ }
1134
+
1135
+ // Re-populate sections
1136
+ renderOverviewStats();
1137
+ renderTreemap();
1138
+ renderBudgetGauges();
1139
+ renderIslandsTable();
1140
+ renderIssuesList();
1141
+
1142
+ // Update drawer if opened
1143
+ if (selectedIsland) {
1144
+ const freshData = report.islands.find(i => i.component.name === selectedIsland.component.name);
1145
+ if (freshData) {
1146
+ showInspector(freshData);
1147
+ }
1148
+ }
198
1149
  }
199
1150
 
200
- function renderStats() {
1151
+ // Populate the Overview Metrics cards
1152
+ function renderOverviewStats() {
1153
+ const statsGrid = document.getElementById('overview-stats');
201
1154
  const t = report.totals;
1155
+
202
1156
  const sizeClass = t.totalGzipSize > 300000 ? 'red' : t.totalGzipSize > 150000 ? 'yellow' : 'green';
203
1157
  const issueClass = t.issuesBySeverity.error > 0 ? 'red' : t.issuesBySeverity.warning > 0 ? 'yellow' : 'green';
204
1158
 
205
- return `<div class="stats">
1159
+ statsGrid.innerHTML = `
206
1160
  <div class="stat-card">
207
- <div class="label">Total Islands</div>
208
- <div class="value">${t.totalIslands}</div>
1161
+ <div class="stat-label">Total Islands</div>
1162
+ <div class="stat-value">${t.totalIslands}</div>
209
1163
  </div>
210
1164
  <div class="stat-card">
211
- <div class="label">Total JS (gzip)</div>
212
- <div class="value ${sizeClass}">${formatBytes(t.totalGzipSize)}</div>
1165
+ <div class="stat-label">Total JS (gzip)</div>
1166
+ <div class="stat-value ${sizeClass}">${formatBytes(t.totalGzipSize)}</div>
213
1167
  </div>
214
1168
  <div class="stat-card">
215
- <div class="label">Total JS (brotli)</div>
216
- <div class="value">${formatBytes(t.totalBrotliSize)}</div>
1169
+ <div class="stat-label">Total JS (brotli)</div>
1170
+ <div class="stat-value">${formatBytes(t.totalBrotliSize)}</div>
217
1171
  </div>
218
1172
  <div class="stat-card">
219
- <div class="label">Issues</div>
220
- <div class="value ${issueClass}">${t.totalIssues}</div>
1173
+ <div class="stat-label">Total Issues</div>
1174
+ <div class="stat-value ${issueClass}">${t.totalIssues}</div>
221
1175
  </div>
222
- </div>`;
1176
+ `;
223
1177
  }
224
1178
 
1179
+ // Populate Overview Budget Gauges
225
1180
  function renderBudgetGauges() {
1181
+ const budgetGauges = document.getElementById('budget-gauges');
226
1182
  const config = report.config;
227
- if (!config) return '<p style="color:var(--text-dim)">No budget config</p>';
1183
+ if (!config) {
1184
+ budgetGauges.innerHTML = `<p style="color:var(--text-muted)">No configuration thresholds defined.</p>`;
1185
+ return;
1186
+ }
228
1187
 
229
1188
  const gauges = [];
230
1189
 
231
1190
  // Per-page budgets
232
1191
  for (const page of report.pages) {
233
- const pct = Math.min((page.totalGzipSize / config.thresholds.pageBudget) * 100, 100);
1192
+ const limit = config.thresholds.pageBudget;
1193
+ const pct = Math.min((page.totalGzipSize / limit) * 100, 100);
234
1194
  const color = pct > 100 ? 'var(--red)' : pct > 66 ? 'var(--yellow)' : 'var(--green)';
1195
+
235
1196
  gauges.push(`
236
- <div style="margin-bottom:1rem">
237
- <div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:4px">
238
- <span>${page.route}</span>
239
- <span>${formatBytes(page.totalGzipSize)} / ${formatBytes(config.thresholds.pageBudget)}</span>
1197
+ <div class="budget-item">
1198
+ <div class="budget-meta">
1199
+ <span class="budget-name">${page.route}</span>
1200
+ <span class="budget-sizes"><strong>${formatBytes(page.totalGzipSize)}</strong> / ${formatBytes(limit)}</span>
240
1201
  </div>
241
- <div class="gauge">
242
- <div class="gauge-fill" style="width:${Math.min(pct, 100)}%;background:${color}"></div>
1202
+ <div class="gauge-bar">
1203
+ <div class="gauge-fill" style="width: ${pct}%; background: ${color};"></div>
243
1204
  </div>
244
1205
  </div>
245
1206
  `);
246
1207
  }
247
1208
 
248
1209
  // Total budget
249
- const totalPct = Math.min((report.totals.totalGzipSize / config.thresholds.totalBudget) * 100, 100);
1210
+ const totalLimit = config.thresholds.totalBudget;
1211
+ const totalPct = Math.min((report.totals.totalGzipSize / totalLimit) * 100, 100);
250
1212
  const totalColor = totalPct > 100 ? 'var(--red)' : totalPct > 66 ? 'var(--yellow)' : 'var(--green)';
1213
+
251
1214
  gauges.push(`
252
- <div style="margin-top:1.5rem">
253
- <div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:4px">
254
- <strong>Total Site Budget</strong>
255
- <span>${formatBytes(report.totals.totalGzipSize)} / ${formatBytes(config.thresholds.totalBudget)}</span>
1215
+ <div class="budget-item" style="border-top:1px solid var(--border); padding-top:1.25rem; margin-top:0.75rem;">
1216
+ <div class="budget-meta">
1217
+ <span class="budget-name" style="font-weight:600; color:var(--text)">Total Site Budget</span>
1218
+ <span class="budget-sizes"><strong>${formatBytes(report.totals.totalGzipSize)}</strong> / ${formatBytes(totalLimit)}</span>
256
1219
  </div>
257
- <div class="gauge">
258
- <div class="gauge-fill" style="width:${Math.min(totalPct, 100)}%;background:${totalColor}"></div>
1220
+ <div class="gauge-bar" style="height:12px;">
1221
+ <div class="gauge-fill" style="width: ${totalPct}%; background: ${totalColor};"></div>
259
1222
  </div>
260
1223
  </div>
261
1224
  `);
262
1225
 
263
- return gauges.join('');
1226
+ budgetGauges.innerHTML = gauges.join('');
264
1227
  }
265
1228
 
266
- function renderIslandTable() {
267
- const islands = getSortedIslands();
268
- if (islands.length === 0) return '<p style="color:var(--text-dim)">No islands found</p>';
269
-
270
- const arrow = (col) => sortColumn === col ? (sortAsc ? ' ↑' : ' ↓') : '';
271
-
272
- return `<table>
273
- <thead>
274
- <tr>
275
- <th data-sort="name">Name${arrow('name')}</th>
276
- <th data-sort="directive">Directive${arrow('directive')}</th>
277
- <th data-sort="framework">Framework${arrow('framework')}</th>
278
- <th data-sort="gzip">Gzip${arrow('gzip')}</th>
279
- <th data-sort="brotli">Brotli${arrow('brotli')}</th>
280
- <th data-sort="issues">Issues${arrow('issues')}</th>
281
- <th>Pages</th>
282
- </tr>
283
- </thead>
284
- <tbody>
285
- ${islands.map(i => `<tr>
286
- <td><strong>${i.component.name}</strong></td>
287
- <td><span class="directive directive-${i.component.directive.split(':')[1]}">${i.component.directive}</span></td>
288
- <td>${i.component.uiFramework}</td>
289
- <td>${formatBytes(i.bundle.totalGzipSize)}</td>
290
- <td style="color:var(--text-dim)">${formatBytes(i.bundle.totalBrotliSize)}</td>
291
- <td>${i.issues.length > 0 ? `<span style="color:var(--yellow)">${i.issues.length}</span>` : '<span style="color:var(--green)">0</span>'}</td>
292
- <td style="color:var(--text-dim);font-size:0.8rem">${i.component.pages.join(', ')}</td>
293
- </tr>`).join('')}
294
- </tbody>
295
- </table>`;
296
- }
297
-
298
- function renderIssues() {
299
- const issues = getAllIssues();
300
- if (issues.length === 0) return '<p style="color:var(--green)">No issues found!</p>';
301
-
302
- return issues.map(issue => `
303
- <div class="issue issue-${issue.severity}">
304
- <div>${issue.message}</div>
305
- <div class="rec">${issue.recommendation}</div>
306
- </div>
307
- `).join('');
308
- }
309
-
310
- // ─── Treemap ───────────────────────────────────────────────
1229
+ // Populate Overview Treemap
311
1230
  function renderTreemap() {
312
- const container = document.getElementById('treemap');
313
- if (!container || !report.islands.length) return;
1231
+ const container = document.getElementById('treemap-container');
1232
+ if (!container || !report || !report.islands.length) return;
314
1233
 
315
1234
  const width = container.offsetWidth;
316
1235
  const height = container.offsetHeight;
317
1236
 
318
- // Build hierarchy data
319
- const data = {
320
- name: 'root',
321
- children: report.islands.map(i => ({
322
- name: i.component.name,
323
- value: Math.max(i.bundle.totalGzipSize, 100), // min size for visibility
324
- gzip: i.bundle.totalGzipSize,
325
- directive: i.component.directive,
326
- issues: i.issues.length,
327
- })),
328
- };
329
-
330
- // Simple treemap layout (no d3 dependency — inline squarify)
331
- const nodes = squarify(data.children, 0, 0, width, height);
1237
+ // Map components to value metrics
1238
+ const items = report.islands.map(i => ({
1239
+ name: i.component.name,
1240
+ value: Math.max(i.bundle.totalGzipSize, 100), // Minimum weight for rendering small elements
1241
+ gzip: i.bundle.totalGzipSize,
1242
+ issues: i.issues.length,
1243
+ rawRecord: i
1244
+ }));
1245
+
1246
+ // Squarified treemap calculation
1247
+ const nodes = squarify(items, 0, 0, width, height);
332
1248
 
333
1249
  container.innerHTML = nodes.map(n => {
334
1250
  const color = getNodeColor(n);
335
- const textColor = '#fff';
336
- return `<div class="treemap-node" style="left:${n.x}px;top:${n.y}px;width:${n.w}px;height:${n.h}px;background:${color}" title="${n.name}: ${formatBytes(n.gzip)}">
337
- ${n.w > 60 && n.h > 30 ? `<div class="name">${n.name}</div>` : ''}
338
- ${n.w > 50 && n.h > 45 ? `<div class="size">${formatBytes(n.gzip)}</div>` : ''}
339
- </div>`;
1251
+ return `
1252
+ <div class="treemap-node"
1253
+ style="left:${n.x}px; top:${n.y}px; width:${n.w}px; height:${n.h}px; background:${color}"
1254
+ title="${n.name}: ${formatBytes(n.gzip)} (${n.issues} issue${n.issues !== 1 ? 's' : ''})"
1255
+ data-treemap-item="${n.name}">
1256
+ ${n.w > 65 && n.h > 35 ? `<div class="treemap-node-name">${n.name}</div>` : ''}
1257
+ ${n.w > 55 && n.h > 50 ? `<div class="treemap-node-size">${formatBytes(n.gzip)}</div>` : ''}
1258
+ </div>
1259
+ `;
340
1260
  }).join('');
1261
+
1262
+ // Click handler on nodes
1263
+ container.querySelectorAll('.treemap-node').forEach(node => {
1264
+ node.addEventListener('click', () => {
1265
+ const componentName = node.dataset.treemapItem;
1266
+ const island = report.islands.find(i => i.component.name === componentName);
1267
+ if (island) showInspector(island);
1268
+ });
1269
+ });
341
1270
  }
342
1271
 
1272
+ // Treemap node color categorization based on issues and size weight
343
1273
  function getNodeColor(node) {
344
1274
  if (node.issues > 0) {
345
- return node.gzip > 100000 ? 'rgba(248,113,113,0.6)' : 'rgba(250,204,21,0.5)';
1275
+ return node.gzip > 80000 ? 'rgba(239, 68, 68, 0.7)' : 'rgba(245, 158, 11, 0.6)';
346
1276
  }
347
- if (node.gzip > 50000) return 'rgba(250,204,21,0.4)';
348
- if (node.gzip > 20000) return 'rgba(96,165,250,0.4)';
349
- return 'rgba(74,222,128,0.4)';
1277
+ if (node.gzip > 50000) return 'rgba(245, 158, 11, 0.45)';
1278
+ if (node.gzip > 15000) return 'rgba(59, 130, 246, 0.45)';
1279
+ return 'rgba(16, 185, 129, 0.4)';
350
1280
  }
351
1281
 
352
- // Simple squarified treemap layout
1282
+ // Squarified treemap layout algorithm
353
1283
  function squarify(items, x, y, w, h) {
354
1284
  const total = items.reduce((s, i) => s + i.value, 0);
355
1285
  if (total === 0 || items.length === 0) return [];
@@ -381,10 +1311,28 @@
381
1311
  return result;
382
1312
  }
383
1313
 
384
- // ─── Sorting ───────────────────────────────────────────────
385
- function getSortedIslands() {
386
- const islands = [...(report.islands || [])];
387
- islands.sort((a, b) => {
1314
+ // Sort and filter Islands grid/table
1315
+ function getSortedAndFilteredIslands() {
1316
+ let list = [...(report.islands || [])];
1317
+
1318
+ // Search query filter
1319
+ if (searchFilter.trim() !== '') {
1320
+ const search = searchFilter.toLowerCase();
1321
+ list = list.filter(i => i.component.name.toLowerCase().includes(search));
1322
+ }
1323
+
1324
+ // Framework filter
1325
+ if (frameworkFilter !== 'all') {
1326
+ list = list.filter(i => i.component.uiFramework.toLowerCase() === frameworkFilter);
1327
+ }
1328
+
1329
+ // Directive filter
1330
+ if (directiveFilter !== 'all') {
1331
+ list = list.filter(i => i.component.directive === directiveFilter);
1332
+ }
1333
+
1334
+ // Sorting
1335
+ list.sort((a, b) => {
388
1336
  let va, vb;
389
1337
  switch (sortColumn) {
390
1338
  case 'name': va = a.component.name; vb = b.component.name; break;
@@ -395,33 +1343,251 @@
395
1343
  case 'issues': va = a.issues.length; vb = b.issues.length; break;
396
1344
  default: va = a.bundle.totalGzipSize; vb = b.bundle.totalGzipSize;
397
1345
  }
398
- if (typeof va === 'string') return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
1346
+
1347
+ if (typeof va === 'string') {
1348
+ return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
1349
+ }
399
1350
  return sortAsc ? va - vb : vb - va;
400
1351
  });
401
- return islands;
1352
+
1353
+ return list;
402
1354
  }
403
1355
 
404
- function attachSortHandlers() {
405
- document.querySelectorAll('th[data-sort]').forEach(th => {
406
- th.addEventListener('click', () => {
407
- const col = th.dataset.sort;
408
- if (sortColumn === col) { sortAsc = !sortAsc; }
409
- else { sortColumn = col; sortAsc = false; }
410
- render();
1356
+ // Render sorted/filtered table body rows
1357
+ function renderIslandsTable() {
1358
+ const tbody = document.getElementById('islands-table-body');
1359
+ const list = getSortedAndFilteredIslands();
1360
+
1361
+ if (list.length === 0) {
1362
+ tbody.innerHTML = `
1363
+ <tr>
1364
+ <td colspan="6" style="text-align:center; color:var(--text-muted); padding:3rem 0;">
1365
+ No matching hydrated islands found.
1366
+ </td>
1367
+ </tr>
1368
+ `;
1369
+ return;
1370
+ }
1371
+
1372
+ tbody.innerHTML = list.map(i => {
1373
+ const directiveName = i.component.directive.split(':')[1] || 'load';
1374
+ const issuesClass = i.issues.length > 0 ? (i.issues.some(iss => iss.severity === 'error') ? 'error' : 'warn') : 'zero';
1375
+
1376
+ return `
1377
+ <tr class="clickable-row" data-component-row="${i.component.name}">
1378
+ <td><strong style="color:var(--text);">${i.component.name}</strong></td>
1379
+ <td><span class="directive-tag ${directiveName}">${i.component.directive}</span></td>
1380
+ <td><span class="framework-badge ${i.component.uiFramework}">${i.component.uiFramework}</span></td>
1381
+ <td style="font-weight:500;">${formatBytes(i.bundle.totalGzipSize)}</td>
1382
+ <td style="color:var(--text-secondary);">${formatBytes(i.bundle.totalBrotliSize)}</td>
1383
+ <td><span class="issues-count ${issuesClass}">${i.issues.length}</span></td>
1384
+ </tr>
1385
+ `;
1386
+ }).join('');
1387
+
1388
+ // Wire row clicks to Inspector Drawer
1389
+ tbody.querySelectorAll('.clickable-row').forEach(row => {
1390
+ row.addEventListener('click', () => {
1391
+ const componentName = row.dataset.componentRow;
1392
+ const island = report.islands.find(i => i.component.name === componentName);
1393
+ if (island) showInspector(island);
411
1394
  });
412
1395
  });
413
1396
  }
414
1397
 
415
- // ─── Helpers ───────────────────────────────────────────────
416
- function getAllIssues() {
417
- if (!report) return [];
418
- return [
419
- ...(report.islands || []).flatMap(i => i.issues),
420
- ...(report.pages || []).flatMap(p => p.issues),
421
- ...(report.issues || []),
422
- ];
1398
+ // Render Diagnostics/Issues list
1399
+ function renderIssuesList() {
1400
+ const container = document.getElementById('issues-list-container');
1401
+ let issues = getAllIssues();
1402
+
1403
+ // Severity filters
1404
+ if (severityFilter !== 'all') {
1405
+ issues = issues.filter(i => i.severity === severityFilter);
1406
+ }
1407
+
1408
+ if (issues.length === 0) {
1409
+ container.innerHTML = `
1410
+ <div style="text-align:center; color:var(--text-muted); padding:4rem 0;">
1411
+ No hydration diagnostics or warnings flagged.
1412
+ </div>
1413
+ `;
1414
+ return;
1415
+ }
1416
+
1417
+ container.innerHTML = issues.map(iss => {
1418
+ const contextMeta = iss.source === 'island'
1419
+ ? `Island &middot; <span class="issue-component" data-inspect-comp="${iss.component}">${iss.component}</span>`
1420
+ : iss.source === 'page'
1421
+ ? `Page &middot; <strong>${iss.route}</strong>`
1422
+ : `Global Metric`;
1423
+
1424
+ return `
1425
+ <div class="issue-card ${iss.severity}">
1426
+ <div class="issue-meta">
1427
+ <span>${contextMeta}</span>
1428
+ <span class="issue-severity ${iss.severity}">${iss.severity}</span>
1429
+ </div>
1430
+ <div class="issue-message">${iss.message}</div>
1431
+ ${iss.recommendation ? `<div class="issue-recommendation">${iss.recommendation}</div>` : ''}
1432
+ </div>
1433
+ `;
1434
+ }).join('');
1435
+
1436
+ // Click handlers on issue component links to trigger Inspector Drawer
1437
+ container.querySelectorAll('.issue-component').forEach(link => {
1438
+ link.addEventListener('click', () => {
1439
+ const comp = link.dataset.inspectComp;
1440
+ const island = report.islands.find(i => i.component.name === comp);
1441
+ if (island) showInspector(island);
1442
+ });
1443
+ });
1444
+ }
1445
+
1446
+ // Inspector Slide-out Drawer Actions
1447
+ function showInspector(island) {
1448
+ selectedIsland = island;
1449
+ const overlay = document.getElementById('drawer-overlay');
1450
+ const drawer = document.getElementById('detail-drawer');
1451
+ const titleContainer = document.getElementById('drawer-title-container');
1452
+ const contentContainer = document.getElementById('drawer-content-container');
1453
+
1454
+ // Update header
1455
+ titleContainer.innerHTML = `
1456
+ <h3 style="font-size:1.3rem;">${island.component.name}</h3>
1457
+ <span class="framework-tag" style="margin-top:0.35rem;">${island.component.uiFramework}</span>
1458
+ `;
1459
+
1460
+ // Build issues breakdown HTML
1461
+ let issuesHtml = '';
1462
+ if (island.issues.length > 0) {
1463
+ issuesHtml = `
1464
+ <div style="margin-bottom:1.5rem;">
1465
+ <div class="drawer-section-title">Diagnostics (${island.issues.length})</div>
1466
+ <div style="display:flex; flex-direction:column; gap:0.75rem;">
1467
+ ${island.issues.map(iss => `
1468
+ <div class="issue-card ${iss.severity}" style="padding:1rem; font-size:0.85rem;">
1469
+ <div class="issue-meta">
1470
+ <span class="issue-severity ${iss.severity}">${iss.severity}</span>
1471
+ </div>
1472
+ <div class="issue-message" style="margin:2px 0 6px 0;">${iss.message}</div>
1473
+ ${iss.recommendation ? `<div class="issue-recommendation">${iss.recommendation}</div>` : ''}
1474
+ </div>
1475
+ `).join('')}
1476
+ </div>
1477
+ </div>
1478
+ `;
1479
+ }
1480
+
1481
+ // Build chunks lists
1482
+ const exclusiveChunks = island.bundle.chunks.filter(c => !c.isShared);
1483
+ const sharedChunks = island.bundle.sharedChunks;
1484
+
1485
+ let chunksHtml = '';
1486
+ if (exclusiveChunks.length > 0 || sharedChunks.length > 0) {
1487
+ chunksHtml = `
1488
+ <div style="margin-bottom:1.5rem;">
1489
+ <div class="drawer-section-title">JS Bundle Chunks</div>
1490
+ <div class="chunks-list">
1491
+ ${exclusiveChunks.map(c => `
1492
+ <div class="chunk-item">
1493
+ <span class="chunk-name" title="${c.filePath}">${pathBasename(c.filePath)}</span>
1494
+ <span class="chunk-size">${formatBytes(c.gzipSize)} <span class="chunk-badge">exclusive</span></span>
1495
+ </div>
1496
+ `).join('')}
1497
+ ${sharedChunks.map(sc => `
1498
+ <div class="chunk-item">
1499
+ <span class="chunk-name" title="${sc.chunk.filePath}">${pathBasename(sc.chunk.filePath)}</span>
1500
+ <span class="chunk-size">
1501
+ ${formatBytes(sc.chunk.gzipSize)}
1502
+ <span class="chunk-badge shared">shared (1/${sc.sharedBy})</span>
1503
+ </span>
1504
+ </div>
1505
+ `).join('')}
1506
+ </div>
1507
+ </div>
1508
+ `;
1509
+ }
1510
+
1511
+ // Parse route details
1512
+ const pagesHtml = island.component.pages.map(p => `
1513
+ <span style="font-size:0.8rem; background:var(--surface-hover); border:1px solid var(--border); padding:2px 8px; border-radius:4px; margin-right:4px; display:inline-block; margin-top:4px;">
1514
+ ${p}
1515
+ </span>
1516
+ `).join('');
1517
+
1518
+ // Build content body
1519
+ contentContainer.innerHTML = `
1520
+ <!-- Metadata -->
1521
+ <div style="margin-bottom:1.5rem;">
1522
+ <div class="drawer-section-title">Component Definition</div>
1523
+ <div class="drawer-meta-grid">
1524
+ <div class="drawer-meta-item" style="grid-column: 1 / -1;">
1525
+ <div class="drawer-meta-label">Source File Location</div>
1526
+ <div class="drawer-meta-value" style="font-size:0.8rem; font-family:'JetBrains Mono', monospace; word-break:break-all;">
1527
+ <a href="file://${island.component.sourceFile}" title="Click to view file">${island.component.sourceFile}</a>
1528
+ ${island.component.sourceLine ? `<span style="color:var(--text-muted)">:L${island.component.sourceLine}</span>` : ''}
1529
+ </div>
1530
+ </div>
1531
+ <div class="drawer-meta-item">
1532
+ <div class="drawer-meta-label">Hydration Directive</div>
1533
+ <div class="drawer-meta-value">
1534
+ <span class="directive-tag ${island.component.directive.split(':')[1]}">${island.component.directive}</span>
1535
+ ${island.component.directiveArg ? `<span style="font-size:0.75rem; color:var(--text-secondary)">=${island.component.directiveArg}</span>` : ''}
1536
+ </div>
1537
+ </div>
1538
+ <div class="drawer-meta-item">
1539
+ <div class="drawer-meta-label">Meta Framework</div>
1540
+ <div class="drawer-meta-value" style="text-transform:uppercase; font-size:0.8rem; font-weight:600;">
1541
+ ${island.component.metaFramework}
1542
+ </div>
1543
+ </div>
1544
+ </div>
1545
+ </div>
1546
+
1547
+ <!-- Size breakdown -->
1548
+ <div style="margin-bottom:1.5rem;">
1549
+ <div class="drawer-section-title">Size Breakdown (Gzip)</div>
1550
+ <div class="size-breakdown-grid">
1551
+ <div class="size-breakdown-card">
1552
+ <div class="drawer-meta-label">Exclusive</div>
1553
+ <div class="size-value exclusive">${formatBytes(island.bundle.exclusiveGzipSize)}</div>
1554
+ </div>
1555
+ <div class="size-breakdown-card">
1556
+ <div class="drawer-meta-label">Shared (Attribution)</div>
1557
+ <div class="size-value shared">${formatBytes(island.bundle.sharedGzipSize)}</div>
1558
+ </div>
1559
+ <div class="size-breakdown-card">
1560
+ <div class="drawer-meta-label">Total Cost</div>
1561
+ <div class="size-value" style="font-weight:800;">${formatBytes(island.bundle.totalGzipSize)}</div>
1562
+ </div>
1563
+ </div>
1564
+ </div>
1565
+
1566
+ <!-- Pages list -->
1567
+ <div style="margin-bottom:1.5rem;">
1568
+ <div class="drawer-section-title">Pages Residing On</div>
1569
+ <div>${pagesHtml}</div>
1570
+ </div>
1571
+
1572
+ <!-- Chunks list section -->
1573
+ ${chunksHtml}
1574
+
1575
+ <!-- Issues Warnings list -->
1576
+ ${issuesHtml}
1577
+ `;
1578
+
1579
+ // Show overlay and slide in drawer
1580
+ overlay.classList.add('open');
1581
+ drawer.classList.add('open');
423
1582
  }
424
1583
 
1584
+ function closeInspector() {
1585
+ selectedIsland = null;
1586
+ document.getElementById('drawer-overlay').classList.remove('open');
1587
+ document.getElementById('detail-drawer').classList.remove('open');
1588
+ }
1589
+
1590
+ // Utility helpers
425
1591
  function formatBytes(bytes) {
426
1592
  if (!bytes || bytes === 0) return '0 B';
427
1593
  if (bytes < 1024) return bytes + ' B';
@@ -430,10 +1596,12 @@
430
1596
  return (kb / 1024).toFixed(2) + ' MB';
431
1597
  }
432
1598
 
433
- // ─── Init ──────────────────────────────────────────────────
434
- loadReport();
435
- connectWS();
436
- window.addEventListener('resize', () => { if (report) renderTreemap(); });
1599
+ function pathBasename(filePath) {
1600
+ return filePath.split(/[\\/]/).pop() || filePath;
1601
+ }
1602
+
1603
+ // Launch UI init
1604
+ init();
437
1605
  </script>
438
1606
  </body>
439
1607
  </html>