GitHub Actions commited on
Commit
2dc46fb
·
1 Parent(s): 78b069f

sync from abhijitramesh/webgpu-bench@9f9ce2bf77

Browse files
Files changed (7) hide show
  1. css/style.css +436 -41
  2. index.html +34 -12
  3. js/app.js +109 -22
  4. js/run/controller.js +183 -24
  5. js/tables.js +64 -31
  6. methodology.html +75 -12
  7. run.html +23 -9
css/style.css CHANGED
@@ -5,6 +5,8 @@
5
 
6
  /* === RESET === */
7
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
 
8
 
9
  /* === DESIGN TOKENS === */
10
  :root {
@@ -203,6 +205,33 @@ a:hover { color: #60a5fa; }
203
  color: var(--foreground-muted);
204
  font-size: 13px;
205
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  .loading-hint code {
207
  background: var(--surface-2);
208
  padding: 2px 8px;
@@ -211,6 +240,59 @@ a:hover { color: #60a5fa; }
211
  font-size: 12px;
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  /* =============================================================
215
  FILTER BAR
216
  ============================================================= */
@@ -234,8 +316,8 @@ a:hover { color: #60a5fa; }
234
  }
235
  .filter-label {
236
  font-size: 11px;
237
- font-weight: 600;
238
- color: var(--foreground-subtle);
239
  text-transform: uppercase;
240
  letter-spacing: 0.05em;
241
  }
@@ -297,11 +379,15 @@ a:hover { color: #60a5fa; }
297
  cursor: pointer;
298
  transition: all var(--transition-fast);
299
  }
300
- .filter-reset-btn:hover {
301
  background: var(--surface-2);
302
  color: var(--foreground);
303
  border-color: var(--border-hover);
304
  }
 
 
 
 
305
 
306
  /* =============================================================
307
  QUANT MULTI-SELECT DROPDOWN
@@ -425,12 +511,25 @@ a:hover { color: #60a5fa; }
425
  }
426
 
427
  /* =============================================================
428
- SECTION NAVIGATION (Sticky Tabs)
429
  ============================================================= */
430
- .section-nav {
431
  position: sticky;
432
  top: 0;
433
- z-index: 40;
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  background: var(--background);
435
  border-bottom: 1px solid var(--border);
436
  }
@@ -482,6 +581,32 @@ a:hover { color: #60a5fa; }
482
  color: var(--foreground);
483
  letter-spacing: -0.01em;
484
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  .results-count {
486
  font-size: 13px;
487
  color: var(--foreground-muted);
@@ -580,6 +705,36 @@ a:hover { color: #60a5fa; }
580
  user-select: none;
581
  }
582
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  /* Sortable headers (results table only) */
584
  .results-table thead th {
585
  cursor: pointer;
@@ -592,6 +747,25 @@ a:hover { color: #60a5fa; }
592
  .results-table thead th.sorted {
593
  color: var(--foreground);
594
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
 
596
  /* Table cells */
597
  .results-table td,
@@ -630,12 +804,19 @@ a:hover { color: #60a5fa; }
630
  text-transform: uppercase;
631
  letter-spacing: 0.02em;
632
  }
633
- .badge--pass { background: var(--success-bg); color: var(--success); }
634
- .badge--fail { background: var(--error-bg); color: var(--error); }
635
- .badge--yes { background: var(--accent-blue-bg); color: var(--accent-blue); }
636
- .badge--no { background: var(--surface-3); color: var(--foreground-subtle); }
637
- .badge--webgpu { background: var(--accent-purple-bg); color: var(--accent-purple); }
638
- .badge--cpu { background: var(--accent-amber-bg); color: var(--accent-amber); }
 
 
 
 
 
 
 
639
 
640
  /* =============================================================
641
  ERROR CELLS & CATEGORIES
@@ -672,16 +853,18 @@ a:hover { color: #60a5fa; }
672
  padding: 20px;
673
  position: relative;
674
  height: 340px;
 
675
  transition: border-color var(--transition-base);
676
  }
 
677
  .chart-box:hover { border-color: var(--border-hover); }
678
 
679
  .subsection-title { font-size: 16px; font-weight: 600; color: var(--foreground); margin: 0; }
680
  .metric-selector { display: flex; align-items: center; gap: 8px; }
681
- .metric-selector-label { font-size: 13px; color: var(--foreground-subtle); }
682
- .col-sublabel { font-size: 10px; font-weight: 400; color: var(--foreground-subtle); }
683
  .th-group { text-align: center; font-weight: 700; color: var(--foreground); background: var(--surface-2); }
684
- .th-sub { font-size: 11px; font-weight: 500; color: var(--foreground-subtle); white-space: nowrap; }
685
  .th-group-border { border-right: 1px solid var(--border-hover); }
686
  .results-table td:has(+ .th-group-border) { border-right: 1px solid var(--border-hover); }
687
 
@@ -690,7 +873,7 @@ a:hover { color: #60a5fa; }
690
  top: 50%;
691
  left: 50%;
692
  transform: translate(-50%, -50%);
693
- color: var(--foreground-subtle);
694
  font-size: 13px;
695
  }
696
 
@@ -711,6 +894,42 @@ a:hover { color: #60a5fa; }
711
  }
712
  .machine-card:hover { border-color: var(--border-hover); }
713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  .machine-card-header {
715
  display: flex;
716
  align-items: center;
@@ -747,9 +966,22 @@ a:hover { color: #60a5fa; }
747
  .empty-state {
748
  padding: 48px 24px;
749
  text-align: center;
750
- color: var(--foreground-subtle);
751
  font-size: 14px;
752
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
753
 
754
  /* =============================================================
755
  UTILITY CLASSES
@@ -784,8 +1016,17 @@ a:hover { color: #60a5fa; }
784
  }
785
 
786
  @media (max-width: 768px) {
787
- .header-inner { padding: 0 16px; height: 48px; }
788
  .header-title { font-size: 14px; }
 
 
 
 
 
 
 
 
 
789
 
790
  .filter-bar { padding: 16px; }
791
  .filter-bar-inner { flex-direction: column; align-items: stretch; gap: 12px; }
@@ -821,11 +1062,74 @@ a:hover { color: #60a5fa; }
821
  /* =============================================================
822
  METHODOLOGY PAGE
823
  ============================================================= */
824
- .methodology-content {
825
- max-width: 800px;
 
 
 
 
 
826
  margin: 0 auto;
827
  padding: 40px 32px 80px;
828
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  .methodology-content .back-link {
830
  display: inline-flex;
831
  align-items: center;
@@ -838,6 +1142,14 @@ a:hover { color: #60a5fa; }
838
  }
839
  .methodology-content .back-link:hover { color: var(--foreground); text-decoration: none; }
840
 
 
 
 
 
 
 
 
 
841
  .methodology-content h2 {
842
  margin-top: 48px;
843
  margin-bottom: 16px;
@@ -846,7 +1158,7 @@ a:hover { color: #60a5fa; }
846
  color: var(--foreground);
847
  letter-spacing: -0.01em;
848
  }
849
- .methodology-content h2:first-of-type { margin-top: 0; }
850
 
851
  .methodology-content h3 {
852
  margin-top: 28px;
@@ -966,14 +1278,18 @@ a:hover { color: #60a5fa; }
966
 
967
  /* Run-mode badge colored by surface. */
968
  .run-mode-badge {
969
- text-transform: lowercase;
970
- letter-spacing: 0.04em;
971
  font-weight: 600;
972
  }
973
- .run-mode-localhost { background: var(--accent-blue-bg); color: var(--accent-blue); }
974
- .run-mode-space { background: var(--accent-purple-bg); color: var(--accent-purple); }
975
- .run-mode-pages { background: var(--surface-3); color: var(--foreground-subtle); }
976
- .run-mode-file { background: var(--accent-amber-bg); color: var(--accent-amber); }
 
 
 
 
 
977
 
978
  /* Pages-mode read-only banner. */
979
  .run-pages-banner {
@@ -988,7 +1304,8 @@ a:hover { color: #60a5fa; }
988
  border-radius: var(--radius-md);
989
  font-size: 13px;
990
  }
991
- .run-pages-banner a { font-weight: 600; }
 
992
 
993
  /* HF hub sign-in/submit row (space surface only). */
994
  .hub-row { margin-bottom: 20px; }
@@ -1025,7 +1342,7 @@ a:hover { color: #60a5fa; }
1025
  }
1026
  .run-device-row-label { color: var(--foreground-muted); }
1027
  .run-device-row-value { color: var(--foreground); font-weight: 600; }
1028
- .run-device-note { font-size: 11px; color: var(--foreground-subtle); margin-top: 2px; }
1029
 
1030
  /* Hide filters + iterations + action buttons — stacks with .filter-bar tokens. */
1031
  .run-filters { align-items: center; }
@@ -1048,7 +1365,13 @@ a:hover { color: #60a5fa; }
1048
  width: 72px;
1049
  min-width: 0;
1050
  padding: 0 10px;
1051
- background: var(--surface-1);
 
 
 
 
 
 
1052
  background-image: none;
1053
  }
1054
  .run-actions {
@@ -1058,7 +1381,44 @@ a:hover { color: #60a5fa; }
1058
  flex-wrap: wrap;
1059
  margin-left: auto;
1060
  }
1061
- #queue-status { font-size: 12px; color: var(--foreground-muted); font-family: var(--font-mono); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1062
 
1063
  /* Family cards + variant rows. */
1064
  .run-models-stack {
@@ -1068,9 +1428,7 @@ a:hover { color: #60a5fa; }
1068
  margin-bottom: 24px;
1069
  }
1070
  .run-family { padding: 0; overflow: hidden; }
1071
- .run-family > summary {
1072
- list-style: none;
1073
- cursor: pointer;
1074
  display: flex;
1075
  align-items: center;
1076
  gap: 10px;
@@ -1078,25 +1436,52 @@ a:hover { color: #60a5fa; }
1078
  background: var(--surface-1);
1079
  border-bottom: 1px solid transparent;
1080
  transition: background var(--transition-fast), border-color var(--transition-fast);
 
 
1081
  }
1082
- .run-family[open] > summary {
 
1083
  border-bottom-color: var(--border);
1084
  background: var(--surface-2);
1085
  }
1086
- .run-family > summary::-webkit-details-marker { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1087
  .run-family-chevron {
1088
  display: inline-block;
1089
- width: 10px;
1090
- height: 10px;
1091
  border-right: 1.5px solid var(--foreground-muted);
1092
  border-bottom: 1.5px solid var(--foreground-muted);
1093
  transform: rotate(-45deg);
1094
  transition: transform var(--transition-fast);
1095
  flex-shrink: 0;
1096
  }
1097
- .run-family[open] > summary .run-family-chevron { transform: rotate(45deg); }
1098
  .run-family-select-all { margin: 0; accent-color: var(--foreground); cursor: pointer; }
1099
- .run-family-name { font-size: 14px; font-weight: 700; color: var(--foreground); }
 
 
 
 
 
 
1100
  .run-family-stats {
1101
  color: var(--foreground-muted);
1102
  font-size: 12px;
@@ -1104,10 +1489,11 @@ a:hover { color: #60a5fa; }
1104
  }
1105
  .run-family-warning {
1106
  margin-left: auto;
1107
- color: var(--accent-amber);
1108
  font-size: 11px;
1109
  font-weight: 600;
1110
  }
 
1111
 
1112
  .run-variant-list {
1113
  display: flex;
@@ -1193,6 +1579,15 @@ a:hover { color: #60a5fa; }
1193
  font-size: 12px;
1194
  line-height: 1.6;
1195
  resize: vertical;
 
 
 
 
 
 
 
 
 
1196
  }
1197
  .run-output-textarea:focus {
1198
  outline: none;
 
5
 
6
  /* === RESET === */
7
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+ html, body { overflow-x: hidden; max-width: 100%; }
9
+ html { scroll-padding-top: 80px; }
10
 
11
  /* === DESIGN TOKENS === */
12
  :root {
 
205
  color: var(--foreground-muted);
206
  font-size: 13px;
207
  }
208
+ /* Skeleton rows that mimic the dashboard shell while data loads — reduces
209
+ perceived latency compared with a centered spinner + text swap. */
210
+ .skeleton {
211
+ background: linear-gradient(90deg, var(--surface-1) 0%, var(--surface-2) 50%, var(--surface-1) 100%);
212
+ background-size: 200% 100%;
213
+ border-radius: var(--radius-md);
214
+ animation: shimmer 1.2s ease-in-out infinite;
215
+ }
216
+ @keyframes shimmer {
217
+ from { background-position: 100% 0; }
218
+ to { background-position: -100% 0; }
219
+ }
220
+ .skeleton-hero { height: 120px; margin: 32px 0 1px; border-radius: 0; }
221
+ .skeleton-filters { height: 70px; margin-bottom: 1px; border-radius: 0; }
222
+ .skeleton-stats {
223
+ display: grid;
224
+ grid-template-columns: repeat(3, 1fr);
225
+ gap: 16px;
226
+ padding: 32px;
227
+ }
228
+ .skeleton-stats > div { height: 80px; }
229
+ .skeleton-table { margin: 0 32px 32px; height: 240px; }
230
+ @media (max-width: 640px) {
231
+ .skeleton-stats { grid-template-columns: 1fr; padding: 16px; }
232
+ .skeleton-table { margin: 0 16px 24px; }
233
+ }
234
+
235
  .loading-hint code {
236
  background: var(--surface-2);
237
  padding: 2px 8px;
 
240
  font-size: 12px;
241
  }
242
 
243
+ /* =============================================================
244
+ HERO
245
+ ============================================================= */
246
+ .hero {
247
+ border-bottom: 1px solid var(--border);
248
+ background: var(--background);
249
+ }
250
+ .hero-inner {
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: space-between;
254
+ gap: 24px;
255
+ padding-top: 32px;
256
+ padding-bottom: 32px;
257
+ flex-wrap: wrap;
258
+ }
259
+ .hero-copy { flex: 1 1 420px; min-width: 0; }
260
+ .hero-title {
261
+ margin: 0 0 8px;
262
+ font-size: 28px;
263
+ font-weight: 800;
264
+ letter-spacing: -0.02em;
265
+ color: var(--foreground);
266
+ }
267
+ .hero-lede {
268
+ margin: 0;
269
+ font-size: 15px;
270
+ line-height: 1.55;
271
+ color: var(--foreground-secondary);
272
+ max-width: 62ch;
273
+ }
274
+ .hero-meta {
275
+ margin: 10px 0 0;
276
+ font-size: 12px;
277
+ color: var(--foreground-muted);
278
+ font-family: var(--font-mono);
279
+ }
280
+ .hero-lede code {
281
+ font-family: var(--font-mono);
282
+ font-size: 13px;
283
+ background: var(--surface-2);
284
+ padding: 1px 6px;
285
+ border-radius: var(--radius-sm);
286
+ color: var(--foreground);
287
+ }
288
+ .hero-actions { display: flex; gap: 10px; flex-shrink: 0; }
289
+ .hero-actions .btn { text-decoration: none; }
290
+ @media (max-width: 640px) {
291
+ .hero-inner { padding-top: 20px; padding-bottom: 20px; }
292
+ .hero-title { font-size: 22px; }
293
+ .hero-lede { font-size: 14px; }
294
+ }
295
+
296
  /* =============================================================
297
  FILTER BAR
298
  ============================================================= */
 
316
  }
317
  .filter-label {
318
  font-size: 11px;
319
+ font-weight: 700;
320
+ color: var(--foreground-secondary);
321
  text-transform: uppercase;
322
  letter-spacing: 0.05em;
323
  }
 
379
  cursor: pointer;
380
  transition: all var(--transition-fast);
381
  }
382
+ .filter-reset-btn:hover:not(:disabled) {
383
  background: var(--surface-2);
384
  color: var(--foreground);
385
  border-color: var(--border-hover);
386
  }
387
+ .filter-reset-btn:disabled {
388
+ opacity: 0.5;
389
+ cursor: not-allowed;
390
+ }
391
 
392
  /* =============================================================
393
  QUANT MULTI-SELECT DROPDOWN
 
511
  }
512
 
513
  /* =============================================================
514
+ STICKY HEAD (filter bar + section jump links)
515
  ============================================================= */
516
+ .sticky-head {
517
  position: sticky;
518
  top: 0;
519
+ z-index: 45;
520
+ background: var(--background);
521
+ }
522
+ /* On small screens the filter-bar + nav together exceed 500px, which is
523
+ worse than not sticking at all. Drop back to static flow so the user
524
+ can see content. */
525
+ @media (max-width: 768px) {
526
+ .sticky-head { position: static; }
527
+ }
528
+
529
+ /* =============================================================
530
+ SECTION NAVIGATION (jump links)
531
+ ============================================================= */
532
+ .section-nav {
533
  background: var(--background);
534
  border-bottom: 1px solid var(--border);
535
  }
 
581
  color: var(--foreground);
582
  letter-spacing: -0.01em;
583
  }
584
+ .section-header h1 {
585
+ font-size: 22px;
586
+ font-weight: 800;
587
+ color: var(--foreground);
588
+ letter-spacing: -0.02em;
589
+ }
590
+
591
+ /* Run page hero row — matches the size of the Methodology h1 / index
592
+ hero-title (28px). Houses the mode badge inline. */
593
+ .run-hero {
594
+ display: flex;
595
+ align-items: baseline;
596
+ justify-content: space-between;
597
+ gap: 16px;
598
+ margin-bottom: 20px;
599
+ flex-wrap: wrap;
600
+ }
601
+ .run-hero-title {
602
+ font-size: 28px;
603
+ font-weight: 800;
604
+ letter-spacing: -0.02em;
605
+ color: var(--foreground);
606
+ }
607
+ @media (max-width: 640px) {
608
+ .run-hero-title { font-size: 22px; }
609
+ }
610
  .results-count {
611
  font-size: 13px;
612
  color: var(--foreground-muted);
 
705
  user-select: none;
706
  }
707
 
708
+ /* Sticky-left columns: Machine (col 1) + Model (col 2). Keeps the row
709
+ identity visible when scrolling through the 19-column table. */
710
+ .results-table .col-pin {
711
+ position: sticky;
712
+ z-index: 2;
713
+ }
714
+ .results-table thead th.col-pin {
715
+ z-index: 11;
716
+ background: var(--surface-1);
717
+ }
718
+ .results-table tbody td.col-pin { background: var(--background); }
719
+ [data-theme="dark"] .results-table tbody td.col-pin { background: var(--surface-0); }
720
+ .results-table tbody tr:hover td.col-pin { background: var(--surface-2); }
721
+ .results-table .col-pin-1 { left: 0; }
722
+ .results-table .col-pin-2 {
723
+ left: 140px;
724
+ box-shadow: 1px 0 0 var(--border);
725
+ }
726
+ @media (max-width: 768px) {
727
+ .results-table .col-pin { position: static; box-shadow: none; }
728
+ }
729
+ /* Priority-based responsive columns: drop the bulky metric columns so
730
+ the mobile table is readable instead of an endless horizontal scroll. */
731
+ @media (max-width: 900px) {
732
+ .results-table .col-p3 { display: none; }
733
+ }
734
+ @media (max-width: 640px) {
735
+ .results-table .col-p2 { display: none; }
736
+ }
737
+
738
  /* Sortable headers (results table only) */
739
  .results-table thead th {
740
  cursor: pointer;
 
747
  .results-table thead th.sorted {
748
  color: var(--foreground);
749
  }
750
+ .results-table thead th:focus-visible {
751
+ outline: 2px solid var(--ring);
752
+ outline-offset: -2px;
753
+ }
754
+ .results-table .th-label { margin-right: 4px; }
755
+ .results-table .th-sort-indicator {
756
+ display: inline-block;
757
+ width: 10px;
758
+ color: var(--foreground-muted);
759
+ opacity: 0.45;
760
+ font-weight: 500;
761
+ transition: opacity var(--transition-fast), color var(--transition-fast);
762
+ }
763
+ .results-table thead th[aria-sort="ascending"] .th-sort-indicator,
764
+ .results-table thead th[aria-sort="descending"] .th-sort-indicator {
765
+ opacity: 1;
766
+ color: var(--foreground);
767
+ }
768
+ .results-table thead th:hover .th-sort-indicator { opacity: 0.85; }
769
 
770
  /* Table cells */
771
  .results-table td,
 
804
  text-transform: uppercase;
805
  letter-spacing: 0.02em;
806
  }
807
+ .badge--pass { background: var(--success-bg); color: #166534; }
808
+ .badge--fail { background: var(--error-bg); color: #991b1b; }
809
+ .badge--yes { background: var(--accent-blue-bg); color: #1d4ed8; }
810
+ .badge--no { background: var(--surface-3); color: var(--foreground-secondary); }
811
+ .badge--webgpu { background: var(--accent-purple-bg); color: #5b21b6; }
812
+ .badge--cpu { background: var(--accent-amber-bg); color: #92400e; }
813
+
814
+ [data-theme="dark"] .badge--pass { color: #4ade80; }
815
+ [data-theme="dark"] .badge--fail { color: #f87171; }
816
+ [data-theme="dark"] .badge--yes { color: #60a5fa; }
817
+ [data-theme="dark"] .badge--no { color: var(--foreground-muted); }
818
+ [data-theme="dark"] .badge--webgpu { color: #c4b5fd; }
819
+ [data-theme="dark"] .badge--cpu { color: #fbbf24; }
820
 
821
  /* =============================================================
822
  ERROR CELLS & CATEGORIES
 
853
  padding: 20px;
854
  position: relative;
855
  height: 340px;
856
+ min-width: 0;
857
  transition: border-color var(--transition-base);
858
  }
859
+ .chart-box canvas { max-width: 100%; }
860
  .chart-box:hover { border-color: var(--border-hover); }
861
 
862
  .subsection-title { font-size: 16px; font-weight: 600; color: var(--foreground); margin: 0; }
863
  .metric-selector { display: flex; align-items: center; gap: 8px; }
864
+ .metric-selector-label { font-size: 13px; color: var(--foreground-muted); }
865
+ .col-sublabel { font-size: 10px; font-weight: 400; color: var(--foreground-muted); }
866
  .th-group { text-align: center; font-weight: 700; color: var(--foreground); background: var(--surface-2); }
867
+ .th-sub { font-size: 11px; font-weight: 500; color: var(--foreground-muted); white-space: nowrap; }
868
  .th-group-border { border-right: 1px solid var(--border-hover); }
869
  .results-table td:has(+ .th-group-border) { border-right: 1px solid var(--border-hover); }
870
 
 
873
  top: 50%;
874
  left: 50%;
875
  transform: translate(-50%, -50%);
876
+ color: var(--foreground-secondary);
877
  font-size: 13px;
878
  }
879
 
 
894
  }
895
  .machine-card:hover { border-color: var(--border-hover); }
896
 
897
+ /* "Add your machine" call-to-action card — same shape as machine cards
898
+ but dashed/muted, and it's a link so the whole card is clickable. */
899
+ .machine-card-add {
900
+ display: flex;
901
+ flex-direction: column;
902
+ border-style: dashed;
903
+ background: transparent;
904
+ color: var(--foreground-secondary);
905
+ text-decoration: none;
906
+ gap: 12px;
907
+ }
908
+ .machine-card-add:hover {
909
+ border-color: var(--foreground-muted);
910
+ background: var(--surface-1);
911
+ color: var(--foreground);
912
+ }
913
+ .machine-card-add .machine-card-header {
914
+ margin-bottom: 0;
915
+ padding-bottom: 0;
916
+ border-bottom: none;
917
+ }
918
+ .machine-card-add .machine-card-icon { color: var(--foreground-muted); }
919
+ .machine-card-add:hover .machine-card-icon { color: var(--foreground); }
920
+ .machine-card-add-blurb {
921
+ font-size: 13px;
922
+ line-height: 1.5;
923
+ color: var(--foreground-secondary);
924
+ }
925
+ .machine-card-add-cta {
926
+ display: inline-flex;
927
+ font-size: 13px;
928
+ font-weight: 600;
929
+ color: var(--foreground);
930
+ margin-top: auto;
931
+ }
932
+
933
  .machine-card-header {
934
  display: flex;
935
  align-items: center;
 
966
  .empty-state {
967
  padding: 48px 24px;
968
  text-align: center;
969
+ color: var(--foreground-secondary);
970
  font-size: 14px;
971
  }
972
+ .empty-state p + p { margin-top: 8px; }
973
+ .empty-state-sub {
974
+ font-size: 13px;
975
+ color: var(--foreground-muted);
976
+ max-width: 52ch;
977
+ margin-inline: auto;
978
+ line-height: 1.5;
979
+ }
980
+ .empty-state a {
981
+ color: var(--foreground);
982
+ font-weight: 600;
983
+ text-decoration: underline;
984
+ }
985
 
986
  /* =============================================================
987
  UTILITY CLASSES
 
1016
  }
1017
 
1018
  @media (max-width: 768px) {
1019
+ .header-inner { padding: 0 12px; height: 48px; gap: 8px; }
1020
  .header-title { font-size: 14px; }
1021
+ .header-nav { gap: 2px; }
1022
+ .header-link { padding: 6px 8px; font-size: 12px; }
1023
+ /* Hide the "GitHub" wordmark, keep the icon-only link. */
1024
+ .header-nav > a[href*="github.com"] { padding: 6px; }
1025
+ .header-nav > a[href*="github.com"] > :not(svg) { display: none; }
1026
+ }
1027
+ @media (max-width: 420px) {
1028
+ .header-title { display: none; }
1029
+ .header-link { padding: 6px; font-size: 12px; }
1030
 
1031
  .filter-bar { padding: 16px; }
1032
  .filter-bar-inner { flex-direction: column; align-items: stretch; gap: 12px; }
 
1062
  /* =============================================================
1063
  METHODOLOGY PAGE
1064
  ============================================================= */
1065
+ /* Two-column layout: sticky ToC on the left, content on the right.
1066
+ Falls back to a stacked layout on small screens. */
1067
+ .methodology-layout {
1068
+ display: grid;
1069
+ grid-template-columns: 220px minmax(0, 800px);
1070
+ gap: 48px;
1071
+ max-width: 1100px;
1072
  margin: 0 auto;
1073
  padding: 40px 32px 80px;
1074
  }
1075
+ .methodology-toc {
1076
+ position: sticky;
1077
+ top: 24px;
1078
+ align-self: start;
1079
+ font-size: 13px;
1080
+ max-height: calc(100vh - 48px);
1081
+ overflow-y: auto;
1082
+ }
1083
+ .methodology-toc-title {
1084
+ font-size: 11px;
1085
+ font-weight: 700;
1086
+ letter-spacing: 0.05em;
1087
+ text-transform: uppercase;
1088
+ color: var(--foreground-muted);
1089
+ margin-bottom: 12px;
1090
+ }
1091
+ .methodology-toc ol {
1092
+ list-style: none;
1093
+ padding: 0;
1094
+ margin: 0;
1095
+ border-left: 1px solid var(--border);
1096
+ }
1097
+ .methodology-toc ol ol { margin-top: 4px; margin-left: 12px; }
1098
+ .methodology-toc li { margin: 0; padding: 0; }
1099
+ .methodology-toc a {
1100
+ display: block;
1101
+ padding: 6px 12px;
1102
+ color: var(--foreground-secondary);
1103
+ text-decoration: none;
1104
+ border-left: 2px solid transparent;
1105
+ margin-left: -1px;
1106
+ line-height: 1.4;
1107
+ transition: color var(--transition-fast), border-color var(--transition-fast);
1108
+ }
1109
+ .methodology-toc a:hover { color: var(--foreground); }
1110
+ .methodology-toc a.active {
1111
+ color: var(--foreground);
1112
+ border-left-color: var(--foreground);
1113
+ font-weight: 600;
1114
+ }
1115
+ @media (max-width: 900px) {
1116
+ .methodology-layout { grid-template-columns: 1fr; gap: 0; padding: 24px 16px 48px; }
1117
+ .methodology-toc {
1118
+ position: static;
1119
+ max-height: none;
1120
+ margin-bottom: 24px;
1121
+ padding: 16px 20px;
1122
+ border: 1px solid var(--border);
1123
+ border-radius: var(--radius-md);
1124
+ background: var(--surface-1);
1125
+ }
1126
+ .methodology-toc ol { border-left: none; }
1127
+ .methodology-toc a { padding: 4px 0; border-left: none; }
1128
+ }
1129
+
1130
+ .methodology-content {
1131
+ min-width: 0;
1132
+ }
1133
  .methodology-content .back-link {
1134
  display: inline-flex;
1135
  align-items: center;
 
1142
  }
1143
  .methodology-content .back-link:hover { color: var(--foreground); text-decoration: none; }
1144
 
1145
+ .methodology-content h1 {
1146
+ margin: 0 0 32px;
1147
+ font-size: 28px;
1148
+ font-weight: 800;
1149
+ color: var(--foreground);
1150
+ letter-spacing: -0.02em;
1151
+ }
1152
+
1153
  .methodology-content h2 {
1154
  margin-top: 48px;
1155
  margin-bottom: 16px;
 
1158
  color: var(--foreground);
1159
  letter-spacing: -0.01em;
1160
  }
1161
+ .methodology-content h1 + h2 { margin-top: 0; }
1162
 
1163
  .methodology-content h3 {
1164
  margin-top: 28px;
 
1278
 
1279
  /* Run-mode badge colored by surface. */
1280
  .run-mode-badge {
1281
+ letter-spacing: 0.02em;
 
1282
  font-weight: 600;
1283
  }
1284
+ .run-mode-localhost { background: var(--accent-blue-bg); color: #1d4ed8; }
1285
+ .run-mode-space { background: var(--accent-purple-bg); color: #5b21b6; }
1286
+ .run-mode-pages { background: var(--surface-3); color: var(--foreground-secondary); }
1287
+ .run-mode-file { background: var(--accent-amber-bg); color: #92400e; }
1288
+
1289
+ [data-theme="dark"] .run-mode-localhost { color: #60a5fa; }
1290
+ [data-theme="dark"] .run-mode-space { color: #c4b5fd; }
1291
+ [data-theme="dark"] .run-mode-pages { color: var(--foreground-muted); }
1292
+ [data-theme="dark"] .run-mode-file { color: #fbbf24; }
1293
 
1294
  /* Pages-mode read-only banner. */
1295
  .run-pages-banner {
 
1304
  border-radius: var(--radius-md);
1305
  font-size: 13px;
1306
  }
1307
+ .run-pages-banner a { font-weight: 600; color: #1d4ed8; text-decoration: underline; }
1308
+ [data-theme="dark"] .run-pages-banner a { color: #93c5fd; }
1309
 
1310
  /* HF hub sign-in/submit row (space surface only). */
1311
  .hub-row { margin-bottom: 20px; }
 
1342
  }
1343
  .run-device-row-label { color: var(--foreground-muted); }
1344
  .run-device-row-value { color: var(--foreground); font-weight: 600; }
1345
+ .run-device-note { font-size: 11px; color: var(--foreground-muted); margin-top: 2px; }
1346
 
1347
  /* Hide filters + iterations + action buttons — stacks with .filter-bar tokens. */
1348
  .run-filters { align-items: center; }
 
1365
  width: 72px;
1366
  min-width: 0;
1367
  padding: 0 10px;
1368
+ background-color: var(--surface-1);
1369
+ background-image: none;
1370
+ background-repeat: no-repeat;
1371
+ }
1372
+ /* The dark-theme .filter-select override re-adds the chevron SVG at higher
1373
+ specificity; explicitly nullify for the number input. */
1374
+ [data-theme="dark"] .run-iter-input {
1375
  background-image: none;
1376
  }
1377
  .run-actions {
 
1381
  flex-wrap: wrap;
1382
  margin-left: auto;
1383
  }
1384
+ .run-queue-status {
1385
+ font-size: 12px;
1386
+ color: var(--foreground-muted);
1387
+ font-family: var(--font-mono);
1388
+ min-height: 16px;
1389
+ }
1390
+
1391
+ /* Small button variant used in the batch-select row. */
1392
+ .btn-xs {
1393
+ height: 28px;
1394
+ padding: 0 10px;
1395
+ font-size: 12px;
1396
+ }
1397
+
1398
+ /* Sticky action bar with Download / Run — always reachable inside the
1399
+ long model list. */
1400
+ .run-action-bar {
1401
+ position: sticky;
1402
+ top: 0;
1403
+ z-index: 40;
1404
+ margin: 0 0 20px;
1405
+ padding: 12px 32px;
1406
+ background: var(--background);
1407
+ border-bottom: 1px solid var(--border);
1408
+ box-shadow: 0 1px 0 var(--border);
1409
+ }
1410
+ .run-action-bar-inner {
1411
+ display: flex;
1412
+ align-items: center;
1413
+ justify-content: space-between;
1414
+ gap: 16px;
1415
+ flex-wrap: wrap;
1416
+ }
1417
+ @media (max-width: 640px) {
1418
+ .run-action-bar { padding: 12px 16px; }
1419
+ .run-action-bar-inner { gap: 10px; }
1420
+ .run-actions { margin-left: 0; }
1421
+ }
1422
 
1423
  /* Family cards + variant rows. */
1424
  .run-models-stack {
 
1428
  margin-bottom: 24px;
1429
  }
1430
  .run-family { padding: 0; overflow: hidden; }
1431
+ .run-family-summary {
 
 
1432
  display: flex;
1433
  align-items: center;
1434
  gap: 10px;
 
1436
  background: var(--surface-1);
1437
  border-bottom: 1px solid transparent;
1438
  transition: background var(--transition-fast), border-color var(--transition-fast);
1439
+ cursor: pointer;
1440
+ user-select: none;
1441
  }
1442
+ .run-family-summary:hover { background: var(--surface-2); }
1443
+ .run-family.is-open > .run-family-summary {
1444
  border-bottom-color: var(--border);
1445
  background: var(--surface-2);
1446
  }
1447
+ .run-family-toggle {
1448
+ display: inline-flex;
1449
+ align-items: center;
1450
+ justify-content: center;
1451
+ width: 22px;
1452
+ height: 22px;
1453
+ padding: 0;
1454
+ border: 0;
1455
+ background: transparent;
1456
+ color: inherit;
1457
+ cursor: pointer;
1458
+ border-radius: var(--radius-sm);
1459
+ flex-shrink: 0;
1460
+ }
1461
+ .run-family-toggle:hover { background: var(--surface-3); }
1462
+ .run-family-toggle:focus-visible {
1463
+ outline: 2px solid var(--ring);
1464
+ outline-offset: 1px;
1465
+ }
1466
  .run-family-chevron {
1467
  display: inline-block;
1468
+ width: 8px;
1469
+ height: 8px;
1470
  border-right: 1.5px solid var(--foreground-muted);
1471
  border-bottom: 1.5px solid var(--foreground-muted);
1472
  transform: rotate(-45deg);
1473
  transition: transform var(--transition-fast);
1474
  flex-shrink: 0;
1475
  }
1476
+ .run-family.is-open .run-family-chevron { transform: rotate(45deg); }
1477
  .run-family-select-all { margin: 0; accent-color: var(--foreground); cursor: pointer; }
1478
+ .run-family-name {
1479
+ font-size: 14px;
1480
+ font-weight: 700;
1481
+ color: var(--foreground);
1482
+ cursor: pointer;
1483
+ user-select: none;
1484
+ }
1485
  .run-family-stats {
1486
  color: var(--foreground-muted);
1487
  font-size: 12px;
 
1489
  }
1490
  .run-family-warning {
1491
  margin-left: auto;
1492
+ color: #92400e;
1493
  font-size: 11px;
1494
  font-weight: 600;
1495
  }
1496
+ [data-theme="dark"] .run-family-warning { color: #fbbf24; }
1497
 
1498
  .run-variant-list {
1499
  display: flex;
 
1579
  font-size: 12px;
1580
  line-height: 1.6;
1581
  resize: vertical;
1582
+ transition: min-height var(--transition-base);
1583
+ }
1584
+ /* Collapse when no output — hides the 320px empty textarea. */
1585
+ .run-output.is-empty .run-output-textarea {
1586
+ min-height: 60px;
1587
+ height: 60px;
1588
+ }
1589
+ .run-output.is-empty .run-output-textarea::placeholder {
1590
+ color: var(--foreground-muted);
1591
  }
1592
  .run-output-textarea:focus {
1593
  outline: none;
index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <meta name="color-scheme" content="light dark">
7
- <script>(function(){var t=localStorage.getItem('theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
8
  <title>WebGPU Bench</title>
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -19,7 +19,7 @@
19
  <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
20
  <span class="header-title">WebGPU Bench</span>
21
  </a>
22
- <nav class="header-nav">
23
  <a href="run.html" class="header-link">Run</a>
24
  <a href="methodology.html" class="header-link">Methodology</a>
25
  <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
@@ -35,14 +35,35 @@
35
  </header>
36
 
37
  <main>
38
- <div id="loading" class="loading-state">
39
- <div class="loading-content">
40
- <div class="loading-spinner"></div>
41
- <p>Loading benchmark data...</p>
 
 
 
42
  </div>
 
43
  </div>
44
 
45
  <div id="dashboard" style="display: none;">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  <!-- Filter Bar -->
47
  <div class="filter-bar">
48
  <div class="filter-bar-inner">
@@ -77,16 +98,16 @@
77
  </div>
78
  </div>
79
  <div class="filter-actions">
80
- <button class="filter-reset-btn" id="filter-reset" type="button" style="display: none;" title="Reset all filters">
81
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
82
- Reset
83
  </button>
84
  </div>
85
  </div>
86
  </div>
87
 
88
  <!-- Section Navigation -->
89
- <nav class="section-nav" id="section-nav">
90
  <div class="section-nav-track">
91
  <button class="section-nav-item active" data-section="overview">Overview</button>
92
  <button class="section-nav-item" data-section="results-section">Results</button>
@@ -95,6 +116,7 @@
95
  <button class="section-nav-item" data-section="machines-section">Machines</button>
96
  </div>
97
  </nav>
 
98
 
99
  <!-- Overview -->
100
  <section id="overview" class="dash-section">
@@ -139,7 +161,7 @@
139
  <span class="results-count" id="results-count"></span>
140
  </div>
141
  <div class="table-card">
142
- <div class="results-wrapper" id="results-table"></div>
143
  </div>
144
  </div>
145
  </section>
@@ -160,8 +182,8 @@
160
  <div class="section-header" style="margin-top: 32px;">
161
  <h3 class="subsection-title">CPU vs WebGPU</h3>
162
  <div class="metric-selector">
163
- <span class="metric-selector-label">Metric</span>
164
- <select id="cpu-gpu-metric" class="filter-select">
165
  <option value="decode_tok_s">Decode tok/s</option>
166
  <option value="prefill_tok_s">Prefill tok/s</option>
167
  </select>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <meta name="color-scheme" content="light dark">
7
+ <script>(function(){var s=localStorage.getItem('theme');if(!s){s=(window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}document.documentElement.setAttribute('data-theme',s);})();</script>
8
  <title>WebGPU Bench</title>
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
19
  <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
20
  <span class="header-title">WebGPU Bench</span>
21
  </a>
22
+ <nav class="header-nav" aria-label="Primary">
23
  <a href="run.html" class="header-link">Run</a>
24
  <a href="methodology.html" class="header-link">Methodology</a>
25
  <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
 
35
  </header>
36
 
37
  <main>
38
+ <div id="loading" aria-busy="true" aria-label="Loading benchmark data">
39
+ <div class="skeleton skeleton-hero" aria-hidden="true"></div>
40
+ <div class="skeleton skeleton-filters" aria-hidden="true"></div>
41
+ <div class="skeleton-stats" aria-hidden="true">
42
+ <div class="skeleton"></div>
43
+ <div class="skeleton"></div>
44
+ <div class="skeleton"></div>
45
  </div>
46
+ <div class="skeleton skeleton-table" aria-hidden="true"></div>
47
  </div>
48
 
49
  <div id="dashboard" style="display: none;">
50
+ <!-- Hero -->
51
+ <section class="hero">
52
+ <div class="container hero-inner">
53
+ <div class="hero-copy">
54
+ <h1 class="hero-title">WebGPU Bench</h1>
55
+ <p class="hero-lede">Real-browser benchmarks for <code>llama.cpp</code>'s WebGPU backend. GGUF models loaded via WebAssembly, measured across Chrome and Safari.</p>
56
+ <p class="hero-meta" id="hero-meta" hidden></p>
57
+ </div>
58
+ <div class="hero-actions">
59
+ <a href="run.html" class="btn btn-primary">Run on your machine</a>
60
+ <a href="methodology.html" class="btn btn-secondary">Methodology</a>
61
+ </div>
62
+ </div>
63
+ </section>
64
+
65
+ <!-- Sticky head: filter bar + section jump links -->
66
+ <div class="sticky-head">
67
  <!-- Filter Bar -->
68
  <div class="filter-bar">
69
  <div class="filter-bar-inner">
 
98
  </div>
99
  </div>
100
  <div class="filter-actions">
101
+ <button class="filter-reset-btn" id="filter-reset" type="button" disabled title="Reset all filters">
102
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
103
+ <span class="filter-reset-label">Reset</span>
104
  </button>
105
  </div>
106
  </div>
107
  </div>
108
 
109
  <!-- Section Navigation -->
110
+ <nav class="section-nav" id="section-nav" aria-label="Dashboard sections">
111
  <div class="section-nav-track">
112
  <button class="section-nav-item active" data-section="overview">Overview</button>
113
  <button class="section-nav-item" data-section="results-section">Results</button>
 
116
  <button class="section-nav-item" data-section="machines-section">Machines</button>
117
  </div>
118
  </nav>
119
+ </div><!-- /sticky-head -->
120
 
121
  <!-- Overview -->
122
  <section id="overview" class="dash-section">
 
161
  <span class="results-count" id="results-count"></span>
162
  </div>
163
  <div class="table-card">
164
+ <div class="results-wrapper" id="results-table" tabindex="0" role="region" aria-label="Benchmark results table"></div>
165
  </div>
166
  </div>
167
  </section>
 
182
  <div class="section-header" style="margin-top: 32px;">
183
  <h3 class="subsection-title">CPU vs WebGPU</h3>
184
  <div class="metric-selector">
185
+ <label class="metric-selector-label" for="cpu-gpu-metric">Metric</label>
186
+ <select id="cpu-gpu-metric" class="filter-select" aria-label="CPU vs WebGPU metric">
187
  <option value="decode_tok_s">Decode tok/s</option>
188
  <option value="prefill_tok_s">Prefill tok/s</option>
189
  </select>
js/app.js CHANGED
@@ -9,7 +9,9 @@ async function init() {
9
  try {
10
  appData = await loadData();
11
  } catch (e) {
12
- document.getElementById('loading').innerHTML = `
 
 
13
  <div class="loading-content">
14
  <p class="loading-error">Failed to load data</p>
15
  <p class="loading-hint">Run: <code>node scripts/build-site.js</code></p>
@@ -28,6 +30,9 @@ async function init() {
28
  // Populate quant options from actual data
29
  populateQuantOptions(appData.results);
30
 
 
 
 
31
  // Init filter dropdowns
32
  initFilters(appData.meta, () => render());
33
 
@@ -89,13 +94,19 @@ function render() {
89
  : `${filtered.length} of ${total}`;
90
  }
91
 
92
- // Reset button visibility
 
93
  const resetBtn = document.getElementById('filter-reset');
94
  if (resetBtn) {
95
- const active = filters.machine !== 'all' || filters.browser !== 'all' ||
96
- filters.model !== 'all' || filters.backend !== 'all' ||
97
- filters.status !== 'all' || filters.quants.size > 0;
98
- resetBtn.style.display = active ? '' : 'none';
 
 
 
 
 
99
  }
100
 
101
  // Tables
@@ -111,11 +122,74 @@ function render() {
111
 
112
  // CPU vs GPU comparison
113
  const metric = document.getElementById('cpu-gpu-metric')?.value || 'decode_tok_s';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  renderCpuGpuChart(filtered, metric);
115
  renderSpeedupChart(filtered, metric);
116
  renderCpuGpuTable(filtered);
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  function initSectionNav() {
120
  const nav = document.getElementById('section-nav');
121
  if (!nav) return;
@@ -123,6 +197,10 @@ function initSectionNav() {
123
  const buttons = nav.querySelectorAll('.section-nav-item');
124
  const sections = [];
125
 
 
 
 
 
126
  buttons.forEach(btn => {
127
  const sectionId = btn.dataset.section;
128
  const section = document.getElementById(sectionId);
@@ -131,29 +209,38 @@ function initSectionNav() {
131
  btn.addEventListener('click', (e) => {
132
  e.preventDefault();
133
  if (section) {
134
- const navHeight = nav.offsetHeight;
135
- const top = section.getBoundingClientRect().top + window.scrollY - navHeight;
136
  window.scrollTo({ top, behavior: 'smooth' });
137
  }
138
  });
139
  });
140
 
141
- // Scroll spy with IntersectionObserver
 
 
 
142
  if (sections.length === 0) return;
143
 
144
- const observer = new IntersectionObserver(
145
- (entries) => {
146
- for (const entry of entries) {
147
- if (entry.isIntersecting) {
148
- const id = entry.target.id;
149
- buttons.forEach(b => b.classList.toggle('active', b.dataset.section === id));
150
- }
151
- }
152
- },
153
- { rootMargin: '-20% 0px -60% 0px' }
154
- );
155
-
156
- sections.forEach(({ section }) => observer.observe(section));
 
 
 
 
 
 
157
  }
158
 
159
  init();
 
9
  try {
10
  appData = await loadData();
11
  } catch (e) {
12
+ const loading = document.getElementById('loading');
13
+ loading.className = 'loading-state';
14
+ loading.innerHTML = `
15
  <div class="loading-content">
16
  <p class="loading-error">Failed to load data</p>
17
  <p class="loading-hint">Run: <code>node scripts/build-site.js</code></p>
 
30
  // Populate quant options from actual data
31
  populateQuantOptions(appData.results);
32
 
33
+ // Surface the dataset's last-updated time so users know data freshness.
34
+ renderHeroMeta(appData);
35
+
36
  // Init filter dropdowns
37
  initFilters(appData.meta, () => render());
38
 
 
94
  : `${filtered.length} of ${total}`;
95
  }
96
 
97
+ // Reset button — always visible so users understand filters can be
98
+ // cleared; disabled when nothing to reset.
99
  const resetBtn = document.getElementById('filter-reset');
100
  if (resetBtn) {
101
+ const activeCount = (filters.machine !== 'all' ? 1 : 0) + (filters.browser !== 'all' ? 1 : 0) +
102
+ (filters.model !== 'all' ? 1 : 0) + (filters.backend !== 'all' ? 1 : 0) +
103
+ (filters.status !== 'all' ? 1 : 0) + (filters.quants.size > 0 ? 1 : 0);
104
+ resetBtn.disabled = activeCount === 0;
105
+ resetBtn.style.display = '';
106
+ const label = resetBtn.querySelector('.filter-reset-label') || resetBtn;
107
+ if (label !== resetBtn) {
108
+ label.textContent = activeCount ? `Reset (${activeCount})` : 'Reset';
109
+ }
110
  }
111
 
112
  // Tables
 
122
 
123
  // CPU vs GPU comparison
124
  const metric = document.getElementById('cpu-gpu-metric')?.value || 'decode_tok_s';
125
+ renderCpuGpuSection(filtered, metric);
126
+ }
127
+
128
+ /* Consolidate the 3-part CPU-vs-GPU block (two charts + table). When there
129
+ is no CPU baseline or no overlapping GPU data, render a single inline
130
+ empty state and hide the charts+table so the user doesn't see the same
131
+ message repeated three times. */
132
+ function renderCpuGpuSection(filtered, metric) {
133
+ const chartsGrid = document.querySelector('#performance-section .charts-grid:nth-of-type(2)');
134
+ const table = document.getElementById('cpu-gpu-table');
135
+ const passed = filtered.filter(r => r.status === 'done');
136
+ const cpuResults = passed.filter(r => r.nGpuLayers === 0);
137
+ const gpuResults = passed.filter(r => r.nGpuLayers !== 0);
138
+
139
+ if (!chartsGrid || !table) {
140
+ renderCpuGpuChart(filtered, metric);
141
+ renderSpeedupChart(filtered, metric);
142
+ renderCpuGpuTable(filtered);
143
+ return;
144
+ }
145
+
146
+ if (cpuResults.length === 0 || gpuResults.length === 0) {
147
+ chartsGrid.hidden = true;
148
+ const reason = cpuResults.length === 0
149
+ ? 'No CPU baseline in the current filter. Select "All Backends" or enable CPU baselines when benchmarking with <code>--consistency</code>.'
150
+ : 'No WebGPU runs in the current filter. Adjust the Backend filter to include WebGPU.';
151
+ table.innerHTML = `<div class="empty-state"><p>${reason}</p></div>`;
152
+ return;
153
+ }
154
+
155
+ chartsGrid.hidden = false;
156
  renderCpuGpuChart(filtered, metric);
157
  renderSpeedupChart(filtered, metric);
158
  renderCpuGpuTable(filtered);
159
  }
160
 
161
+ function renderHeroMeta(data) {
162
+ const el = document.getElementById('hero-meta');
163
+ if (!el) return;
164
+ const generated = data?.meta?.generatedAt;
165
+ const machineCount = data?.meta?.machines?.length || 0;
166
+ const resultCount = data?.results?.length || 0;
167
+ const parts = [];
168
+ if (machineCount > 0) parts.push(`${machineCount} machine${machineCount === 1 ? '' : 's'}`);
169
+ if (resultCount > 0) parts.push(`${resultCount} benchmark${resultCount === 1 ? '' : 's'}`);
170
+ if (generated) {
171
+ const d = new Date(generated);
172
+ const rel = formatRelativeTime(d);
173
+ parts.push(`updated ${rel}`);
174
+ }
175
+ if (parts.length === 0) return;
176
+ el.textContent = parts.join(' · ');
177
+ el.hidden = false;
178
+ if (generated) el.title = new Date(generated).toLocaleString();
179
+ }
180
+
181
+ function formatRelativeTime(date) {
182
+ const now = Date.now();
183
+ const diff = Math.max(0, now - date.getTime());
184
+ const min = 60_000, hr = 60 * min, day = 24 * hr;
185
+ if (diff < min) return 'just now';
186
+ if (diff < hr) return `${Math.floor(diff / min)} min ago`;
187
+ if (diff < day) return `${Math.floor(diff / hr)} h ago`;
188
+ const days = Math.floor(diff / day);
189
+ if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
190
+ return date.toISOString().slice(0, 10);
191
+ }
192
+
193
  function initSectionNav() {
194
  const nav = document.getElementById('section-nav');
195
  if (!nav) return;
 
197
  const buttons = nav.querySelectorAll('.section-nav-item');
198
  const sections = [];
199
 
200
+ // Prefer the sticky wrapper height so the jumped-to section isn't
201
+ // obscured by the sticky head.
202
+ const stickyHead = document.querySelector('.sticky-head') || nav;
203
+
204
  buttons.forEach(btn => {
205
  const sectionId = btn.dataset.section;
206
  const section = document.getElementById(sectionId);
 
209
  btn.addEventListener('click', (e) => {
210
  e.preventDefault();
211
  if (section) {
212
+ const offset = stickyHead.offsetHeight + 8;
213
+ const top = section.getBoundingClientRect().top + window.scrollY - offset;
214
  window.scrollTo({ top, behavior: 'smooth' });
215
  }
216
  });
217
  });
218
 
219
+ // Scroll spy: instead of IntersectionObserver (which fires inconsistently
220
+ // when multiple sections overlap the observer band), compute the
221
+ // currently-active section on scroll by comparing each section's top to
222
+ // the bottom of the sticky head. Cheaper and predictable.
223
  if (sections.length === 0) return;
224
 
225
+ let ticking = false;
226
+ const updateActive = () => {
227
+ const anchor = stickyHead.offsetHeight + 16;
228
+ let activeId = sections[0].section.id;
229
+ for (const { section } of sections) {
230
+ const top = section.getBoundingClientRect().top;
231
+ if (top - anchor <= 0) activeId = section.id;
232
+ else break;
233
+ }
234
+ buttons.forEach(b => b.classList.toggle('active', b.dataset.section === activeId));
235
+ };
236
+ const onScroll = () => {
237
+ if (ticking) return;
238
+ ticking = true;
239
+ requestAnimationFrame(() => { updateActive(); ticking = false; });
240
+ };
241
+ window.addEventListener('scroll', onScroll, { passive: true });
242
+ window.addEventListener('resize', onScroll);
243
+ updateActive();
244
  }
245
 
246
  init();
js/run/controller.js CHANGED
@@ -120,6 +120,15 @@ function flattenVariants(models) {
120
  return out;
121
  }
122
 
 
 
 
 
 
 
 
 
 
123
  function computeWarnings(modelName, quant) {
124
  const w = [];
125
  if (/^granite-4/i.test(modelName)) w.push('needs SSM_SCAN');
@@ -153,7 +162,13 @@ function renderHeader() {
153
 
154
  const badge = $('run-mode-badge');
155
  if (badge) {
156
- badge.textContent = state.surface;
 
 
 
 
 
 
157
  badge.className = `badge run-mode-badge run-mode-${state.surface}`;
158
  }
159
 
@@ -241,31 +256,55 @@ function renderModels() {
241
  const groups = groupByFamily(state.variants);
242
  for (const [family, variants] of groups) {
243
  const fitsCount = variants.filter(variantFitsDevice).length;
244
- const allFit = fitsCount === variants.length;
245
 
246
- const familyEl = document.createElement('details');
 
 
 
247
  familyEl.className = 'run-family card';
248
  familyEl.dataset.family = family;
249
- familyEl.open = true;
250
-
251
- const summary = document.createElement('summary');
252
- summary.className = 'run-family-summary';
253
- summary.innerHTML = `
254
- <span class="run-family-chevron" aria-hidden="true"></span>
255
- <input type="checkbox" class="run-family-select-all" data-family="${escapeAttr(family)}"${allFit ? ' checked' : ''}>
256
- <span class="run-family-name">${escapeText(family)}</span>
257
- <span class="run-family-stats">${variants.length} variants · ${fitsCount} fit</span>
258
- `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  if (/^granite-4/i.test(family)) {
260
  const w = document.createElement('span');
261
  w.className = 'run-family-warning';
262
  w.textContent = '⚠ needs SSM_SCAN in llama.cpp';
263
- summary.appendChild(w);
264
  }
265
- familyEl.appendChild(summary);
266
 
267
  const list = document.createElement('div');
268
  list.className = 'run-variant-list';
 
269
 
270
  for (const v of variants) {
271
  const row = document.createElement('label');
@@ -277,7 +316,7 @@ function renderModels() {
277
  cb.type = 'checkbox';
278
  cb.className = 'run-variant-select';
279
  cb.dataset.key = cacheKey(v);
280
- cb.checked = variantFitsDevice(v);
281
 
282
  const quant = document.createElement('span');
283
  quant.className = 'run-variant-quant';
@@ -300,9 +339,27 @@ function renderModels() {
300
  }
301
  familyEl.appendChild(list);
302
  panel.appendChild(familyEl);
 
 
303
  }
304
  }
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  function updateBadgesForVariant(badgesEl, v) {
307
  badgesEl.innerHTML = '';
308
  if (isCached(v)) badgesEl.appendChild(makeBadge('cached', 'badge--cached'));
@@ -346,19 +403,39 @@ function wireSelectionHandlers() {
346
  if (t.classList?.contains('run-family-select-all')) {
347
  const family = t.dataset.family;
348
  const rows = panel.querySelectorAll(
349
- `details.run-family[data-family="${cssEscape(family)}"] .run-variant-select`,
350
  );
351
- rows.forEach(cb => { cb.checked = t.checked; });
 
 
 
 
 
 
 
352
  updateButtons();
353
  } else if (t.classList?.contains('run-variant-select')) {
 
 
354
  updateButtons();
355
  }
356
  });
357
- // Prevent the select-all toggling expand/collapse.
358
  panel.addEventListener('click', (e) => {
359
- if (e.target.classList?.contains('run-family-select-all')) {
360
- e.stopPropagation();
 
 
361
  }
 
 
 
 
 
 
 
 
 
 
362
  });
363
  }
364
 
@@ -369,6 +446,28 @@ function wireFilters() {
369
  });
370
  }
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  function wireIterationsInput() {
373
  const el = $('iterations-input');
374
  if (!el) return;
@@ -390,6 +489,7 @@ function applyFilters() {
390
  const hideUd = $('hide-ud')?.checked;
391
  const hideIq = $('hide-iq')?.checked;
392
  const hideHifp = $('hide-hifp')?.checked;
 
393
  document.querySelectorAll('.run-variant-row').forEach(row => {
394
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
395
  if (!v) return;
@@ -398,7 +498,27 @@ function applyFilters() {
398
  const isHifp = /^(BF16|F16|bf16|f16)$/.test(v.quant);
399
  const hide = (hideUd && isUd) || (hideIq && isIq) || (hideHifp && isHifp);
400
  row.style.display = hide ? 'none' : '';
 
401
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  }
403
 
404
  function getCheckedVariants() {
@@ -415,9 +535,18 @@ function updateButtons() {
415
  const ab = $('btn-abort'); if (ab) { ab.disabled = !state.running; ab.hidden = !state.running; }
416
  const status = $('queue-status');
417
  if (status) {
418
- status.textContent = checked.length
419
- ? `${checked.length} selected · ${cachedChecked.length} cached`
420
- : '';
 
 
 
 
 
 
 
 
 
421
  }
422
  }
423
 
@@ -426,6 +555,13 @@ function updateButtons() {
426
  function ensureProgressTable() {
427
  const wrap = $('run-progress-wrapper');
428
  if (!wrap) return null;
 
 
 
 
 
 
 
429
  let table = wrap.querySelector('table');
430
  if (!table) {
431
  table = document.createElement('table');
@@ -832,6 +968,26 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
832
  function renderOutput() {
833
  const ta = $('output-textarea');
834
  if (ta) ta.value = generateMarkdown(state.results);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  }
836
 
837
  function generateMarkdown(results) {
@@ -1026,6 +1182,7 @@ export async function mountRunSection() {
1026
  renderModels();
1027
  wireSelectionHandlers();
1028
  wireFilters();
 
1029
  wireIterationsInput();
1030
  wireRunHandlers();
1031
  wireAbortHandler();
@@ -1033,6 +1190,8 @@ export async function mountRunSection() {
1033
  wireHubHandlers();
1034
  wireOutputHandlers();
1035
  updateButtons();
 
 
1036
  }
1037
 
1038
  export function teardownRunSection() {
 
120
  return out;
121
  }
122
 
123
+ function getQuickVariantSet() {
124
+ const list = state.models?.quickVariants;
125
+ return new Set(Array.isArray(list) && list.length ? list : ['Q2_K', 'Q4_K_M', 'Q8_0']);
126
+ }
127
+
128
+ function isQuickVariant(v) {
129
+ return getQuickVariantSet().has(v.quant);
130
+ }
131
+
132
  function computeWarnings(modelName, quant) {
133
  const w = [];
134
  if (/^granite-4/i.test(modelName)) w.push('needs SSM_SCAN');
 
162
 
163
  const badge = $('run-mode-badge');
164
  if (badge) {
165
+ const labels = {
166
+ localhost: 'Local dev',
167
+ space: 'Hosted · Hugging Face',
168
+ pages: 'Read-only preview',
169
+ file: 'Local file',
170
+ };
171
+ badge.textContent = labels[state.surface] || state.surface;
172
  badge.className = `badge run-mode-badge run-mode-${state.surface}`;
173
  }
174
 
 
256
  const groups = groupByFamily(state.variants);
257
  for (const [family, variants] of groups) {
258
  const fitsCount = variants.filter(variantFitsDevice).length;
259
+ const quickFitCount = variants.filter(v => isQuickVariant(v) && variantFitsDevice(v)).length;
260
 
261
+ // Card wrapper (not <details>, to avoid nested-interactive with the
262
+ // family-level checkbox). A dedicated toggle button expands/collapses
263
+ // the variant list.
264
+ const familyEl = document.createElement('section');
265
  familyEl.className = 'run-family card';
266
  familyEl.dataset.family = family;
267
+
268
+ const header = document.createElement('div');
269
+ header.className = 'run-family-summary';
270
+
271
+ const toggleBtn = document.createElement('button');
272
+ toggleBtn.type = 'button';
273
+ toggleBtn.className = 'run-family-toggle';
274
+ toggleBtn.setAttribute('aria-expanded', 'false');
275
+ toggleBtn.setAttribute('aria-label', `Expand ${family}`);
276
+ toggleBtn.innerHTML = '<span class="run-family-chevron" aria-hidden="true"></span>';
277
+
278
+ const selectAllId = `run-family-all-${family.replace(/[^a-z0-9]/gi, '-')}`;
279
+ const selectAll = document.createElement('input');
280
+ selectAll.type = 'checkbox';
281
+ selectAll.className = 'run-family-select-all';
282
+ selectAll.dataset.family = family;
283
+ selectAll.id = selectAllId;
284
+ selectAll.setAttribute('aria-label', `Select all variants in ${family}`);
285
+
286
+ const nameLabel = document.createElement('label');
287
+ nameLabel.className = 'run-family-name';
288
+ nameLabel.htmlFor = selectAllId;
289
+ nameLabel.textContent = family;
290
+
291
+ const stats = document.createElement('span');
292
+ stats.className = 'run-family-stats';
293
+ stats.textContent = `${variants.length} variants · ${fitsCount} fit · ${quickFitCount} quick`;
294
+
295
+ header.append(toggleBtn, selectAll, nameLabel, stats);
296
+
297
  if (/^granite-4/i.test(family)) {
298
  const w = document.createElement('span');
299
  w.className = 'run-family-warning';
300
  w.textContent = '⚠ needs SSM_SCAN in llama.cpp';
301
+ header.appendChild(w);
302
  }
303
+ familyEl.appendChild(header);
304
 
305
  const list = document.createElement('div');
306
  list.className = 'run-variant-list';
307
+ list.hidden = true;
308
 
309
  for (const v of variants) {
310
  const row = document.createElement('label');
 
316
  cb.type = 'checkbox';
317
  cb.className = 'run-variant-select';
318
  cb.dataset.key = cacheKey(v);
319
+ cb.checked = isQuickVariant(v) && variantFitsDevice(v);
320
 
321
  const quant = document.createElement('span');
322
  quant.className = 'run-variant-quant';
 
339
  }
340
  familyEl.appendChild(list);
341
  panel.appendChild(familyEl);
342
+
343
+ updateFamilySelectAllState(family);
344
  }
345
  }
346
 
347
+ function updateFamilySelectAllState(family) {
348
+ const panel = $('run-models');
349
+ if (!panel) return;
350
+ const familyEl = panel.querySelector(
351
+ `.run-family[data-family="${cssEscape(family)}"]`,
352
+ );
353
+ if (!familyEl) return;
354
+ const rows = familyEl.querySelectorAll('.run-variant-select');
355
+ const all = rows.length;
356
+ const checked = [...rows].filter(cb => cb.checked).length;
357
+ const selectAll = familyEl.querySelector('.run-family-select-all');
358
+ if (!selectAll) return;
359
+ selectAll.checked = checked === all && all > 0;
360
+ selectAll.indeterminate = checked > 0 && checked < all;
361
+ }
362
+
363
  function updateBadgesForVariant(badgesEl, v) {
364
  badgesEl.innerHTML = '';
365
  if (isCached(v)) badgesEl.appendChild(makeBadge('cached', 'badge--cached'));
 
403
  if (t.classList?.contains('run-family-select-all')) {
404
  const family = t.dataset.family;
405
  const rows = panel.querySelectorAll(
406
+ `.run-family[data-family="${cssEscape(family)}"] .run-variant-row`,
407
  );
408
+ // Only affect fit variants checking non-fit can cause OOM on the
409
+ // user's device, which is actively dangerous.
410
+ rows.forEach(row => {
411
+ if (row.classList.contains('is-non-fit')) return;
412
+ const cb = row.querySelector('.run-variant-select');
413
+ if (cb) cb.checked = t.checked;
414
+ });
415
+ updateFamilySelectAllState(family);
416
  updateButtons();
417
  } else if (t.classList?.contains('run-variant-select')) {
418
+ const familyEl = t.closest('.run-family');
419
+ if (familyEl) updateFamilySelectAllState(familyEl.dataset.family);
420
  updateButtons();
421
  }
422
  });
 
423
  panel.addEventListener('click', (e) => {
424
+ // Clicks on the select-all checkbox or name label must not toggle
425
+ // expansion — they have their own semantics.
426
+ if (e.target.closest('.run-family-select-all, .run-family-name, .run-variant-list, .run-variant-row')) {
427
+ return;
428
  }
429
+ const header = e.target.closest?.('.run-family-summary');
430
+ if (!header) return;
431
+ const familyEl = header.closest('.run-family');
432
+ const list = familyEl?.querySelector('.run-variant-list');
433
+ const toggle = familyEl?.querySelector('.run-family-toggle');
434
+ if (!list || !toggle) return;
435
+ const expanded = !list.hidden;
436
+ list.hidden = expanded;
437
+ toggle.setAttribute('aria-expanded', String(!expanded));
438
+ familyEl.classList.toggle('is-open', !expanded);
439
  });
440
  }
441
 
 
446
  });
447
  }
448
 
449
+ function wireBatchSelect() {
450
+ const apply = (pred) => {
451
+ document.querySelectorAll('.run-variant-select').forEach(cb => {
452
+ const v = state.variants.find(x => cacheKey(x) === cb.dataset.key);
453
+ cb.checked = pred(v);
454
+ });
455
+ document.querySelectorAll('.run-family').forEach(el => {
456
+ if (el.dataset.family) updateFamilySelectAllState(el.dataset.family);
457
+ });
458
+ updateButtons();
459
+ };
460
+ $('btn-select-quick')?.addEventListener('click', () => {
461
+ apply(v => !!v && isQuickVariant(v) && variantFitsDevice(v));
462
+ });
463
+ $('btn-select-fit')?.addEventListener('click', () => {
464
+ apply(v => !!v && variantFitsDevice(v));
465
+ });
466
+ $('btn-select-none')?.addEventListener('click', () => {
467
+ apply(() => false);
468
+ });
469
+ }
470
+
471
  function wireIterationsInput() {
472
  const el = $('iterations-input');
473
  if (!el) return;
 
489
  const hideUd = $('hide-ud')?.checked;
490
  const hideIq = $('hide-iq')?.checked;
491
  const hideHifp = $('hide-hifp')?.checked;
492
+ const hiddenByFamily = new Map();
493
  document.querySelectorAll('.run-variant-row').forEach(row => {
494
  const v = state.variants.find(x => cacheKey(x) === row.dataset.key);
495
  if (!v) return;
 
498
  const isHifp = /^(BF16|F16|bf16|f16)$/.test(v.quant);
499
  const hide = (hideUd && isUd) || (hideIq && isIq) || (hideHifp && isHifp);
500
  row.style.display = hide ? 'none' : '';
501
+ if (hide) hiddenByFamily.set(v.modelName, (hiddenByFamily.get(v.modelName) || 0) + 1);
502
  });
503
+ // Refresh the per-family stats line so users see hidden filter impact.
504
+ document.querySelectorAll('.run-family').forEach(familyEl => {
505
+ const family = familyEl.dataset.family;
506
+ const all = [...familyEl.querySelectorAll('.run-variant-row')];
507
+ const visible = all.filter(r => r.style.display !== 'none').length;
508
+ const fit = all.filter(r => !r.classList.contains('is-non-fit') && r.style.display !== 'none').length;
509
+ const quick = all.filter(r => {
510
+ if (r.style.display === 'none' || r.classList.contains('is-non-fit')) return false;
511
+ const v = state.variants.find(x => cacheKey(x) === r.dataset.key);
512
+ return v && isQuickVariant(v);
513
+ }).length;
514
+ const stats = familyEl.querySelector('.run-family-stats');
515
+ if (!stats) return;
516
+ const hiddenCount = hiddenByFamily.get(family) || 0;
517
+ const base = `${visible} variants · ${fit} fit · ${quick} quick`;
518
+ stats.textContent = hiddenCount > 0 ? `${base} · ${hiddenCount} hidden` : base;
519
+ });
520
+ // A selected-but-now-hidden variant is a footgun; re-count the queue.
521
+ updateButtons();
522
  }
523
 
524
  function getCheckedVariants() {
 
535
  const ab = $('btn-abort'); if (ab) { ab.disabled = !state.running; ab.hidden = !state.running; }
536
  const status = $('queue-status');
537
  if (status) {
538
+ if (checked.length === 0) {
539
+ status.textContent = '';
540
+ } else {
541
+ const toDownload = checked.filter(v => !isCached(v));
542
+ const dlMB = toDownload.reduce((a, v) => a + (v.sizeMB || 0), 0);
543
+ const parts = [
544
+ `${checked.length} selected`,
545
+ `${cachedChecked.length} cached`,
546
+ ];
547
+ if (dlMB > 0) parts.push(`~${formatSize(dlMB)} to download`);
548
+ status.textContent = parts.join(' · ');
549
+ }
550
  }
551
  }
552
 
 
555
  function ensureProgressTable() {
556
  const wrap = $('run-progress-wrapper');
557
  if (!wrap) return null;
558
+ // Reveal the progress card + its header — they are hidden by default on
559
+ // mount so the user doesn't see an empty "Progress" scaffold, but we must
560
+ // un-hide them as soon as the first row (download or run) appears.
561
+ const card = wrap.closest('.table-card');
562
+ if (card) card.hidden = false;
563
+ const header = card?.previousElementSibling;
564
+ if (header?.classList?.contains('section-header')) header.hidden = false;
565
  let table = wrap.querySelector('table');
566
  if (!table) {
567
  table = document.createElement('table');
 
968
  function renderOutput() {
969
  const ta = $('output-textarea');
970
  if (ta) ta.value = generateMarkdown(state.results);
971
+ // Reflect emptiness: collapse the textarea, disable copy/download.
972
+ const hasContent = !!ta?.value;
973
+ const outputCard = document.querySelector('.run-output');
974
+ if (outputCard) outputCard.classList.toggle('is-empty', !hasContent);
975
+ const copyBtn = $('btn-copy');
976
+ const dlJson = $('btn-download-json');
977
+ if (copyBtn) copyBtn.disabled = !hasContent;
978
+ if (dlJson) dlJson.disabled = !hasContent;
979
+ }
980
+
981
+ /* Hide the Progress scaffolding at mount so we don't show an empty
982
+ placeholder. `ensureProgressTable` un-hides it the moment a download or
983
+ run row appears. */
984
+ function hideProgressUntilFirstRow() {
985
+ const wrap = $('run-progress-wrapper');
986
+ if (!wrap) return;
987
+ const card = wrap.closest('.table-card');
988
+ if (card) card.hidden = true;
989
+ const header = card?.previousElementSibling;
990
+ if (header?.classList?.contains('section-header')) header.hidden = true;
991
  }
992
 
993
  function generateMarkdown(results) {
 
1182
  renderModels();
1183
  wireSelectionHandlers();
1184
  wireFilters();
1185
+ wireBatchSelect();
1186
  wireIterationsInput();
1187
  wireRunHandlers();
1188
  wireAbortHandler();
 
1190
  wireHubHandlers();
1191
  wireOutputHandlers();
1192
  updateButtons();
1193
+ renderOutput();
1194
+ hideProgressUntilFirstRow();
1195
  }
1196
 
1197
  export function teardownRunSection() {
js/tables.js CHANGED
@@ -45,48 +45,60 @@ export function renderResultsTable(results) {
45
  if (!container) return;
46
 
47
  if (results.length === 0) {
48
- container.innerHTML = '<div class="empty-state"><p>No results match the current filters.</p></div>';
 
 
 
 
49
  return;
50
  }
51
 
52
  const sorted = sortState.key ? sortResults(results, sortState.key, sortState.dir) : results;
53
 
 
54
  const cols = [
55
- { key: 'machineSlug', label: 'Machine' },
56
- { key: 'model', label: 'Model' },
57
- { key: 'variant', label: 'Quant' },
58
- { key: 'sizeMB', label: 'Size (MB)' },
59
- { key: 'browser', label: 'Browser' },
60
- { key: 'nGpuLayers', label: 'Backend' },
61
- { key: 'status', label: 'Status' },
62
- { key: 'buildType', label: 'Build' },
63
- { key: 'webgpuAvailable', label: 'WebGPU' },
64
- { key: 'decode_tok_s', label: 'Decode tok/s' },
65
- { key: 'prefill_tok_s', label: 'Prefill tok/s' },
66
- { key: 'n_eval', label: 'n_eval' },
67
- { key: 't_eval_ms', label: 't_eval (ms)' },
68
- { key: 'n_p_eval', label: 'n_p_eval' },
69
- { key: 't_p_eval_ms', label: 't_p_eval (ms)' },
70
- { key: 'wallTimeMs', label: 'Wall (s)' },
71
- { key: 'consistency_rate', label: 'CPU Match' },
72
- { key: 'llamaCppCommit', label: 'llama.cpp' },
73
- { key: 'error', label: 'Error' },
74
  ];
75
 
76
  let html = '<table class="results-table"><thead><tr>';
77
- for (const col of cols) {
78
  const isActive = sortState.key === col.key;
79
- const arrow = isActive ? (sortState.dir === 'asc' ? ' \u2191' : ' \u2193') : '';
80
- const cls = isActive ? ' class="sorted"' : '';
81
- html += `<th data-key="${col.key}"${cls}>${col.label}${arrow}</th>`;
82
- }
 
 
 
83
  html += '</tr></thead><tbody>';
84
 
85
  for (const r of sorted) {
86
  const rowClass = r.status === 'done' ? 'row-pass' : 'row-fail';
87
  html += `<tr class="${rowClass}">`;
88
- for (const col of cols) {
89
- html += '<td>';
 
 
 
 
90
  switch (col.key) {
91
  case 'status':
92
  html += r.status === 'done'
@@ -153,16 +165,22 @@ export function renderResultsTable(results) {
153
  html += escapeHtml(String(r[col.key] ?? '\u2014'));
154
  }
155
  html += '</td>';
156
- }
157
  html += '</tr>';
158
  }
159
 
160
  html += '</tbody></table>';
161
  container.innerHTML = html;
162
 
163
- // Wire sort click handlers
164
  container.querySelectorAll('th[data-key]').forEach(th => {
165
  th.addEventListener('click', () => handleSort(th.dataset.key));
 
 
 
 
 
 
166
  });
167
  }
168
 
@@ -172,7 +190,11 @@ export function renderErrorTable(results) {
172
 
173
  const errors = results.filter(r => r.status !== 'done' && r.error);
174
  if (errors.length === 0) {
175
- container.innerHTML = '<div class="empty-state"><p>No errors found.</p></div>';
 
 
 
 
176
  return;
177
  }
178
 
@@ -192,8 +214,18 @@ export function renderMachineInfo(machines) {
192
  const container = document.getElementById('machine-info');
193
  if (!container) return;
194
 
 
 
 
 
 
 
 
 
 
 
195
  if (machines.length === 0) {
196
- container.innerHTML = '<div class="empty-state"><p>No machine data.</p></div>';
197
  return;
198
  }
199
 
@@ -217,6 +249,7 @@ export function renderMachineInfo(machines) {
217
  </div>
218
  </div>`;
219
  }
 
220
  html += '</div>';
221
  container.innerHTML = html;
222
  }
 
45
  if (!container) return;
46
 
47
  if (results.length === 0) {
48
+ container.innerHTML = `
49
+ <div class="empty-state">
50
+ <p>No results match the current filters.</p>
51
+ <p class="empty-state-sub">Try resetting filters above, or <a href="run.html">run the benchmark</a> on your own machine to contribute data.</p>
52
+ </div>`;
53
  return;
54
  }
55
 
56
  const sorted = sortState.key ? sortResults(results, sortState.key, sortState.dir) : results;
57
 
58
+ /* priority: 1 = always show; 2 = hide below 640px; 3 = hide below 900px */
59
  const cols = [
60
+ { key: 'machineSlug', label: 'Machine', priority: 1 },
61
+ { key: 'model', label: 'Model', priority: 1 },
62
+ { key: 'variant', label: 'Quant', priority: 1 },
63
+ { key: 'sizeMB', label: 'Size (MB)', priority: 3 },
64
+ { key: 'browser', label: 'Browser', priority: 2 },
65
+ { key: 'nGpuLayers', label: 'Backend', priority: 2 },
66
+ { key: 'status', label: 'Status', priority: 1 },
67
+ { key: 'buildType', label: 'Build', priority: 3 },
68
+ { key: 'webgpuAvailable', label: 'WebGPU', priority: 3 },
69
+ { key: 'decode_tok_s', label: 'Decode tok/s', priority: 1 },
70
+ { key: 'prefill_tok_s', label: 'Prefill tok/s', priority: 3 },
71
+ { key: 'n_eval', label: 'n_eval', priority: 3 },
72
+ { key: 't_eval_ms', label: 't_eval (ms)', priority: 3 },
73
+ { key: 'n_p_eval', label: 'n_p_eval', priority: 3 },
74
+ { key: 't_p_eval_ms', label: 't_p_eval (ms)', priority: 3 },
75
+ { key: 'wallTimeMs', label: 'Wall (s)', priority: 3 },
76
+ { key: 'consistency_rate', label: 'CPU Match', priority: 2 },
77
+ { key: 'llamaCppCommit', label: 'llama.cpp', priority: 3 },
78
+ { key: 'error', label: 'Error', priority: 2 },
79
  ];
80
 
81
  let html = '<table class="results-table"><thead><tr>';
82
+ cols.forEach((col, i) => {
83
  const isActive = sortState.key === col.key;
84
+ const ariaSort = isActive ? (sortState.dir === 'asc' ? 'ascending' : 'descending') : 'none';
85
+ const arrowChar = isActive ? (sortState.dir === 'asc' ? '\u2191' : '\u2193') : '\u2195';
86
+ const pin = i === 0 ? ' col-pin col-pin-1' : (i === 1 ? ' col-pin col-pin-2' : '');
87
+ const prio = col.priority >= 3 ? ' col-p3' : (col.priority === 2 ? ' col-p2' : '');
88
+ const cls = `sortable${isActive ? ' sorted' : ''}${pin}${prio}`;
89
+ html += `<th data-key="${col.key}" class="${cls}" aria-sort="${ariaSort}" scope="col" tabindex="0"><span class="th-label">${col.label}</span><span class="th-sort-indicator" aria-hidden="true">${arrowChar}</span></th>`;
90
+ });
91
  html += '</tr></thead><tbody>';
92
 
93
  for (const r of sorted) {
94
  const rowClass = r.status === 'done' ? 'row-pass' : 'row-fail';
95
  html += `<tr class="${rowClass}">`;
96
+ cols.forEach((col, i) => {
97
+ const pin = i === 0 ? 'col-pin col-pin-1' : (i === 1 ? 'col-pin col-pin-2' : '');
98
+ const prio = col.priority >= 3 ? 'col-p3' : (col.priority === 2 ? 'col-p2' : '');
99
+ const parts = [pin, prio].filter(Boolean);
100
+ const cls = parts.length ? ` class="${parts.join(' ')}"` : '';
101
+ html += `<td${cls}>`;
102
  switch (col.key) {
103
  case 'status':
104
  html += r.status === 'done'
 
165
  html += escapeHtml(String(r[col.key] ?? '\u2014'));
166
  }
167
  html += '</td>';
168
+ });
169
  html += '</tr>';
170
  }
171
 
172
  html += '</tbody></table>';
173
  container.innerHTML = html;
174
 
175
+ // Wire sort click + keyboard handlers
176
  container.querySelectorAll('th[data-key]').forEach(th => {
177
  th.addEventListener('click', () => handleSort(th.dataset.key));
178
+ th.addEventListener('keydown', (e) => {
179
+ if (e.key === 'Enter' || e.key === ' ') {
180
+ e.preventDefault();
181
+ handleSort(th.dataset.key);
182
+ }
183
+ });
184
  });
185
  }
186
 
 
190
 
191
  const errors = results.filter(r => r.status !== 'done' && r.error);
192
  if (errors.length === 0) {
193
+ container.innerHTML = `
194
+ <div class="empty-state">
195
+ <p>No errors in the current filter.</p>
196
+ <p class="empty-state-sub">Either every benchmark passed, or no results are in scope — try widening the filter.</p>
197
+ </div>`;
198
  return;
199
  }
200
 
 
214
  const container = document.getElementById('machine-info');
215
  if (!container) return;
216
 
217
+ const addYourMachineCard = `
218
+ <a class="machine-card machine-card-add" href="run.html">
219
+ <div class="machine-card-header">
220
+ <svg class="machine-card-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
221
+ <h3>Add your machine</h3>
222
+ </div>
223
+ <p class="machine-card-add-blurb">Run the benchmark in your browser and contribute results to the leaderboard.</p>
224
+ <span class="machine-card-add-cta">Open Run page →</span>
225
+ </a>`;
226
+
227
  if (machines.length === 0) {
228
+ container.innerHTML = `<div class="machine-grid">${addYourMachineCard}</div>`;
229
  return;
230
  }
231
 
 
249
  </div>
250
  </div>`;
251
  }
252
+ html += addYourMachineCard;
253
  html += '</div>';
254
  container.innerHTML = html;
255
  }
methodology.html CHANGED
@@ -1,9 +1,10 @@
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
- <meta name="color-scheme" content="dark">
 
7
  <title>Methodology — WebGPU Bench</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -17,20 +18,48 @@
17
  <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
18
  <span class="header-title">WebGPU Bench</span>
19
  </a>
20
- <nav class="header-nav">
21
  <a href="index.html" class="header-link">Dashboard</a>
22
  <a href="run.html" class="header-link">Run</a>
 
 
 
 
 
 
 
 
23
  </nav>
24
  </div>
25
  </header>
26
 
27
- <div class="methodology-content">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  <a href="index.html" class="back-link">
29
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
30
  Back to Dashboard
31
  </a>
32
 
33
- <h2>How Benchmarks Work</h2>
 
 
34
  <ol>
35
  <li><code>build.sh</code> compiles llama.cpp to WebAssembly with WebGPU support via Emscripten + emdawnwebgpu, producing two WASM variants: JSPI (Chrome) and Asyncify (Firefox, Safari).</li>
36
  <li><code>runner.js</code> launches Playwright browsers and navigates to <code>harness.html</code>.</li>
@@ -41,7 +70,7 @@
41
  <li>A fresh browser instance is launched for each variant to prevent WASM memory accumulation (OOM fix).</li>
42
  </ol>
43
 
44
- <h2>Dashboard Columns</h2>
45
  <table>
46
  <thead>
47
  <tr><th>Column</th><th>Description</th></tr>
@@ -67,7 +96,7 @@
67
  </tbody>
68
  </table>
69
 
70
- <h2>Error Categories</h2>
71
  <table>
72
  <thead>
73
  <tr><th>Category</th><th>Pattern</th><th>Typical Cause</th></tr>
@@ -81,20 +110,20 @@
81
  </tbody>
82
  </table>
83
 
84
- <h2>Consistency Measurement</h2>
85
  <p>The <code>--consistency</code> flag measures how faithfully the WebGPU backend reproduces the CPU computation for each quantization type.</p>
86
 
87
- <h3>How it works</h3>
88
  <p>For each variant, two runs are performed:</p>
89
  <ol>
90
  <li><strong>CPU baseline</strong> (<code>n_gpu_layers=0</code>): greedy-decodes 128 tokens and records the token ID sequence. Cached to <code>results/cpu_baselines.json</code>. When testing multiple browsers, the baseline is collected once on the first browser and shared across all browsers (CPU output is identical regardless of JSPI vs Asyncify). When testing a single browser, the baseline runs in that same browser.</li>
91
  <li><strong>WebGPU run</strong> (<code>n_gpu_layers=999</code>): performs a forced-decoding pass — feeds the CPU's token sequence one token at a time and checks whether the WebGPU backend independently predicts the same top-1 token at each position.</li>
92
  </ol>
93
 
94
- <h3>Why forced decoding</h3>
95
  <p>Naively comparing generated text suffers from cascading divergence: a single token difference changes the KV cache context for all subsequent tokens. Forced decoding evaluates each position independently, giving a clean per-token accuracy signal.</p>
96
 
97
- <h3>Interpreting CPU Match</h3>
98
  <table>
99
  <thead>
100
  <tr><th>CPU Match</th><th>Interpretation</th></tr>
@@ -107,6 +136,40 @@
107
  <tr><td><code>—</code></td><td>No consistency data — benchmarks were run without <code>--consistency</code></td></tr>
108
  </tbody>
109
  </table>
110
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  </body>
112
  </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="color-scheme" content="light dark">
7
+ <script>(function(){var s=localStorage.getItem('theme');if(!s){s=(window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}document.documentElement.setAttribute('data-theme',s);})();</script>
8
  <title>Methodology — WebGPU Bench</title>
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
18
  <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
19
  <span class="header-title">WebGPU Bench</span>
20
  </a>
21
+ <nav class="header-nav" aria-label="Primary">
22
  <a href="index.html" class="header-link">Dashboard</a>
23
  <a href="run.html" class="header-link">Run</a>
24
+ <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
25
+ <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
26
+ <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
27
+ </button>
28
+ <a href="https://github.com/abhijitramesh/webgpu-bench" target="_blank" rel="noopener" class="header-link">
29
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.39.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.82.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
30
+ GitHub
31
+ </a>
32
  </nav>
33
  </div>
34
  </header>
35
 
36
+ <main class="methodology-layout">
37
+ <nav class="methodology-toc" aria-label="Table of contents">
38
+ <p class="methodology-toc-title">On this page</p>
39
+ <ol>
40
+ <li><a href="#how-benchmarks-work">How Benchmarks Work</a></li>
41
+ <li><a href="#dashboard-columns">Dashboard Columns</a></li>
42
+ <li><a href="#error-categories">Error Categories</a></li>
43
+ <li>
44
+ <a href="#consistency-measurement">Consistency Measurement</a>
45
+ <ol>
46
+ <li><a href="#how-it-works">How it works</a></li>
47
+ <li><a href="#why-forced-decoding">Why forced decoding</a></li>
48
+ <li><a href="#interpreting-cpu-match">Interpreting CPU Match</a></li>
49
+ </ol>
50
+ </li>
51
+ </ol>
52
+ </nav>
53
+
54
+ <div class="methodology-content">
55
  <a href="index.html" class="back-link">
56
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
57
  Back to Dashboard
58
  </a>
59
 
60
+ <h1>Methodology</h1>
61
+
62
+ <h2 id="how-benchmarks-work">How Benchmarks Work</h2>
63
  <ol>
64
  <li><code>build.sh</code> compiles llama.cpp to WebAssembly with WebGPU support via Emscripten + emdawnwebgpu, producing two WASM variants: JSPI (Chrome) and Asyncify (Firefox, Safari).</li>
65
  <li><code>runner.js</code> launches Playwright browsers and navigates to <code>harness.html</code>.</li>
 
70
  <li>A fresh browser instance is launched for each variant to prevent WASM memory accumulation (OOM fix).</li>
71
  </ol>
72
 
73
+ <h2 id="dashboard-columns">Dashboard Columns</h2>
74
  <table>
75
  <thead>
76
  <tr><th>Column</th><th>Description</th></tr>
 
96
  </tbody>
97
  </table>
98
 
99
+ <h2 id="error-categories">Error Categories</h2>
100
  <table>
101
  <thead>
102
  <tr><th>Category</th><th>Pattern</th><th>Typical Cause</th></tr>
 
110
  </tbody>
111
  </table>
112
 
113
+ <h2 id="consistency-measurement">Consistency Measurement</h2>
114
  <p>The <code>--consistency</code> flag measures how faithfully the WebGPU backend reproduces the CPU computation for each quantization type.</p>
115
 
116
+ <h3 id="how-it-works">How it works</h3>
117
  <p>For each variant, two runs are performed:</p>
118
  <ol>
119
  <li><strong>CPU baseline</strong> (<code>n_gpu_layers=0</code>): greedy-decodes 128 tokens and records the token ID sequence. Cached to <code>results/cpu_baselines.json</code>. When testing multiple browsers, the baseline is collected once on the first browser and shared across all browsers (CPU output is identical regardless of JSPI vs Asyncify). When testing a single browser, the baseline runs in that same browser.</li>
120
  <li><strong>WebGPU run</strong> (<code>n_gpu_layers=999</code>): performs a forced-decoding pass — feeds the CPU's token sequence one token at a time and checks whether the WebGPU backend independently predicts the same top-1 token at each position.</li>
121
  </ol>
122
 
123
+ <h3 id="why-forced-decoding">Why forced decoding</h3>
124
  <p>Naively comparing generated text suffers from cascading divergence: a single token difference changes the KV cache context for all subsequent tokens. Forced decoding evaluates each position independently, giving a clean per-token accuracy signal.</p>
125
 
126
+ <h3 id="interpreting-cpu-match">Interpreting CPU Match</h3>
127
  <table>
128
  <thead>
129
  <tr><th>CPU Match</th><th>Interpretation</th></tr>
 
136
  <tr><td><code>—</code></td><td>No consistency data — benchmarks were run without <code>--consistency</code></td></tr>
137
  </tbody>
138
  </table>
139
+ </div>
140
+ </main>
141
+
142
+ <script>
143
+ document.getElementById('theme-toggle')?.addEventListener('click', () => {
144
+ const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
145
+ document.documentElement.setAttribute('data-theme', next);
146
+ localStorage.setItem('theme', next);
147
+ });
148
+
149
+ // ToC scroll-spy: mark active link based on the topmost visible heading.
150
+ (function() {
151
+ const links = [...document.querySelectorAll('.methodology-toc a[href^="#"]')];
152
+ const targets = links
153
+ .map(a => ({ a, el: document.getElementById(a.getAttribute('href').slice(1)) }))
154
+ .filter(x => x.el);
155
+ if (targets.length === 0) return;
156
+ let ticking = false;
157
+ function update() {
158
+ const anchor = 120;
159
+ let active = targets[0].a;
160
+ for (const t of targets) {
161
+ if (t.el.getBoundingClientRect().top - anchor <= 0) active = t.a;
162
+ else break;
163
+ }
164
+ links.forEach(l => l.classList.toggle('active', l === active));
165
+ }
166
+ window.addEventListener('scroll', () => {
167
+ if (ticking) return;
168
+ ticking = true;
169
+ requestAnimationFrame(() => { update(); ticking = false; });
170
+ }, { passive: true });
171
+ update();
172
+ })();
173
+ </script>
174
  </body>
175
  </html>
run.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <meta name="color-scheme" content="light dark">
7
- <script>(function(){var t=localStorage.getItem('theme')||'light';document.documentElement.setAttribute('data-theme',t);})();</script>
8
  <title>Run — WebGPU Bench</title>
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -27,7 +27,7 @@
27
  <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
28
  <span class="header-title">WebGPU Bench</span>
29
  </a>
30
- <nav class="header-nav">
31
  <a href="index.html" class="header-link">Dashboard</a>
32
  <a href="methodology.html" class="header-link">Methodology</a>
33
  <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
@@ -45,8 +45,8 @@
45
  <main>
46
  <section id="run-section" class="dash-section">
47
  <div class="container">
48
- <div class="section-header">
49
- <h2>Run a benchmark</h2>
50
  <span id="run-mode-badge" class="badge run-mode-badge">…</span>
51
  </div>
52
 
@@ -96,7 +96,7 @@
96
  </div>
97
 
98
  <!-- Hide filters, iterations, actions -->
99
- <div class="filter-bar">
100
  <div class="filter-bar-inner run-filters">
101
  <div class="filter-group">
102
  <span class="filter-label">Hide</span>
@@ -106,12 +106,26 @@
106
  <label class="run-hide-label"><input type="checkbox" id="hide-hifp"> BF16/F16</label>
107
  </div>
108
  </div>
 
 
 
 
 
 
 
 
109
  <div class="filter-group">
110
  <label class="filter-label" for="iterations-input">Iterations</label>
111
  <input type="number" id="iterations-input" class="filter-select run-iter-input" value="5" min="1" max="50" step="1">
112
  </div>
 
 
 
 
 
 
 
113
  <div class="run-actions">
114
- <span id="queue-status"></span>
115
  <button class="btn btn-secondary" id="btn-download" type="button" disabled>Download selected</button>
116
  <button class="btn btn-primary" id="btn-run" type="button" disabled>Run benchmarks</button>
117
  <button class="btn btn-danger" id="btn-abort" type="button" hidden>Abort</button>
@@ -127,7 +141,7 @@
127
 
128
  <!-- Progress -->
129
  <div class="section-header">
130
- <h3 class="subsection-title">Progress</h3>
131
  </div>
132
  <div class="table-card">
133
  <div id="run-progress-wrapper" class="results-wrapper"></div>
@@ -135,14 +149,14 @@
135
 
136
  <!-- Output -->
137
  <div class="section-header" style="margin-top: 32px;">
138
- <h3 class="subsection-title">Output</h3>
139
  </div>
140
  <div class="card run-output">
141
  <label id="save-local-row" class="run-output-toggle" hidden>
142
  <input type="checkbox" id="save-local" checked>
143
  Save to <code>results/results.json</code> on this server
144
  </label>
145
- <textarea id="output-textarea" class="run-output-textarea" readonly spellcheck="false"></textarea>
146
  <div class="run-output-buttons">
147
  <button class="btn btn-secondary" id="btn-copy" type="button">Copy</button>
148
  <button class="btn btn-secondary" id="btn-download-json" type="button">Download JSON</button>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <meta name="color-scheme" content="light dark">
7
+ <script>(function(){var s=localStorage.getItem('theme');if(!s){s=(window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}document.documentElement.setAttribute('data-theme',s);})();</script>
8
  <title>Run — WebGPU Bench</title>
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
27
  <svg class="header-logo" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
28
  <span class="header-title">WebGPU Bench</span>
29
  </a>
30
+ <nav class="header-nav" aria-label="Primary">
31
  <a href="index.html" class="header-link">Dashboard</a>
32
  <a href="methodology.html" class="header-link">Methodology</a>
33
  <button id="theme-toggle" class="header-link theme-toggle-btn" type="button" title="Toggle theme">
 
45
  <main>
46
  <section id="run-section" class="dash-section">
47
  <div class="container">
48
+ <div class="run-hero">
49
+ <h1 class="run-hero-title">Run a benchmark</h1>
50
  <span id="run-mode-badge" class="badge run-mode-badge">…</span>
51
  </div>
52
 
 
96
  </div>
97
 
98
  <!-- Hide filters, iterations, actions -->
99
+ <div class="filter-bar run-controls">
100
  <div class="filter-bar-inner run-filters">
101
  <div class="filter-group">
102
  <span class="filter-label">Hide</span>
 
106
  <label class="run-hide-label"><input type="checkbox" id="hide-hifp"> BF16/F16</label>
107
  </div>
108
  </div>
109
+ <div class="filter-group">
110
+ <span class="filter-label">Select</span>
111
+ <div class="run-filters-checks">
112
+ <button class="btn btn-secondary btn-xs" id="btn-select-quick" type="button">Quick set</button>
113
+ <button class="btn btn-secondary btn-xs" id="btn-select-fit" type="button">All fit</button>
114
+ <button class="btn btn-secondary btn-xs" id="btn-select-none" type="button">None</button>
115
+ </div>
116
+ </div>
117
  <div class="filter-group">
118
  <label class="filter-label" for="iterations-input">Iterations</label>
119
  <input type="number" id="iterations-input" class="filter-select run-iter-input" value="5" min="1" max="50" step="1">
120
  </div>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Sticky action bar: always-reachable Download/Run -->
125
+ <div class="run-action-bar">
126
+ <div class="run-action-bar-inner">
127
+ <span id="queue-status" class="run-queue-status"></span>
128
  <div class="run-actions">
 
129
  <button class="btn btn-secondary" id="btn-download" type="button" disabled>Download selected</button>
130
  <button class="btn btn-primary" id="btn-run" type="button" disabled>Run benchmarks</button>
131
  <button class="btn btn-danger" id="btn-abort" type="button" hidden>Abort</button>
 
141
 
142
  <!-- Progress -->
143
  <div class="section-header">
144
+ <h2 class="subsection-title">Progress</h2>
145
  </div>
146
  <div class="table-card">
147
  <div id="run-progress-wrapper" class="results-wrapper"></div>
 
149
 
150
  <!-- Output -->
151
  <div class="section-header" style="margin-top: 32px;">
152
+ <h2 class="subsection-title">Output</h2>
153
  </div>
154
  <div class="card run-output">
155
  <label id="save-local-row" class="run-output-toggle" hidden>
156
  <input type="checkbox" id="save-local" checked>
157
  Save to <code>results/results.json</code> on this server
158
  </label>
159
+ <textarea id="output-textarea" class="run-output-textarea" readonly spellcheck="false" aria-label="Benchmark results output (JSON)" placeholder="Run benchmarks to generate output here…"></textarea>
160
  <div class="run-output-buttons">
161
  <button class="btn btn-secondary" id="btn-copy" type="button">Copy</button>
162
  <button class="btn btn-secondary" id="btn-download-json" type="button">Download JSON</button>