inspectra-engine 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1408 @@
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">
6
+ <title>Visual Regression Comparison</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f7f8fa;
10
+ --card: #fff;
11
+ --fg: #14161a;
12
+ --mut: #667085;
13
+ --line: #e6e8ec;
14
+ --soft: #f2f4f7;
15
+ --acc: #5b5bf0;
16
+ --acc2: #8b5cf6;
17
+ --good: #0a7a3d;
18
+ --avg: #c33300;
19
+ --bad: #c7221f;
20
+ --r: 14px;
21
+ }
22
+ @media(prefers-color-scheme:dark){
23
+ :root {
24
+ --bg: #0d0f13;
25
+ --card: #161920;
26
+ --fg: #eceef2;
27
+ --mut: #98a2b3;
28
+ --line: #262a33;
29
+ --soft: #1d212a;
30
+ --good: #3ddc84;
31
+ --avg: #ffb020;
32
+ --bad: #ff6b61;
33
+ }
34
+ }
35
+ * { box-sizing: border-box; }
36
+ body {
37
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
38
+ max-width: 1180px;
39
+ margin: 0 auto;
40
+ padding: 2.2rem 1.2rem 4rem;
41
+ color: var(--fg);
42
+ background: var(--bg);
43
+ -webkit-font-smoothing: antialiased;
44
+ }
45
+ .nav {
46
+ display: flex;
47
+ gap: .6rem;
48
+ margin-bottom: 1.8rem;
49
+ border-bottom: 1px solid var(--line);
50
+ padding-bottom: .8rem;
51
+ }
52
+ .nav-link {
53
+ text-decoration: none;
54
+ font-weight: 600;
55
+ font-size: .9rem;
56
+ padding: .4rem .9rem;
57
+ border-radius: 8px;
58
+ color: var(--mut);
59
+ transition: .15s;
60
+ }
61
+ .nav-link:hover { color: var(--fg); background: var(--soft); }
62
+ .nav-link.active { color: #fff; background: linear-gradient(135deg, var(--acc), var(--acc2)); }
63
+
64
+ h1 {
65
+ font-size: 1.9rem;
66
+ letter-spacing: -.03em;
67
+ margin: 0 0 .4rem;
68
+ background: linear-gradient(90deg, var(--acc), var(--acc2));
69
+ -webkit-background-clip: text;
70
+ background-clip: text;
71
+ color: transparent;
72
+ }
73
+ p.subtitle {
74
+ margin: 0 0 1.5rem;
75
+ color: var(--mut);
76
+ font-size: .95rem;
77
+ }
78
+
79
+ .sources-grid {
80
+ display: grid;
81
+ grid-template-columns: 1fr 1fr;
82
+ gap: 1.2rem;
83
+ margin-bottom: 1.2rem;
84
+ }
85
+ @media(max-width: 760px){
86
+ .sources-grid { grid-template-columns: 1fr; }
87
+ }
88
+
89
+ .source-card {
90
+ background: var(--card);
91
+ border: 1px solid var(--line);
92
+ border-radius: var(--r);
93
+ padding: 1.2rem;
94
+ box-shadow: 0 1px 2px rgba(16,24,40,.04);
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: .9rem;
98
+ }
99
+ .card-header {
100
+ display: flex;
101
+ justify-content: space-between;
102
+ align-items: center;
103
+ }
104
+ .badge {
105
+ font-size: .75rem;
106
+ font-weight: 700;
107
+ text-transform: uppercase;
108
+ letter-spacing: .05em;
109
+ padding: .2rem .6rem;
110
+ border-radius: 99px;
111
+ }
112
+ .badge-ref { background: color-mix(in srgb, var(--acc) 18%, transparent); color: var(--acc); }
113
+ .badge-act { background: color-mix(in srgb, var(--acc2) 18%, transparent); color: var(--acc2); }
114
+
115
+ .mode-switch {
116
+ display: flex;
117
+ gap: .3rem;
118
+ background: var(--soft);
119
+ padding: .25rem;
120
+ border-radius: 8px;
121
+ }
122
+ .mode-btn {
123
+ border: 0;
124
+ background: transparent;
125
+ color: var(--mut);
126
+ font-size: .82rem;
127
+ font-weight: 600;
128
+ padding: .35rem .6rem;
129
+ border-radius: 6px;
130
+ cursor: pointer;
131
+ transition: .15s;
132
+ }
133
+ .mode-btn.active {
134
+ background: var(--card);
135
+ color: var(--fg);
136
+ box-shadow: 0 1px 3px rgba(0,0,0,.08);
137
+ }
138
+
139
+ label.field-lbl {
140
+ font-size: .8rem;
141
+ font-weight: 600;
142
+ color: var(--mut);
143
+ display: block;
144
+ margin-bottom: .3rem;
145
+ }
146
+ input[type=url], input[type=text], input[type=password], input[type=number], select {
147
+ width: 100%;
148
+ padding: .65rem .85rem;
149
+ font: inherit;
150
+ color: inherit;
151
+ background: var(--bg);
152
+ border: 1px solid var(--line);
153
+ border-radius: 10px;
154
+ outline: 0;
155
+ transition: .15s;
156
+ }
157
+ input[type=url]:focus, input[type=text]:focus, input[type=password]:focus, input[type=number]:focus, select:focus {
158
+ border-color: var(--acc);
159
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 22%, transparent);
160
+ }
161
+
162
+ .file-dropzone {
163
+ border: 2px dashed var(--line);
164
+ border-radius: 10px;
165
+ padding: 1.2rem 1rem;
166
+ text-align: center;
167
+ background: var(--bg);
168
+ cursor: pointer;
169
+ transition: .15s;
170
+ position: relative;
171
+ }
172
+ .file-dropzone:hover, .file-dropzone.dragover {
173
+ border-color: var(--acc);
174
+ background: color-mix(in srgb, var(--acc) 6%, transparent);
175
+ }
176
+ .file-dropzone input[type=file] {
177
+ position: absolute;
178
+ inset: 0;
179
+ opacity: 0;
180
+ cursor: pointer;
181
+ width: 100%;
182
+ height: 100%;
183
+ }
184
+ .file-preview {
185
+ display: none;
186
+ align-items: center;
187
+ gap: .8rem;
188
+ margin-top: .5rem;
189
+ padding: .5rem;
190
+ background: var(--soft);
191
+ border-radius: 8px;
192
+ }
193
+ .file-preview img {
194
+ width: 48px;
195
+ height: 48px;
196
+ object-fit: cover;
197
+ border-radius: 6px;
198
+ border: 1px solid var(--line);
199
+ }
200
+ .file-preview-info {
201
+ font-size: .8rem;
202
+ color: var(--mut);
203
+ text-align: left;
204
+ overflow: hidden;
205
+ }
206
+ .file-preview-info b {
207
+ display: block;
208
+ color: var(--fg);
209
+ white-space: nowrap;
210
+ overflow: hidden;
211
+ text-overflow: ellipsis;
212
+ }
213
+
214
+ .options-card {
215
+ background: var(--card);
216
+ border: 1px solid var(--line);
217
+ border-radius: var(--r);
218
+ padding: 1.2rem;
219
+ margin-bottom: 1.2rem;
220
+ box-shadow: 0 1px 2px rgba(16,24,40,.04);
221
+ }
222
+ .options-grid {
223
+ display: grid;
224
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
225
+ gap: 1rem;
226
+ align-items: end;
227
+ }
228
+ .custom-dim {
229
+ display: none;
230
+ grid-template-columns: 1fr 1fr;
231
+ gap: .5rem;
232
+ }
233
+
234
+ .threshold-wrap {
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: .4rem;
238
+ }
239
+ .range-header {
240
+ display: flex;
241
+ justify-content: space-between;
242
+ font-size: .8rem;
243
+ color: var(--mut);
244
+ font-weight: 500;
245
+ }
246
+ input[type=range] {
247
+ width: 100%;
248
+ accent-color: var(--acc);
249
+ }
250
+
251
+ .checkbox-group {
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: .4rem;
255
+ font-size: .85rem;
256
+ color: var(--mut);
257
+ padding-top: .5rem;
258
+ }
259
+ .checkbox-group label {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: .4rem;
263
+ cursor: pointer;
264
+ }
265
+ input[type=checkbox] { accent-color: var(--acc); }
266
+
267
+ .action-row {
268
+ display: flex;
269
+ justify-content: flex-end;
270
+ margin-top: 1.2rem;
271
+ }
272
+ .btn-submit {
273
+ padding: .75rem 1.8rem;
274
+ font: 600 1rem ui-sans-serif, system-ui, sans-serif;
275
+ border: 0;
276
+ border-radius: 10px;
277
+ background: linear-gradient(135deg, var(--acc), var(--acc2));
278
+ color: #fff;
279
+ cursor: pointer;
280
+ transition: .15s;
281
+ display: inline-flex;
282
+ align-items: center;
283
+ gap: .5rem;
284
+ }
285
+ .btn-submit:hover { filter: brightness(1.08); }
286
+ .btn-submit:active { transform: scale(.98); }
287
+ .btn-submit:disabled { opacity: .55; cursor: progress; }
288
+
289
+ /* Results Styling */
290
+ #results-container {
291
+ margin-top: 2rem;
292
+ animation: fadeIn .25s ease-in-out;
293
+ }
294
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
295
+
296
+ .stat-grid {
297
+ display: grid;
298
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
299
+ gap: .8rem;
300
+ margin-bottom: 1.2rem;
301
+ }
302
+ .stat-box {
303
+ background: var(--card);
304
+ border: 1px solid var(--line);
305
+ border-radius: var(--r);
306
+ padding: .9rem 1.1rem;
307
+ }
308
+ .stat-box small {
309
+ display: block;
310
+ color: var(--mut);
311
+ font-size: .8rem;
312
+ font-weight: 500;
313
+ margin-bottom: .2rem;
314
+ }
315
+ .stat-box .val {
316
+ font-size: 1.5rem;
317
+ font-weight: 700;
318
+ letter-spacing: -.02em;
319
+ }
320
+ .val-good { color: var(--good); }
321
+ .val-avg { color: var(--avg); }
322
+ .val-bad { color: var(--bad); }
323
+
324
+ .warning-box {
325
+ background: color-mix(in srgb, var(--avg) 12%, transparent);
326
+ border: 1px solid color-mix(in srgb, var(--avg) 35%, transparent);
327
+ color: var(--fg);
328
+ padding: .7rem 1rem;
329
+ border-radius: 10px;
330
+ font-size: .88rem;
331
+ margin-bottom: 1.2rem;
332
+ }
333
+
334
+ /* Diff Navigator Toolbar */
335
+ .diff-nav-bar {
336
+ background: var(--card);
337
+ border: 1px solid var(--line);
338
+ border-radius: var(--r);
339
+ padding: .8rem 1.1rem;
340
+ margin-bottom: 1.2rem;
341
+ display: flex;
342
+ flex-wrap: wrap;
343
+ align-items: center;
344
+ justify-content: space-between;
345
+ gap: .8rem;
346
+ }
347
+ .diff-nav-left {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: .7rem;
351
+ flex-wrap: wrap;
352
+ }
353
+ .diff-chip-group {
354
+ display: flex;
355
+ gap: .4rem;
356
+ overflow-x: auto;
357
+ max-width: 550px;
358
+ padding-bottom: .2rem;
359
+ }
360
+ .diff-chip {
361
+ border: 1px solid var(--line);
362
+ background: var(--soft);
363
+ color: var(--fg);
364
+ font-size: .78rem;
365
+ font-weight: 600;
366
+ padding: .25rem .6rem;
367
+ border-radius: 6px;
368
+ cursor: pointer;
369
+ white-space: nowrap;
370
+ transition: .15s;
371
+ }
372
+ .diff-chip:hover { border-color: var(--acc); background: color-mix(in srgb, var(--acc) 10%, transparent); }
373
+ .diff-chip.active { background: var(--acc); color: #fff; border-color: var(--acc); }
374
+
375
+ .diff-nav-btns {
376
+ display: flex;
377
+ gap: .3rem;
378
+ }
379
+ .btn-nav-step {
380
+ border: 1px solid var(--line);
381
+ background: var(--card);
382
+ color: var(--fg);
383
+ font-size: .82rem;
384
+ font-weight: 600;
385
+ padding: .3rem .7rem;
386
+ border-radius: 6px;
387
+ cursor: pointer;
388
+ transition: .15s;
389
+ }
390
+ .btn-nav-step:hover { background: var(--soft); }
391
+
392
+ .view-controls-bar {
393
+ display: flex;
394
+ justify-content: space-between;
395
+ align-items: center;
396
+ flex-wrap: wrap;
397
+ gap: .6rem;
398
+ margin-bottom: .8rem;
399
+ }
400
+ .view-tabs {
401
+ display: flex;
402
+ gap: .3rem;
403
+ background: var(--soft);
404
+ padding: .25rem;
405
+ border-radius: 8px;
406
+ }
407
+ .view-tab {
408
+ background: transparent;
409
+ border: 0;
410
+ font-size: .84rem;
411
+ font-weight: 600;
412
+ color: var(--mut);
413
+ padding: .35rem .75rem;
414
+ border-radius: 6px;
415
+ cursor: pointer;
416
+ transition: .15s;
417
+ }
418
+ .view-tab:hover { color: var(--fg); }
419
+ .view-tab.active { color: #fff; background: var(--acc); }
420
+
421
+ .zoom-controls {
422
+ display: flex;
423
+ gap: .3rem;
424
+ align-items: center;
425
+ }
426
+ .btn-zoom {
427
+ border: 1px solid var(--line);
428
+ background: var(--card);
429
+ color: var(--fg);
430
+ font-size: .78rem;
431
+ font-weight: 600;
432
+ padding: .25rem .55rem;
433
+ border-radius: 6px;
434
+ cursor: pointer;
435
+ }
436
+ .btn-zoom.active {
437
+ background: var(--fg);
438
+ color: var(--bg);
439
+ }
440
+
441
+ /* Synced Side-by-Side Panels */
442
+ .diff-wrapper {
443
+ position: relative;
444
+ display: flex;
445
+ gap: .6rem;
446
+ }
447
+ .diff-panels {
448
+ flex: 1;
449
+ display: grid;
450
+ grid-template-columns: 1fr 1fr 1fr;
451
+ gap: .8rem;
452
+ }
453
+ @media(max-width: 860px){
454
+ .diff-panels { grid-template-columns: 1fr; }
455
+ }
456
+
457
+ .diff-panel {
458
+ background: var(--card);
459
+ border: 1px solid var(--line);
460
+ border-radius: var(--r);
461
+ overflow: hidden;
462
+ display: flex;
463
+ flex-direction: column;
464
+ }
465
+ .panel-title {
466
+ padding: .5rem .8rem;
467
+ font-size: .8rem;
468
+ font-weight: 700;
469
+ color: var(--mut);
470
+ background: var(--soft);
471
+ border-bottom: 1px solid var(--line);
472
+ display: flex;
473
+ justify-content: space-between;
474
+ align-items: center;
475
+ }
476
+ .panel-body-scroll {
477
+ overflow-y: scroll;
478
+ max-height: 650px;
479
+ background: #fff;
480
+ scroll-behavior: smooth;
481
+ position: relative;
482
+ }
483
+ .panel-body-scroll.full-height {
484
+ max-height: none;
485
+ overflow-y: visible;
486
+ }
487
+ .panel-body-scroll img {
488
+ width: 100%;
489
+ height: auto;
490
+ display: block;
491
+ transform-origin: top left;
492
+ }
493
+
494
+ /* Minimap Heatstrip */
495
+ .minimap-bar {
496
+ width: 24px;
497
+ background: var(--card);
498
+ border: 1px solid var(--line);
499
+ border-radius: 8px;
500
+ position: relative;
501
+ cursor: pointer;
502
+ overflow: hidden;
503
+ }
504
+ .minimap-viewport-indicator {
505
+ position: absolute;
506
+ width: 100%;
507
+ background: rgba(91, 91, 240, 0.25);
508
+ border-top: 2px solid var(--acc);
509
+ border-bottom: 2px solid var(--acc);
510
+ pointer-events: none;
511
+ }
512
+ .minimap-marker {
513
+ position: absolute;
514
+ left: 2px;
515
+ right: 2px;
516
+ background: #ff3b30;
517
+ border-radius: 2px;
518
+ opacity: 0.85;
519
+ pointer-events: none;
520
+ }
521
+
522
+ /* Interactive Split Slider */
523
+ .split-wrap-outer {
524
+ max-height: 700px;
525
+ overflow-y: auto;
526
+ border-radius: var(--r);
527
+ border: 1px solid var(--line);
528
+ background: #000;
529
+ position: relative;
530
+ }
531
+ .split-viewer {
532
+ position: relative;
533
+ width: 100%;
534
+ overflow: hidden;
535
+ user-select: none;
536
+ -webkit-user-select: none;
537
+ }
538
+ .split-viewer img {
539
+ display: block;
540
+ width: 100%;
541
+ height: auto;
542
+ pointer-events: none;
543
+ }
544
+ .split-top {
545
+ position: absolute;
546
+ top: 0;
547
+ left: 0;
548
+ bottom: 0;
549
+ overflow: hidden;
550
+ border-right: 3px solid #fff;
551
+ box-shadow: 2px 0 10px rgba(0,0,0,.5);
552
+ }
553
+ .split-top img {
554
+ position: absolute;
555
+ top: 0;
556
+ left: 0;
557
+ height: 100%;
558
+ width: auto;
559
+ max-width: none;
560
+ }
561
+ .split-handle {
562
+ position: absolute;
563
+ top: 50%;
564
+ width: 40px;
565
+ height: 40px;
566
+ background: #fff;
567
+ border-radius: 50%;
568
+ box-shadow: 0 2px 10px rgba(0,0,0,.5);
569
+ transform: translate(-50%, -50%);
570
+ cursor: ew-resize;
571
+ display: flex;
572
+ align-items: center;
573
+ justify-content: center;
574
+ font-size: 16px;
575
+ font-weight: bold;
576
+ color: #111;
577
+ z-index: 10;
578
+ }
579
+ .split-labels {
580
+ position: sticky;
581
+ top: 12px;
582
+ display: flex;
583
+ justify-content: space-between;
584
+ pointer-events: none;
585
+ padding: 0 16px;
586
+ z-index: 20;
587
+ }
588
+ .split-lbl {
589
+ background: rgba(0,0,0,.75);
590
+ color: #fff;
591
+ font-size: .78rem;
592
+ font-weight: 600;
593
+ padding: .25rem .7rem;
594
+ border-radius: 6px;
595
+ }
596
+
597
+ /* Onion Skin */
598
+ .onion-skin-wrap {
599
+ background: var(--card);
600
+ border: 1px solid var(--line);
601
+ border-radius: var(--r);
602
+ padding: 1.2rem;
603
+ }
604
+ .onion-ctrl {
605
+ display: flex;
606
+ align-items: center;
607
+ gap: .8rem;
608
+ margin-bottom: .8rem;
609
+ font-size: .85rem;
610
+ }
611
+ .onion-scroll {
612
+ max-height: 700px;
613
+ overflow-y: auto;
614
+ position: relative;
615
+ border-radius: 8px;
616
+ border: 1px solid var(--line);
617
+ }
618
+ .onion-canvas {
619
+ position: relative;
620
+ width: 100%;
621
+ }
622
+ .onion-canvas img {
623
+ width: 100%;
624
+ display: block;
625
+ }
626
+ .onion-overlay {
627
+ position: absolute;
628
+ inset: 0;
629
+ }
630
+
631
+ .downloads {
632
+ display: flex;
633
+ flex-wrap: wrap;
634
+ gap: .6rem;
635
+ margin-top: 1.4rem;
636
+ }
637
+ .btn-outline {
638
+ font-size: .85rem;
639
+ font-weight: 600;
640
+ padding: .45rem .9rem;
641
+ border-radius: 8px;
642
+ border: 1px solid var(--line);
643
+ background: var(--card);
644
+ color: var(--fg);
645
+ text-decoration: none;
646
+ cursor: pointer;
647
+ transition: .15s;
648
+ display: inline-flex;
649
+ align-items: center;
650
+ gap: .4rem;
651
+ }
652
+ .btn-outline:hover { background: var(--soft); }
653
+
654
+ .msg-error {
655
+ background: color-mix(in srgb, var(--bad) 12%, transparent);
656
+ border: 1px solid color-mix(in srgb, var(--bad) 35%, transparent);
657
+ color: var(--bad);
658
+ padding: .8rem 1rem;
659
+ border-radius: 10px;
660
+ font-size: .9rem;
661
+ margin-top: 1.2rem;
662
+ }
663
+ </style>
664
+ </head>
665
+ <body>
666
+
667
+ <nav class="nav">
668
+ <a href="/" class="nav-link">Accessibility Scanner</a>
669
+ <a href="/compare" class="nav-link active">Visual Regression Comparison</a>
670
+ <a href="/compare-2" class="nav-link">UI Differences</a>
671
+ <a href="/compare-3" class="nav-link">UI Completeness Check</a>
672
+ </nav>
673
+
674
+ <h1>Visual Regression & Image Comparison</h1>
675
+ <p class="subtitle">Compare web pages, Figma frames or uploaded screenshots pixel-by-pixel to locate and inspect UI changes.</p>
676
+
677
+ <form id="compare-form">
678
+ <div class="sources-grid">
679
+ <!-- Reference Card -->
680
+ <div class="source-card">
681
+ <div class="card-header">
682
+ <span class="badge badge-ref">Reference (Baseline)</span>
683
+ <div class="mode-switch">
684
+ <button type="button" class="mode-btn active" data-source="ref" data-mode="url">Web URL</button>
685
+ <button type="button" class="mode-btn" data-source="ref" data-mode="image">Upload Image</button>
686
+ <button type="button" class="mode-btn" data-source="ref" data-mode="figma">Figma URL</button>
687
+ </div>
688
+ </div>
689
+
690
+ <div id="ref-url-section">
691
+ <label class="field-lbl" for="ref-url">Baseline URL</label>
692
+ <input type="url" id="ref-url" placeholder="https://example.com" value="https://example.com">
693
+ </div>
694
+
695
+ <div id="ref-figma-section" style="display:none">
696
+ <label class="field-lbl" for="ref-figma-url">Figma Frame URL</label>
697
+ <input type="url" id="ref-figma-url" placeholder="https://www.figma.com/design/FILE_KEY/Name?node-id=1-2">
698
+ <label class="field-lbl" for="ref-figma-token" style="margin-top:.6rem">Figma Access Token (optional if set on server)</label>
699
+ <input type="password" id="ref-figma-token" placeholder="figd_..." autocomplete="off">
700
+ </div>
701
+
702
+ <div id="ref-image-section" style="display:none">
703
+ <label class="field-lbl">Upload Baseline Image</label>
704
+ <div class="file-dropzone" id="ref-dropzone">
705
+ <p style="margin:0;font-size:.85rem;color:var(--mut);">Drag & drop PNG/JPG or click to select</p>
706
+ <input type="file" id="ref-file" accept="image/png,image/jpeg,image/webp,image/svg+xml">
707
+ </div>
708
+ <div class="file-preview" id="ref-preview">
709
+ <img id="ref-preview-img" alt="Reference Preview">
710
+ <div class="file-preview-info">
711
+ <b id="ref-preview-name">file.png</b>
712
+ <span id="ref-preview-dim">0 x 0</span>
713
+ </div>
714
+ </div>
715
+ </div>
716
+ </div>
717
+
718
+ <!-- Actual Card -->
719
+ <div class="source-card">
720
+ <div class="card-header">
721
+ <span class="badge badge-act">Actual (Target)</span>
722
+ <div class="mode-switch">
723
+ <button type="button" class="mode-btn active" data-source="act" data-mode="url">Web URL</button>
724
+ <button type="button" class="mode-btn" data-source="act" data-mode="image">Upload Image</button>
725
+ <button type="button" class="mode-btn" data-source="act" data-mode="figma">Figma URL</button>
726
+ </div>
727
+ </div>
728
+
729
+ <div id="act-url-section">
730
+ <label class="field-lbl" for="act-url">Target URL</label>
731
+ <input type="url" id="act-url" placeholder="https://staging.example.com" value="https://staging.example.com">
732
+ </div>
733
+
734
+ <div id="act-figma-section" style="display:none">
735
+ <label class="field-lbl" for="act-figma-url">Figma Frame URL</label>
736
+ <input type="url" id="act-figma-url" placeholder="https://www.figma.com/design/FILE_KEY/Name?node-id=1-2">
737
+ <label class="field-lbl" for="act-figma-token" style="margin-top:.6rem">Figma Access Token (optional if set on server)</label>
738
+ <input type="password" id="act-figma-token" placeholder="figd_..." autocomplete="off">
739
+ </div>
740
+
741
+ <div id="act-image-section" style="display:none">
742
+ <label class="field-lbl">Upload Target Image</label>
743
+ <div class="file-dropzone" id="act-dropzone">
744
+ <p style="margin:0;font-size:.85rem;color:var(--mut);">Drag & drop PNG/JPG or click to select</p>
745
+ <input type="file" id="act-file" accept="image/png,image/jpeg,image/webp,image/svg+xml">
746
+ </div>
747
+ <div class="file-preview" id="act-preview">
748
+ <img id="act-preview-img" alt="Actual Preview">
749
+ <div class="file-preview-info">
750
+ <b id="act-preview-name">file.png</b>
751
+ <span id="act-preview-dim">0 x 0</span>
752
+ </div>
753
+ </div>
754
+ </div>
755
+ </div>
756
+ </div>
757
+
758
+ <!-- Options Card -->
759
+ <div class="options-card">
760
+ <div class="options-grid">
761
+ <div>
762
+ <label class="field-lbl" for="viewport-preset">Viewport Dimensions</label>
763
+ <select id="viewport-preset">
764
+ <option value="1441x900" selected>Desktop (1440 × 900) - Default</option>
765
+ <option value="1920x1080">Desktop Full HD (1920 × 1080)</option>
766
+ <option value="1280x800">Laptop (1280 × 800)</option>
767
+ <option value="768x1024">Tablet iPad (768 × 1024)</option>
768
+ <option value="390x844">Mobile iPhone (390 × 844)</option>
769
+ <option value="375x667">Mobile SE (375 × 667)</option>
770
+ <option value="custom">Custom Dimensions…</option>
771
+ </select>
772
+ <div class="custom-dim" id="custom-dim-wrap" style="margin-top:.4rem">
773
+ <div><input type="number" id="custom-width" placeholder="Width" value="1441"></div>
774
+ <div><input type="number" id="custom-height" placeholder="Height" value="900"></div>
775
+ </div>
776
+ </div>
777
+
778
+ <div class="threshold-wrap">
779
+ <div class="range-header">
780
+ <span>Sensitivity (Threshold)</span>
781
+ <b id="threshold-val">0.50</b>
782
+ </div>
783
+ <input type="range" id="threshold" min="0.01" max="0.5" step="0.01" value="0.5">
784
+ </div>
785
+
786
+ <div class="checkbox-group">
787
+ <label><input type="checkbox" id="full-page" checked> Full page screenshot (entire height)</label>
788
+ <label><input type="checkbox" id="disable-animations" checked> Disable animations & transitions</label>
789
+ </div>
790
+ </div>
791
+
792
+ <div class="action-row">
793
+ <button type="submit" class="btn-submit" id="btn-compare">
794
+ <span>Run Visual Comparison</span>
795
+ </button>
796
+ </div>
797
+ </div>
798
+ </form>
799
+
800
+ <div id="status-msg"></div>
801
+
802
+ <div id="results-container" style="display:none">
803
+ <!-- Dynamic Results Populated via JS -->
804
+ </div>
805
+
806
+ <script>
807
+ let refState = { mode: 'url', data: null, name: '' };
808
+ let actState = { mode: 'url', data: null, name: '' };
809
+ let currentResult = null;
810
+ let currentDiffIndex = -1;
811
+
812
+ // Mode Switching (URL vs Image)
813
+ document.querySelectorAll('.mode-btn').forEach(btn => {
814
+ btn.addEventListener('click', () => {
815
+ const src = btn.dataset.source;
816
+ const mode = btn.dataset.mode;
817
+ btn.parentElement.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
818
+ btn.classList.add('active');
819
+
820
+ if (src === 'ref') {
821
+ refState.mode = mode;
822
+ document.getElementById('ref-url-section').style.display = mode === 'url' ? 'block' : 'none';
823
+ document.getElementById('ref-image-section').style.display = mode === 'image' ? 'block' : 'none';
824
+ document.getElementById('ref-figma-section').style.display = mode === 'figma' ? 'block' : 'none';
825
+ } else {
826
+ actState.mode = mode;
827
+ document.getElementById('act-url-section').style.display = mode === 'url' ? 'block' : 'none';
828
+ document.getElementById('act-image-section').style.display = mode === 'image' ? 'block' : 'none';
829
+ document.getElementById('act-figma-section').style.display = mode === 'figma' ? 'block' : 'none';
830
+ }
831
+ });
832
+ });
833
+
834
+ // Viewport Presets
835
+ const vpSelect = document.getElementById('viewport-preset');
836
+ const customDimWrap = document.getElementById('custom-dim-wrap');
837
+ vpSelect.addEventListener('change', () => {
838
+ customDimWrap.style.display = vpSelect.value === 'custom' ? 'grid' : 'none';
839
+ });
840
+
841
+ // Threshold Slider
842
+ const thInput = document.getElementById('threshold');
843
+ const thVal = document.getElementById('threshold-val');
844
+ thInput.addEventListener('input', () => {
845
+ thVal.textContent = Number(thInput.value).toFixed(2);
846
+ });
847
+
848
+ // File Upload Handlers
849
+ function setupFilePicker(inputId, dropzoneId, previewId, imgId, nameId, dimId, stateTarget) {
850
+ const fileInput = document.getElementById(inputId);
851
+ const dropzone = document.getElementById(dropzoneId);
852
+ const preview = document.getElementById(previewId);
853
+ const previewImg = document.getElementById(imgId);
854
+ const nameEl = document.getElementById(nameId);
855
+ const dimEl = document.getElementById(dimId);
856
+
857
+ const handleFile = file => {
858
+ if (!file || !file.type.startsWith('image/')) return;
859
+ const reader = new FileReader();
860
+ reader.onload = e => {
861
+ const dataUrl = e.target.result;
862
+ stateTarget.data = dataUrl;
863
+ stateTarget.name = file.name;
864
+ previewImg.src = dataUrl;
865
+ nameEl.textContent = file.name;
866
+ preview.style.display = 'flex';
867
+
868
+ const img = new Image();
869
+ img.onload = () => {
870
+ dimEl.textContent = `${img.naturalWidth} × ${img.naturalHeight} px`;
871
+ };
872
+ img.src = dataUrl;
873
+ };
874
+ reader.readAsDataURL(file);
875
+ };
876
+
877
+ fileInput.addEventListener('change', e => {
878
+ if (e.target.files.length) handleFile(e.target.files[0]);
879
+ });
880
+
881
+ dropzone.addEventListener('dragover', e => {
882
+ e.preventDefault();
883
+ dropzone.classList.add('dragover');
884
+ });
885
+ dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
886
+ dropzone.addEventListener('drop', e => {
887
+ e.preventDefault();
888
+ dropzone.classList.remove('dragover');
889
+ if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
890
+ });
891
+ }
892
+
893
+ setupFilePicker('ref-file', 'ref-dropzone', 'ref-preview', 'ref-preview-img', 'ref-preview-name', 'ref-preview-dim', refState);
894
+ setupFilePicker('act-file', 'act-dropzone', 'act-preview', 'act-preview-img', 'act-preview-name', 'act-preview-dim', actState);
895
+
896
+ // Form Submission
897
+ const form = document.getElementById('compare-form');
898
+ const btnCompare = document.getElementById('btn-compare');
899
+ const statusMsg = document.getElementById('status-msg');
900
+ const resultsContainer = document.getElementById('results-container');
901
+
902
+ form.addEventListener('submit', async e => {
903
+ e.preventDefault();
904
+
905
+ let referencePayload, actualPayload;
906
+
907
+ if (refState.mode === 'url') {
908
+ const u = document.getElementById('ref-url').value.trim();
909
+ if (!u) return alert('Please enter Reference URL');
910
+ referencePayload = { type: 'url', value: u, name: u };
911
+ } else if (refState.mode === 'figma') {
912
+ const u = document.getElementById('ref-figma-url').value.trim();
913
+ if (!u) return alert('Please enter Reference Figma URL');
914
+ referencePayload = { type: 'figma', value: u, token: document.getElementById('ref-figma-token').value.trim(), name: 'Figma: ' + u };
915
+ } else {
916
+ if (!refState.data) return alert('Please upload a Reference image');
917
+ referencePayload = { type: 'image', value: refState.data, name: refState.name };
918
+ }
919
+
920
+ if (actState.mode === 'url') {
921
+ const u = document.getElementById('act-url').value.trim();
922
+ if (!u) return alert('Please enter Actual URL');
923
+ actualPayload = { type: 'url', value: u, name: u };
924
+ } else if (actState.mode === 'figma') {
925
+ const u = document.getElementById('act-figma-url').value.trim();
926
+ if (!u) return alert('Please enter Actual Figma URL');
927
+ actualPayload = { type: 'figma', value: u, token: document.getElementById('act-figma-token').value.trim(), name: 'Figma: ' + u };
928
+ } else {
929
+ if (!actState.data) return alert('Please upload an Actual image');
930
+ actualPayload = { type: 'image', value: actState.data, name: actState.name };
931
+ }
932
+
933
+ let width = 1441, height = 900;
934
+ if (vpSelect.value === 'custom') {
935
+ width = parseInt(document.getElementById('custom-width').value) || 1441;
936
+ height = parseInt(document.getElementById('custom-height').value) || 900;
937
+ } else {
938
+ const [w, h] = vpSelect.value.split('x').map(Number);
939
+ width = w; height = h;
940
+ }
941
+
942
+ const payload = {
943
+ reference: referencePayload,
944
+ actual: actualPayload,
945
+ viewport: { width, height },
946
+ threshold: parseFloat(thInput.value) || 0.1,
947
+ fullPage: document.getElementById('full-page').checked,
948
+ disableAnimations: document.getElementById('disable-animations').checked,
949
+ };
950
+
951
+ btnCompare.disabled = true;
952
+ statusMsg.innerHTML = '<p style="color:var(--mut);font-size:.9rem;padding:1rem 0">Capturing screenshots & computing pixel difference… (this may take 10-30 seconds for tall pages)</p>';
953
+ resultsContainer.style.display = 'none';
954
+
955
+ try {
956
+ const res = await fetch('/api/compare', {
957
+ method: 'POST',
958
+ headers: { 'Content-Type': 'application/json' },
959
+ body: JSON.stringify(payload),
960
+ });
961
+
962
+ const data = await res.json();
963
+ if (!res.ok || data.error) throw new Error(data.error || 'Comparison failed');
964
+
965
+ currentResult = data;
966
+ statusMsg.innerHTML = '';
967
+ renderResults(data);
968
+ } catch (err) {
969
+ statusMsg.innerHTML = `<div class="msg-error"><b>Error:</b> ${err.message}</div>`;
970
+ } finally {
971
+ btnCompare.disabled = false;
972
+ }
973
+ });
974
+
975
+ function cleanUrl(p) {
976
+ if (!p) return '';
977
+ return '/' + String(p).replace(/^\/+/, '');
978
+ }
979
+
980
+ function renderResults(d) {
981
+ const diffPct = Number(d.differencePercent || 0);
982
+ const statClass = diffPct === 0 ? 'val-good' : (diffPct < 1 ? 'val-avg' : 'val-bad');
983
+
984
+ const refSrc = `${cleanUrl(d.referenceImage)}?t=${Date.now()}`;
985
+ const actSrc = `${cleanUrl(d.actualImage)}?t=${Date.now()}`;
986
+ const diffSrc = `${cleanUrl(d.reportImage)}?t=${Date.now()}`;
987
+
988
+ let mismatchHtml = '';
989
+ if (d.sizeMismatch) {
990
+ mismatchHtml = `
991
+ <div class="warning-box">
992
+ <strong>⚠️ Dimension Adjustment:</strong> Reference was <code>${d.sizeMismatch.reference}</code> and Actual was <code>${d.sizeMismatch.actual}</code>. Both were normalized to <code>${d.viewport}</code> so pixel diffing could proceed without clipping.
993
+ </div>
994
+ `;
995
+ }
996
+
997
+ const regions = d.diffRegions || [];
998
+ let regionsHtml = '';
999
+ if (regions.length > 0) {
1000
+ regionsHtml = `
1001
+ <div class="diff-nav-bar">
1002
+ <div class="diff-nav-left">
1003
+ <strong style="font-size:.88rem">🔍 ${regions.length} Diff Regions Located:</strong>
1004
+ <div class="diff-chip-group">
1005
+ ${regions.map((r, i) => `
1006
+ <button type="button" class="diff-chip" data-idx="${i}" data-y="${r.startY}">
1007
+ #${i+1} at Y:${r.startY}px (${r.diffPixels.toLocaleString()} px)
1008
+ </button>
1009
+ `).join('')}
1010
+ </div>
1011
+ </div>
1012
+ <div class="diff-nav-btns">
1013
+ <button type="button" class="btn-nav-step" id="btn-prev-diff" title="Previous difference">▲ Prev</button>
1014
+ <button type="button" class="btn-nav-step" id="btn-next-diff" title="Next difference">▼ Next</button>
1015
+ <button type="button" class="btn-nav-step" id="btn-toggle-height">↕ Expand Full Height</button>
1016
+ </div>
1017
+ </div>
1018
+ `;
1019
+ }
1020
+
1021
+ resultsContainer.innerHTML = `
1022
+ <h2>Comparison Results</h2>
1023
+ ${mismatchHtml}
1024
+
1025
+ <div class="stat-grid">
1026
+ <div class="stat-box">
1027
+ <small>Difference</small>
1028
+ <div class="val ${statClass}">${diffPct}%</div>
1029
+ </div>
1030
+ <div class="stat-box">
1031
+ <small>Differing Pixels</small>
1032
+ <div class="val">${d.differentPixels.toLocaleString()} <span style="font-size:.85rem;color:var(--mut);font-weight:normal">/ ${d.totalPixels.toLocaleString()}</span></div>
1033
+ </div>
1034
+ <div class="stat-box">
1035
+ <small>Canvas Viewport</small>
1036
+ <div class="val" style="font-size:1.3rem">${d.viewport}</div>
1037
+ </div>
1038
+ <div class="stat-box">
1039
+ <small>Sensitivity (Threshold)</small>
1040
+ <div class="val" style="font-size:1.3rem">${d.threshold}</div>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ ${regionsHtml}
1045
+
1046
+ <div class="view-controls-bar">
1047
+ <div class="view-tabs">
1048
+ <button class="view-tab active" data-view="side">Side-by-Side (Synced Scroll)</button>
1049
+ <button class="view-tab" data-view="split">Interactive Slider Swipe</button>
1050
+ <button class="view-tab" data-view="onion">Onion Skin (Opacity)</button>
1051
+ <button class="view-tab" data-view="diff">Difference Mask Only</button>
1052
+ </div>
1053
+
1054
+ <div class="zoom-controls">
1055
+ <span style="font-size:.8rem;color:var(--mut);margin-right:.2rem">Scale:</span>
1056
+ <button type="button" class="btn-zoom active" data-scale="fit">Fit Width</button>
1057
+ <button type="button" class="btn-zoom" data-scale="100">100% Pixels</button>
1058
+ <button type="button" class="btn-zoom" data-scale="150">150%</button>
1059
+ </div>
1060
+ </div>
1061
+
1062
+ <!-- Side by Side View -->
1063
+ <div id="view-side" class="diff-view-content">
1064
+ <div class="diff-wrapper">
1065
+ <div class="diff-panels">
1066
+ <div class="diff-panel">
1067
+ <div class="panel-title">
1068
+ <span>Reference (Baseline)</span>
1069
+ <small style="font-weight:normal">${d.referenceUrl || d.referenceName}</small>
1070
+ </div>
1071
+ <div class="panel-body-scroll" id="scroll-ref">
1072
+ <img src="${refSrc}" alt="Reference Baseline" id="img-ref">
1073
+ </div>
1074
+ </div>
1075
+
1076
+ <div class="diff-panel">
1077
+ <div class="panel-title">
1078
+ <span>Pixel Diff (Highlighted)</span>
1079
+ <small style="color:var(--bad)">Red / Yellow = Change</small>
1080
+ </div>
1081
+ <div class="panel-body-scroll" id="scroll-diff">
1082
+ <img src="${diffSrc}" alt="Pixel Difference" id="img-diff">
1083
+ </div>
1084
+ </div>
1085
+
1086
+ <div class="diff-panel">
1087
+ <div class="panel-title">
1088
+ <span>Actual (Target)</span>
1089
+ <small style="font-weight:normal">${d.actualUrl || d.actualName}</small>
1090
+ </div>
1091
+ <div class="panel-body-scroll" id="scroll-act">
1092
+ <img src="${actSrc}" alt="Actual Target" id="img-act">
1093
+ </div>
1094
+ </div>
1095
+ </div>
1096
+
1097
+ <!-- Vertical Minimap -->
1098
+ <div class="minimap-bar" id="minimap-bar" title="Click to jump along the full page height">
1099
+ <div class="minimap-viewport-indicator" id="minimap-indicator"></div>
1100
+ ${regions.map(r => `
1101
+ <div class="minimap-marker" style="top:${r.pctY}%; height:${Math.max(2, (r.height / (parseInt(d.viewport.split('x')[1])||1000))*100)}%"></div>
1102
+ `).join('')}
1103
+ </div>
1104
+ </div>
1105
+ </div>
1106
+
1107
+ <!-- Split Swipe View -->
1108
+ <div id="view-split" class="diff-view-content" style="display:none">
1109
+ <div class="split-wrap-outer">
1110
+ <div class="split-viewer" id="split-container">
1111
+ <div class="split-labels">
1112
+ <span class="split-lbl">Reference</span>
1113
+ <span class="split-lbl">Actual</span>
1114
+ </div>
1115
+ <img src="${actSrc}" alt="Actual Base" id="split-base-img">
1116
+ <div class="split-top" id="split-top-layer" style="width:50%">
1117
+ <img src="${refSrc}" alt="Reference Overlay" id="split-overlay-img">
1118
+ </div>
1119
+ <div class="split-handle" id="split-handle" style="left:50%">↔</div>
1120
+ </div>
1121
+ </div>
1122
+ </div>
1123
+
1124
+ <!-- Onion Skin View -->
1125
+ <div id="view-onion" class="diff-view-content" style="display:none">
1126
+ <div class="onion-skin-wrap">
1127
+ <div class="onion-ctrl">
1128
+ <span>Actual (0%)</span>
1129
+ <input type="range" id="onion-range" min="0" max="100" value="50" style="flex:1">
1130
+ <span>Reference (100%)</span>
1131
+ <b id="onion-pct" style="min-width:45px">50%</b>
1132
+ </div>
1133
+ <div class="onion-scroll">
1134
+ <div class="onion-canvas">
1135
+ <img src="${actSrc}" alt="Actual">
1136
+ <div class="onion-overlay" id="onion-layer" style="opacity:0.5">
1137
+ <img src="${refSrc}" alt="Reference">
1138
+ </div>
1139
+ </div>
1140
+ </div>
1141
+ </div>
1142
+ </div>
1143
+
1144
+ <!-- Difference Only -->
1145
+ <div id="view-diff" class="diff-view-content" style="display:none">
1146
+ <div class="diff-panel" style="max-width:1000px;margin:0 auto">
1147
+ <div class="panel-title">Full Difference Image (Highlighted)</div>
1148
+ <div class="panel-body-scroll" style="max-height:750px">
1149
+ <img src="${diffSrc}" alt="Difference">
1150
+ </div>
1151
+ </div>
1152
+ </div>
1153
+
1154
+ <!-- Downloads / Action Links -->
1155
+ <div class="downloads">
1156
+ <a href="${diffSrc}" download="difference.png" class="btn-outline">⬇ Download Difference Image</a>
1157
+ <a href="${refSrc}" download="reference.png" class="btn-outline">⬇ Download Reference</a>
1158
+ <a href="${actSrc}" download="actual.png" class="btn-outline">⬇ Download Actual</a>
1159
+ <button type="button" class="btn-outline" id="btn-dl-json">⬇ Download result.json</button>
1160
+ <a href="${diffSrc}" target="_blank" class="btn-outline">↗ Open Diff in New Tab</a>
1161
+ <a href="${actSrc}" target="_blank" class="btn-outline">↗ Open Actual in New Tab</a>
1162
+ <a href="${refSrc}" target="_blank" class="btn-outline">↗ Open Reference in New Tab</a>
1163
+ </div>
1164
+ `;
1165
+
1166
+ resultsContainer.style.display = 'block';
1167
+
1168
+ // Wire View Tab Switcher
1169
+ document.querySelectorAll('.view-tab').forEach(tab => {
1170
+ tab.addEventListener('click', () => {
1171
+ document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
1172
+ tab.classList.add('active');
1173
+ const v = tab.dataset.view;
1174
+ document.querySelectorAll('.diff-view-content').forEach(c => c.style.display = 'none');
1175
+ const target = document.getElementById(`view-${v}`);
1176
+ if (target) {
1177
+ target.style.display = 'block';
1178
+ if (v === 'split') syncSplitViewerSize();
1179
+ }
1180
+ });
1181
+ });
1182
+
1183
+ // Wire Synced Scroll
1184
+ setupSyncedScroll();
1185
+
1186
+ // Wire Diff Navigation Buttons & Chips
1187
+ setupDiffNavigator(regions, parseInt(d.viewport.split('x')[1]) || 1000);
1188
+
1189
+ // Wire Zoom Controls
1190
+ setupZoomControls();
1191
+
1192
+ // Wire Split Swipe Viewer
1193
+ setupSplitViewer();
1194
+
1195
+ // Wire Onion Skin Slider
1196
+ const onionRange = document.getElementById('onion-range');
1197
+ const onionLayer = document.getElementById('onion-layer');
1198
+ const onionPct = document.getElementById('onion-pct');
1199
+ onionRange.addEventListener('input', () => {
1200
+ const val = onionRange.value;
1201
+ onionLayer.style.opacity = (val / 100).toFixed(2);
1202
+ onionPct.textContent = `${val}%`;
1203
+ });
1204
+
1205
+ // Wire Full-Height Expand Toggle
1206
+ const btnToggleHeight = document.getElementById('btn-toggle-height');
1207
+ if (btnToggleHeight) {
1208
+ btnToggleHeight.addEventListener('click', () => {
1209
+ const scrollPanels = document.querySelectorAll('.panel-body-scroll');
1210
+ const isFull = scrollPanels[0].classList.toggle('full-height');
1211
+ btnToggleHeight.textContent = isFull ? '↕ Collapse Height' : '↕ Expand Full Height';
1212
+ });
1213
+ }
1214
+
1215
+ // Wire Download JSON
1216
+ document.getElementById('btn-dl-json').addEventListener('click', () => {
1217
+ const blob = new Blob([JSON.stringify(d, null, 2)], { type: 'application/json' });
1218
+ const url = URL.createObjectURL(blob);
1219
+ const a = document.createElement('a');
1220
+ a.href = url;
1221
+ a.download = 'result.json';
1222
+ a.click();
1223
+ URL.revokeObjectURL(url);
1224
+ });
1225
+ }
1226
+
1227
+ function setupSyncedScroll() {
1228
+ const p1 = document.getElementById('scroll-ref');
1229
+ const p2 = document.getElementById('scroll-diff');
1230
+ const p3 = document.getElementById('scroll-act');
1231
+ const minimap = document.getElementById('minimap-bar');
1232
+ const indicator = document.getElementById('minimap-indicator');
1233
+ const panels = [p1, p2, p3].filter(Boolean);
1234
+
1235
+ let activeScroller = null;
1236
+ let scrollTimeout = null;
1237
+
1238
+ panels.forEach(p => {
1239
+ p.addEventListener('scroll', () => {
1240
+ if (activeScroller && activeScroller !== p) return;
1241
+ activeScroller = p;
1242
+ clearTimeout(scrollTimeout);
1243
+
1244
+ const scrollTop = p.scrollTop;
1245
+ const scrollHeight = p.scrollHeight - p.clientHeight;
1246
+ const ratio = scrollHeight > 0 ? scrollTop / scrollHeight : 0;
1247
+
1248
+ panels.forEach(other => {
1249
+ if (other !== p) {
1250
+ const otherMax = other.scrollHeight - other.clientHeight;
1251
+ other.scrollTop = ratio * otherMax;
1252
+ }
1253
+ });
1254
+
1255
+ if (indicator && minimap) {
1256
+ const miniHeight = minimap.clientHeight;
1257
+ const indHeight = Math.max(12, (p.clientHeight / p.scrollHeight) * miniHeight);
1258
+ indicator.style.height = `${indHeight}px`;
1259
+ indicator.style.top = `${ratio * (miniHeight - indHeight)}px`;
1260
+ }
1261
+
1262
+ scrollTimeout = setTimeout(() => { activeScroller = null; }, 50);
1263
+ });
1264
+ });
1265
+
1266
+ // Click on minimap to scroll
1267
+ if (minimap && p2) {
1268
+ minimap.addEventListener('click', e => {
1269
+ const rect = minimap.getBoundingClientRect();
1270
+ const clickRatio = (e.clientY - rect.top) / rect.height;
1271
+ const targetScroll = clickRatio * (p2.scrollHeight - p2.clientHeight);
1272
+ p2.scrollTo({ top: targetScroll, behavior: 'smooth' });
1273
+ });
1274
+ }
1275
+ }
1276
+
1277
+ function setupDiffNavigator(regions, totalHeight) {
1278
+ if (!regions || !regions.length) return;
1279
+ const p2 = document.getElementById('scroll-diff');
1280
+ const chips = document.querySelectorAll('.diff-chip');
1281
+
1282
+ function jumpToRegion(idx) {
1283
+ if (idx < 0 || idx >= regions.length) return;
1284
+ currentDiffIndex = idx;
1285
+ const r = regions[idx];
1286
+
1287
+ chips.forEach(c => c.classList.remove('active'));
1288
+ if (chips[idx]) chips[idx].classList.add('active');
1289
+
1290
+ if (p2) {
1291
+ // Calculate pixel offset in the scrollable view
1292
+ const imgDiff = document.getElementById('img-diff');
1293
+ if (imgDiff) {
1294
+ const renderedHeight = imgDiff.clientHeight || totalHeight;
1295
+ const scale = renderedHeight / totalHeight;
1296
+ const targetY = (r.startY * scale) - (p2.clientHeight / 3);
1297
+ p2.scrollTo({ top: Math.max(0, targetY), behavior: 'smooth' });
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ chips.forEach((c, idx) => {
1303
+ c.addEventListener('click', () => jumpToRegion(idx));
1304
+ });
1305
+
1306
+ const btnPrev = document.getElementById('btn-prev-diff');
1307
+ const btnNext = document.getElementById('btn-next-diff');
1308
+ if (btnPrev) {
1309
+ btnPrev.addEventListener('click', () => {
1310
+ const nextIdx = currentDiffIndex <= 0 ? regions.length - 1 : currentDiffIndex - 1;
1311
+ jumpToRegion(nextIdx);
1312
+ });
1313
+ }
1314
+ if (btnNext) {
1315
+ btnNext.addEventListener('click', () => {
1316
+ const nextIdx = (currentDiffIndex + 1) % regions.length;
1317
+ jumpToRegion(nextIdx);
1318
+ });
1319
+ }
1320
+
1321
+ // Default jump to first diff region
1322
+ if (regions.length > 0) {
1323
+ setTimeout(() => jumpToRegion(0), 300);
1324
+ }
1325
+ }
1326
+
1327
+ function setupZoomControls() {
1328
+ const zoomBtns = document.querySelectorAll('.btn-zoom');
1329
+ const images = [
1330
+ document.getElementById('img-ref'),
1331
+ document.getElementById('img-diff'),
1332
+ document.getElementById('img-act'),
1333
+ ].filter(Boolean);
1334
+
1335
+ zoomBtns.forEach(btn => {
1336
+ btn.addEventListener('click', () => {
1337
+ zoomBtns.forEach(b => b.classList.remove('active'));
1338
+ btn.classList.add('active');
1339
+ const scale = btn.dataset.scale;
1340
+
1341
+ images.forEach(img => {
1342
+ if (scale === 'fit') {
1343
+ img.style.width = '100%';
1344
+ img.style.maxWidth = 'none';
1345
+ } else if (scale === '100') {
1346
+ img.style.width = '1441px';
1347
+ img.style.maxWidth = 'none';
1348
+ } else if (scale === '150') {
1349
+ img.style.width = '2160px';
1350
+ img.style.maxWidth = 'none';
1351
+ }
1352
+ });
1353
+ });
1354
+ });
1355
+ }
1356
+
1357
+ function syncSplitViewerSize() {
1358
+ const baseImg = document.getElementById('split-base-img');
1359
+ const overlayImg = document.getElementById('split-overlay-img');
1360
+ if (baseImg && overlayImg) {
1361
+ overlayImg.style.width = `${baseImg.clientWidth}px`;
1362
+ overlayImg.style.height = `${baseImg.clientHeight}px`;
1363
+ }
1364
+ }
1365
+
1366
+ function setupSplitViewer() {
1367
+ const container = document.getElementById('split-container');
1368
+ const topLayer = document.getElementById('split-top-layer');
1369
+ const handle = document.getElementById('split-handle');
1370
+ const baseImg = document.getElementById('split-base-img');
1371
+
1372
+ if (!container || !topLayer || !handle || !baseImg) return;
1373
+
1374
+ baseImg.onload = syncSplitViewerSize;
1375
+ window.addEventListener('resize', syncSplitViewerSize);
1376
+
1377
+ let isDragging = false;
1378
+ const updateSplit = clientX => {
1379
+ const rect = container.getBoundingClientRect();
1380
+ let x = clientX - rect.left;
1381
+ if (x < 0) x = 0;
1382
+ if (x > rect.width) x = rect.width;
1383
+ const pct = (x / rect.width) * 100;
1384
+ topLayer.style.width = `${pct}%`;
1385
+ handle.style.left = `${pct}%`;
1386
+ };
1387
+
1388
+ container.addEventListener('mousedown', e => {
1389
+ isDragging = true;
1390
+ updateSplit(e.clientX);
1391
+ });
1392
+ window.addEventListener('mousemove', e => {
1393
+ if (isDragging) updateSplit(e.clientX);
1394
+ });
1395
+ window.addEventListener('mouseup', () => { isDragging = false; });
1396
+
1397
+ container.addEventListener('touchstart', e => {
1398
+ isDragging = true;
1399
+ if (e.touches.length) updateSplit(e.touches[0].clientX);
1400
+ });
1401
+ window.addEventListener('touchmove', e => {
1402
+ if (isDragging && e.touches.length) updateSplit(e.touches[0].clientX);
1403
+ });
1404
+ window.addEventListener('touchend', () => { isDragging = false; });
1405
+ }
1406
+ </script>
1407
+ </body>
1408
+ </html>