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,1222 @@
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>UI Completeness Check</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
+ .val-high { color:#f43f5e; } .val-medium { color:#f59e0b; } .val-low { color:#6366f1; }
324
+
325
+ .warning-box {
326
+ background: color-mix(in srgb, var(--avg) 12%, transparent);
327
+ border: 1px solid color-mix(in srgb, var(--avg) 35%, transparent);
328
+ color: var(--fg);
329
+ padding: .7rem 1rem;
330
+ border-radius: 10px;
331
+ font-size: .88rem;
332
+ margin-bottom: 1.2rem;
333
+ }
334
+
335
+ /* Diff Navigator Toolbar */
336
+ .diff-nav-bar {
337
+ background: var(--card);
338
+ border: 1px solid var(--line);
339
+ border-radius: var(--r);
340
+ padding: .8rem 1.1rem;
341
+ margin-bottom: 1.2rem;
342
+ display: flex;
343
+ flex-wrap: wrap;
344
+ align-items: center;
345
+ justify-content: space-between;
346
+ gap: .8rem;
347
+ }
348
+ .diff-nav-left {
349
+ display: flex;
350
+ align-items: center;
351
+ gap: .7rem;
352
+ flex-wrap: wrap;
353
+ }
354
+ .diff-chip-group {
355
+ display: flex;
356
+ gap: .4rem;
357
+ overflow-x: auto;
358
+ max-width: 550px;
359
+ padding-bottom: .2rem;
360
+ }
361
+ .diff-chip {
362
+ border: 1px solid var(--line);
363
+ background: var(--soft);
364
+ color: var(--fg);
365
+ font-size: .78rem;
366
+ font-weight: 600;
367
+ padding: .25rem .6rem;
368
+ border-radius: 6px;
369
+ cursor: pointer;
370
+ white-space: nowrap;
371
+ transition: .15s;
372
+ }
373
+ .diff-chip:hover { border-color: var(--acc); background: color-mix(in srgb, var(--acc) 10%, transparent); }
374
+ .diff-chip.active { background: var(--acc); color: #fff; border-color: var(--acc); }
375
+
376
+ .diff-nav-btns {
377
+ display: flex;
378
+ gap: .3rem;
379
+ }
380
+ .btn-nav-step {
381
+ border: 1px solid var(--line);
382
+ background: var(--card);
383
+ color: var(--fg);
384
+ font-size: .82rem;
385
+ font-weight: 600;
386
+ padding: .3rem .7rem;
387
+ border-radius: 6px;
388
+ cursor: pointer;
389
+ transition: .15s;
390
+ }
391
+ .btn-nav-step:hover { background: var(--soft); }
392
+
393
+ .view-controls-bar {
394
+ display: flex;
395
+ justify-content: space-between;
396
+ align-items: center;
397
+ flex-wrap: wrap;
398
+ gap: .6rem;
399
+ margin-bottom: .8rem;
400
+ }
401
+ .view-tabs {
402
+ display: flex;
403
+ gap: .3rem;
404
+ background: var(--soft);
405
+ padding: .25rem;
406
+ border-radius: 8px;
407
+ }
408
+ .view-tab {
409
+ background: transparent;
410
+ border: 0;
411
+ font-size: .84rem;
412
+ font-weight: 600;
413
+ color: var(--mut);
414
+ padding: .35rem .75rem;
415
+ border-radius: 6px;
416
+ cursor: pointer;
417
+ transition: .15s;
418
+ }
419
+ .view-tab:hover { color: var(--fg); }
420
+ .view-tab.active { color: #fff; background: var(--acc); }
421
+
422
+ .zoom-controls {
423
+ display: flex;
424
+ gap: .3rem;
425
+ align-items: center;
426
+ }
427
+ .btn-zoom {
428
+ border: 1px solid var(--line);
429
+ background: var(--card);
430
+ color: var(--fg);
431
+ font-size: .78rem;
432
+ font-weight: 600;
433
+ padding: .25rem .55rem;
434
+ border-radius: 6px;
435
+ cursor: pointer;
436
+ }
437
+ .btn-zoom.active {
438
+ background: var(--fg);
439
+ color: var(--bg);
440
+ }
441
+
442
+ /* Synced Side-by-Side Panels */
443
+ .diff-wrapper {
444
+ position: relative;
445
+ display: flex;
446
+ gap: .6rem;
447
+ }
448
+ .diff-panels {
449
+ flex: 1;
450
+ display: grid;
451
+ grid-template-columns: 1fr 1fr 1fr;
452
+ gap: .8rem;
453
+ }
454
+ @media(max-width: 860px){
455
+ .diff-panels { grid-template-columns: 1fr; }
456
+ }
457
+
458
+ .diff-panel {
459
+ background: var(--card);
460
+ border: 1px solid var(--line);
461
+ border-radius: var(--r);
462
+ overflow: hidden;
463
+ display: flex;
464
+ flex-direction: column;
465
+ }
466
+ .panel-title {
467
+ padding: .5rem .8rem;
468
+ font-size: .8rem;
469
+ font-weight: 700;
470
+ color: var(--mut);
471
+ background: var(--soft);
472
+ border-bottom: 1px solid var(--line);
473
+ display: flex;
474
+ justify-content: space-between;
475
+ align-items: center;
476
+ }
477
+ .panel-body-scroll {
478
+ overflow-y: scroll;
479
+ max-height: 650px;
480
+ background: #fff;
481
+ scroll-behavior: smooth;
482
+ position: relative;
483
+ }
484
+ .panel-body-scroll.full-height {
485
+ max-height: none;
486
+ overflow-y: visible;
487
+ }
488
+ .panel-body-scroll img {
489
+ width: 100%;
490
+ height: auto;
491
+ display: block;
492
+ transform-origin: top left;
493
+ }
494
+
495
+ /* Minimap Heatstrip */
496
+ .minimap-bar {
497
+ width: 24px;
498
+ background: var(--card);
499
+ border: 1px solid var(--line);
500
+ border-radius: 8px;
501
+ position: relative;
502
+ cursor: pointer;
503
+ overflow: hidden;
504
+ }
505
+ .minimap-viewport-indicator {
506
+ position: absolute;
507
+ width: 100%;
508
+ background: rgba(91, 91, 240, 0.25);
509
+ border-top: 2px solid var(--acc);
510
+ border-bottom: 2px solid var(--acc);
511
+ pointer-events: none;
512
+ }
513
+ .minimap-marker {
514
+ position: absolute;
515
+ left: 2px;
516
+ right: 2px;
517
+ background: #ff3b30;
518
+ border-radius: 2px;
519
+ opacity: 0.85;
520
+ pointer-events: none;
521
+ }
522
+
523
+ /* Interactive Split Slider */
524
+ .split-wrap-outer {
525
+ max-height: 700px;
526
+ overflow-y: auto;
527
+ border-radius: var(--r);
528
+ border: 1px solid var(--line);
529
+ background: #000;
530
+ position: relative;
531
+ }
532
+ .split-viewer {
533
+ position: relative;
534
+ width: 100%;
535
+ overflow: hidden;
536
+ user-select: none;
537
+ -webkit-user-select: none;
538
+ }
539
+ .split-viewer img {
540
+ display: block;
541
+ width: 100%;
542
+ height: auto;
543
+ pointer-events: none;
544
+ }
545
+ .split-top {
546
+ position: absolute;
547
+ top: 0;
548
+ left: 0;
549
+ bottom: 0;
550
+ overflow: hidden;
551
+ border-right: 3px solid #fff;
552
+ box-shadow: 2px 0 10px rgba(0,0,0,.5);
553
+ }
554
+ .split-top img {
555
+ position: absolute;
556
+ top: 0;
557
+ left: 0;
558
+ height: 100%;
559
+ width: auto;
560
+ max-width: none;
561
+ }
562
+ .split-handle {
563
+ position: absolute;
564
+ top: 50%;
565
+ width: 40px;
566
+ height: 40px;
567
+ background: #fff;
568
+ border-radius: 50%;
569
+ box-shadow: 0 2px 10px rgba(0,0,0,.5);
570
+ transform: translate(-50%, -50%);
571
+ cursor: ew-resize;
572
+ display: flex;
573
+ align-items: center;
574
+ justify-content: center;
575
+ font-size: 16px;
576
+ font-weight: bold;
577
+ color: #111;
578
+ z-index: 10;
579
+ }
580
+ .split-labels {
581
+ position: sticky;
582
+ top: 12px;
583
+ display: flex;
584
+ justify-content: space-between;
585
+ pointer-events: none;
586
+ padding: 0 16px;
587
+ z-index: 20;
588
+ }
589
+ .split-lbl {
590
+ background: rgba(0,0,0,.75);
591
+ color: #fff;
592
+ font-size: .78rem;
593
+ font-weight: 600;
594
+ padding: .25rem .7rem;
595
+ border-radius: 6px;
596
+ }
597
+
598
+ /* Onion Skin */
599
+ .onion-skin-wrap {
600
+ background: var(--card);
601
+ border: 1px solid var(--line);
602
+ border-radius: var(--r);
603
+ padding: 1.2rem;
604
+ }
605
+ .onion-ctrl {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: .8rem;
609
+ margin-bottom: .8rem;
610
+ font-size: .85rem;
611
+ }
612
+ .onion-scroll {
613
+ max-height: 700px;
614
+ overflow-y: auto;
615
+ position: relative;
616
+ border-radius: 8px;
617
+ border: 1px solid var(--line);
618
+ }
619
+ .onion-canvas {
620
+ position: relative;
621
+ width: 100%;
622
+ }
623
+ .onion-canvas img {
624
+ width: 100%;
625
+ display: block;
626
+ }
627
+ .onion-overlay {
628
+ position: absolute;
629
+ inset: 0;
630
+ }
631
+
632
+ .downloads {
633
+ display: flex;
634
+ flex-wrap: wrap;
635
+ gap: .6rem;
636
+ margin-top: 1.4rem;
637
+ }
638
+ .btn-outline {
639
+ font-size: .85rem;
640
+ font-weight: 600;
641
+ padding: .45rem .9rem;
642
+ border-radius: 8px;
643
+ border: 1px solid var(--line);
644
+ background: var(--card);
645
+ color: var(--fg);
646
+ text-decoration: none;
647
+ cursor: pointer;
648
+ transition: .15s;
649
+ display: inline-flex;
650
+ align-items: center;
651
+ gap: .4rem;
652
+ }
653
+ .btn-outline:hover { background: var(--soft); }
654
+
655
+ .msg-error {
656
+ background: color-mix(in srgb, var(--bad) 12%, transparent);
657
+ border: 1px solid color-mix(in srgb, var(--bad) 35%, transparent);
658
+ color: var(--bad);
659
+ padding: .8rem 1rem;
660
+ border-radius: 10px;
661
+ font-size: .9rem;
662
+ margin-top: 1.2rem;
663
+ }
664
+
665
+ .dom-section { margin-bottom: 1.5rem; }
666
+ .dom-toolbar { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin:.8rem 0; }
667
+ .dom-toolbar input[type=text] { flex:1; min-width:180px; }
668
+ .dom-filter { border:1px solid var(--line); background:#fff; border-radius:8px; padding:.35rem .7rem; font-size:.8rem; font-weight:600; cursor:pointer; color:var(--mut); }
669
+ .dom-filter.active { background:var(--acc); border-color:var(--acc); color:#fff; }
670
+ .dom-list { max-height:560px; overflow:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
671
+ .dom-item { padding:.7rem 1rem; border-bottom:1px solid var(--line); font-size:.85rem; }
672
+ .dom-item:last-child { border-bottom:0; }
673
+ .dom-item-head { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
674
+ .dom-tag { font-family:ui-monospace,Menlo,monospace; font-weight:600; }
675
+ .dom-pill { font-size:.68rem; font-weight:700; padding:.1rem .5rem; border-radius:999px; text-transform:uppercase; }
676
+ .dom-pill.changed { background:#fef3c7; color:#92400e; }
677
+ .dom-pill.missing { background:#fee2e2; color:#991b1b; }
678
+ .dom-pill.extra { background:#dbeafe; color:#1e40af; }
679
+ .dom-text { color:var(--mut); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:420px; }
680
+ .dom-path { font-family:ui-monospace,Menlo,monospace; font-size:.72rem; color:var(--mut); margin-top:.15rem; word-break:break-all; }
681
+ .dom-diff-table { width:100%; border-collapse:collapse; margin-top:.5rem; font-size:.8rem; }
682
+ .dom-diff-table th { text-align:left; color:var(--mut); font-weight:600; padding:.2rem .5rem; }
683
+ .dom-diff-table td { padding:.2rem .5rem; font-family:ui-monospace,Menlo,monospace; }
684
+ .dom-diff-table td.ref { color:#166534; }
685
+ .dom-diff-table td.act { color:#b91c1c; }
686
+ .dom-swatch { display:inline-block; width:.8rem; height:.8rem; border-radius:3px; border:1px solid #0003; vertical-align:-1px; margin-right:.3rem; }
687
+
688
+ .sev { font-size:.68rem; font-weight:700; padding:.12rem .55rem; border-radius:999px; text-transform:uppercase; white-space:nowrap; }
689
+ .sev.high { background:#fee2e2; color:#991b1b; }
690
+ .sev.medium { background:#fef3c7; color:#92400e; }
691
+ .sev.low { background:#e0e7ff; color:#3730a3; }
692
+ .sev.info { background:#e5e7eb; color:#374151; }
693
+ .tabs { display:flex; gap:.3rem; flex-wrap:wrap; margin:1rem 0 .8rem; background:var(--soft); padding:.3rem; border-radius:10px; width:fit-content; }
694
+ .tab { border:0; background:transparent; padding:.45rem .9rem; border-radius:8px; font-weight:600; font-size:.85rem; color:var(--mut); cursor:pointer; }
695
+ .tab.active { background:var(--acc); color:#fff; }
696
+ .finding { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:.75rem 1rem; margin-bottom:.5rem; }
697
+ .finding-head { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; font-weight:600; }
698
+ .finding-detail { color:var(--mut); font-size:.86rem; margin-top:.25rem; }
699
+ .finding code { font-size:.78rem; background:var(--soft); padding:.05rem .35rem; border-radius:5px; }
700
+ .vp-title { font-size:1rem; margin:1.2rem 0 .5rem; }
701
+ .chk-row { display:flex; gap:1rem; flex-wrap:wrap; }
702
+ textarea { width:100%; font:inherit; padding:.6rem .8rem; border:1px solid var(--line); border-radius:10px; background:var(--soft); color:var(--fg); resize:vertical; min-height:84px; }
703
+ .hint { font-size:.78rem; color:var(--mut); margin-top:.3rem; }
704
+ .dtable { width:100%; border-collapse:collapse; margin-top:.5rem; font-size:.8rem; }
705
+ .dtable th { text-align:left; color:var(--mut); font-weight:600; padding:.2rem .5rem; }
706
+ .dtable td { padding:.2rem .5rem; font-family:ui-monospace,Menlo,monospace; }
707
+ .dtable td.ref { color:#166534; } .dtable td.act { color:#b91c1c; }
708
+ .empty { padding:1.2rem; color:var(--good); font-weight:600; background:var(--card); border:1px solid var(--line); border-radius:12px; }
709
+
710
+ .viewer { display:grid; gap:.8rem; max-height:70vh; overflow:auto; border:1px solid var(--line); border-radius:12px; background:var(--card); padding:.6rem; align-items:start; margin-bottom:.8rem; }
711
+ .viewer.two { grid-template-columns:1fr 1fr; }
712
+ .vcol-title { position:sticky; top:0; z-index:5; background:var(--card); font-weight:700; font-size:.8rem; padding:.3rem .2rem; color:var(--mut); }
713
+ .shot { position:relative; line-height:0; }
714
+ .shot img { width:100%; display:block; border:1px solid var(--line); }
715
+ .swatch { display:inline-block; width:12px; height:12px; border-radius:3px; border:1px solid rgba(0,0,0,.2); vertical-align:-2px; margin-right:4px; }
716
+ .hl { position:absolute; border:2px solid #f43f5e; background:rgba(244,63,94,.14); border-radius:3px; pointer-events:none; }
717
+ .hl.medium { border-color:#f59e0b; background:rgba(245,158,11,.16); }
718
+ .hl.low, .hl.info { border-color:#6366f1; background:rgba(99,102,241,.14); }
719
+ .hl-n { position:absolute; top:-2px; left:-2px; transform:translateY(-100%); background:inherit; font:700 10px/1 system-ui; color:#fff; padding:2px 5px; border-radius:4px 4px 0 0; background:#f43f5e; }
720
+ .hl.medium .hl-n { background:#f59e0b; } .hl.low .hl-n, .hl.info .hl-n { background:#6366f1; }
721
+ .num-badge { display:inline-block; min-width:1.4rem; text-align:center; font-size:.72rem; font-weight:700; color:#fff; background:var(--acc); border-radius:6px; padding:.1rem .35rem; }
722
+ .hl.flash { animation:flash 1.2s ease 2; z-index:4; }
723
+ @keyframes flash { 50% { background:rgba(244,63,94,.6); } }
724
+ .finding.clickable { cursor:pointer; }
725
+ .finding.clickable:hover { border-color:var(--acc); }
726
+ .rv-bar { display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; margin-top:.6rem; padding-top:.55rem; border-top:1px dashed var(--line); }
727
+ .rv-q { font-size:.78rem; color:var(--mut); margin-right:.2rem; }
728
+ .rv-btn { border:1px solid var(--line); background:var(--card); color:var(--fg); font:600 .76rem/1 system-ui; padding:.4rem .65rem; border-radius:7px; cursor:pointer; }
729
+ .rv-btn:hover { border-color:var(--acc); }
730
+ .rv-issue .rv-btn[data-act=issue] { background:#f43f5e; border-color:#f43f5e; color:#fff; }
731
+ .rv-accepted .rv-btn[data-act=accepted] { background:#16a34a; border-color:#16a34a; color:#fff; }
732
+ .rv-ignored .rv-btn[data-act=ignored] { background:#6b7280; border-color:#6b7280; color:#fff; }
733
+ .finding.rv-issue { border-left:4px solid #f43f5e; }
734
+ .finding.rv-accepted, .finding.rv-ignored { opacity:.6; }
735
+ .rv-hidden { display:none !important; }
736
+ .rv-summary { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; font-size:.85rem; color:var(--mut); background:var(--card); border:1px solid var(--line); border-radius:10px; padding:.6rem .9rem; margin-top:.8rem; }
737
+ .rv-summary b { color:var(--fg); }
738
+ .rv-summary label { margin-left:auto; display:flex; gap:.4rem; align-items:center; cursor:pointer; }
739
+ .rp-item { background:var(--card); border:1px solid var(--line); border-left:4px solid #f43f5e; border-radius:12px; padding:.8rem 1rem; margin-bottom:.6rem; }
740
+ .rp-item pre { background:var(--soft); border-radius:8px; padding:.5rem .7rem; font-size:.78rem; margin:.5rem 0 0; white-space:pre-wrap; }
741
+ .rp-meta { font-size:.78rem; color:var(--mut); margin-top:.3rem; }
742
+ .rp-note { min-height:46px; margin-top:.5rem; font-size:.82rem; }
743
+ .rp-actions { display:flex; gap:.5rem; margin:.4rem 0 .9rem; flex-wrap:wrap; }
744
+ .shot { overflow:hidden; }
745
+ .pd-layer { position:absolute; left:0; top:0; pointer-events:none; display:none; }
746
+ .viewer.pd-on .pd-layer { display:block; }
747
+ .pd-ctl { display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; font-size:.8rem; color:var(--mut); margin:.2rem 0 .4rem; }
748
+ .pd-ctl label { display:flex; gap:.35rem; align-items:center; cursor:pointer; color:var(--fg); font-weight:600; }
749
+ .pd-ctl input[type=range] { width:120px; }
750
+ .legend { display:flex; gap:1rem; font-size:.8rem; color:var(--mut); margin:.4rem 0; flex-wrap:wrap; }
751
+ .legend i { display:inline-block; width:.9rem; height:.9rem; border-radius:3px; margin-right:.35rem; vertical-align:-2px; }
752
+ </style>
753
+ </head>
754
+ <body>
755
+
756
+ <nav class="nav">
757
+ <a href="/" class="nav-link">Accessibility Scanner</a>
758
+ <a href="/compare" class="nav-link">Visual Regression Comparison</a>
759
+ <a href="/compare-2" class="nav-link">UI Differences</a>
760
+ <a href="/compare-3" class="nav-link active">UI Completeness Check</a>
761
+ </nav>
762
+
763
+ <h1>UI Completeness Check</h1>
764
+ <p class="subtitle">Compares visual UI structure, container widths, typography, gaps, spacing and boxes. Dynamic copy, content length, photos, class names and equivalent HTML elements are ignored. Layout and optional spelling checks inspect the target separately.</p>
765
+
766
+ <form id="uc-form">
767
+ <div class="sources-grid">
768
+ <div class="source-card">
769
+ <div class="card-header"><span class="badge badge-ref">Reference (Baseline)</span></div>
770
+ <label class="field-lbl" for="ref-url">Reference URL <span style="font-weight:400">(optional if you give a checklist)</span></label>
771
+ <input type="url" id="ref-url" placeholder="https://reference-site.example">
772
+ <div class="hint">Used to learn which components should exist. Text and images are not compared.</div>
773
+ </div>
774
+ <div class="source-card">
775
+ <div class="card-header"><span class="badge badge-act">Actual (Target)</span></div>
776
+ <label class="field-lbl" for="tgt-url">Target URL</label>
777
+ <input type="url" id="tgt-url" placeholder="http://localhost:4015/">
778
+ <div class="hint">The dynamic site being checked.</div>
779
+ </div>
780
+ </div>
781
+
782
+ <div class="options-card">
783
+ <div>
784
+ <label class="field-lbl" for="checklist">Expected buttons / sections / links (optional, one per line)</label>
785
+ <textarea id="checklist" placeholder="Book an appointment&#10;Opening hours&#10;Contact us"></textarea>
786
+ <div class="hint">Each line must appear somewhere on the target (as a button, link, heading, label or text). Use this when your reference is only a Figma frame or screenshot.</div>
787
+ </div>
788
+
789
+ <div class="options-grid" style="margin-top:1rem">
790
+ <div>
791
+ <label class="field-lbl">Check at viewports</label>
792
+ <div class="chk-row">
793
+ <label><input type="checkbox" class="vp" value="desktop1920"> Desktop 1920</label>
794
+ <label><input type="checkbox" class="vp" value="desktop" checked> Desktop 1440</label>
795
+ <label><input type="checkbox" class="vp" value="laptop1366"> Laptop 1366</label>
796
+ <label><input type="checkbox" class="vp" value="tablet1024"> Tablet 1024</label>
797
+ <label><input type="checkbox" class="vp" value="tablet"> Tablet 768</label>
798
+ <label><input type="checkbox" class="vp" value="mobile375"> Mobile 375</label>
799
+ <label><input type="checkbox" class="vp" value="mobile320"> Mobile 320</label>
800
+ </div>
801
+ </div>
802
+ <div class="checkbox-group">
803
+ <label><input type="checkbox" id="do-spelling"> Spelling check (en-US / en-GB)</label>
804
+ <label><input type="checkbox" id="do-styling" checked> Typography check for visually matched components</label>
805
+ <label><input type="checkbox" id="do-pixeldiff"> Pixel diff layer (shows where pixels differ; content changes show too, so it is only a guide)</label>
806
+ </div>
807
+ </div>
808
+
809
+ <div style="margin-top:1rem">
810
+ <label class="field-lbl" for="custom-words">Custom words (brand, pharmacy and medical terms, comma or line separated)</label>
811
+ <textarea id="custom-words" placeholder="onecare, Gateshead, GPhC, Munir"></textarea>
812
+ </div>
813
+
814
+ <div style="margin-top:1rem">
815
+ <label class="field-lbl" for="ignore">Ignore elements (CSS selectors, comma-separated)</label>
816
+ <input type="text" id="ignore" placeholder=".cookie-banner, [data-dynamic]">
817
+ </div>
818
+
819
+ <div class="action-row">
820
+ <button type="submit" class="btn-submit" id="btn-run"><span>Run UI Check</span></button>
821
+ </div>
822
+ </div>
823
+ </form>
824
+
825
+ <div id="status-msg"></div>
826
+ <div id="results" style="display:none"></div>
827
+
828
+ <script>
829
+ const form = document.getElementById('uc-form');
830
+ const statusMsg = document.getElementById('status-msg');
831
+ const resultsEl = document.getElementById('results');
832
+ const btn = document.getElementById('btn-run');
833
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
834
+ const SEV_ORDER = { high: 0, medium: 1, low: 2, info: 3 };
835
+
836
+ form.addEventListener('submit', async e => {
837
+ e.preventDefault();
838
+ const targetUrl = document.getElementById('tgt-url').value.trim();
839
+ const referenceUrl = document.getElementById('ref-url').value.trim();
840
+ const checklist = document.getElementById('checklist').value;
841
+ if (!targetUrl) return alert('Please enter the Target URL');
842
+ if (!referenceUrl && !checklist.trim()) return alert('Enter a Reference URL, an expected-items checklist, or both');
843
+ const viewports = [...document.querySelectorAll('.vp:checked')].map(x => x.value);
844
+ if (!viewports.length) return alert('Select at least one viewport');
845
+
846
+ btn.disabled = true;
847
+ resultsEl.style.display = 'none';
848
+ statusMsg.innerHTML = '<p style="color:var(--mut);font-size:.9rem;padding:1rem 0">Inspecting pages… (about 10–20 seconds per viewport)</p>';
849
+ try {
850
+ const res = await fetch('/api/ui-check', {
851
+ method: 'POST',
852
+ headers: { 'Content-Type': 'application/json' },
853
+ body: JSON.stringify({
854
+ referenceUrl, targetUrl, checklist, viewports,
855
+ customWords: document.getElementById('custom-words').value,
856
+ ignoreSelectors: document.getElementById('ignore').value,
857
+ spelling: document.getElementById('do-spelling').checked,
858
+ styling: document.getElementById('do-styling').checked,
859
+ pixelDiff: document.getElementById('do-pixeldiff').checked,
860
+ }),
861
+ });
862
+ const data = await res.json();
863
+ if (!res.ok || data.error) throw new Error(data.error || 'Check failed');
864
+ PAIR = { ref: referenceUrl, tgt: targetUrl };
865
+ try {
866
+ const rv = await fetch(`/api/reviews?ref=${encodeURIComponent(referenceUrl)}&tgt=${encodeURIComponent(targetUrl)}`);
867
+ REVIEW = rv.ok ? await rv.json() : {};
868
+ } catch {
869
+ REVIEW = {};
870
+ }
871
+ statusMsg.innerHTML = '';
872
+ render(data);
873
+ } catch (err) {
874
+ statusMsg.innerHTML = `<div class="msg-error"><b>Error:</b> ${esc(err.message)}</div>`;
875
+ } finally {
876
+ btn.disabled = false;
877
+ }
878
+ });
879
+
880
+ // ---- Review state: decisions are stored on the server per reference/target URL pair ----
881
+ let REVIEW = {};
882
+ let PAIR = { ref: '', tgt: '' };
883
+ let SHOW_REVIEWED = false;
884
+ const PD = { on: false, opacity: 70 };
885
+
886
+ // Stable id of a finding across runs: same viewport + same kind of difference + same details.
887
+ function fingerprint(vpKey, f) {
888
+ const str = `${vpKey}|${String(f.type).replace(/\s*\(\d+\)/g, '')}|${f.detail}|${(f.elements || []).join(',')}`;
889
+ let h = 0x811c9dc5;
890
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
891
+ return (h >>> 0).toString(36);
892
+ }
893
+ const statusOf = f => REVIEW[f.fp]?.status || null;
894
+
895
+ // Colour values get a swatch so the difference is visible without reading rgb numbers.
896
+ function valueHtml(v) {
897
+ const colours = String(v ?? '').match(/rgba?\([^)]*\)/g);
898
+ if (!colours) return esc(v);
899
+ return colours.map(c => `<span class="swatch" style="background:${c}"></span>${esc(c)}`).join(' + ');
900
+ }
901
+
902
+ function findingHtml(f, key, located) {
903
+ const diffs = f.diffs ? `<table class="dtable"><tr><th>Property</th><th>Reference</th><th>Target</th></tr>${
904
+ f.diffs.map(d => `<tr><td>${esc(d.prop)}</td><td class="ref">${valueHtml(d.ref)}</td><td class="act">${valueHtml(d.act)}</td></tr>`).join('')}</table>` : '';
905
+ const els = f.elements?.length ? ` <span style="color:var(--mut)">·</span> ${f.elements.map(x => `<code>${esc(x)}</code>`).join(' ')}` : '';
906
+ const txt = f.text ? `<div class="finding-detail">“${esc(f.text)}”</div>` : '';
907
+ const num = located ? `<span class="num-badge">${Number(key.split('-').pop()) + 1}</span>` : '';
908
+ const hint = located
909
+ ? '<span style="margin-left:auto;font-size:.72rem;color:var(--mut);font-weight:400">click to locate ↑ (same number on the screenshots)</span>'
910
+ : '<span style="margin-left:auto;font-size:.72rem;color:var(--mut);font-weight:400">count only · nothing to locate on the screenshots</span>';
911
+ return `<div class="finding ${located ? 'clickable' : ''}" data-k="${key}" data-fp="${f.fp}">
912
+ <div class="finding-head">${num}<span class="sev ${f.severity}">${f.severity}</span><span>${esc(f.type)}</span>${hint}</div>
913
+ <div class="finding-detail">${esc(f.detail)}${els}</div>${txt}${diffs}
914
+ <div class="rv-bar"><span class="rv-q">Does this need fixing?</span>
915
+ <button type="button" class="rv-btn" data-act="issue">Mark as issue</button>
916
+ <button type="button" class="rv-btn" data-act="accepted">Accept difference</button>
917
+ <button type="button" class="rv-btn" data-act="ignored">Ignore</button>
918
+ </div>
919
+ </div>`;
920
+ }
921
+
922
+ const hasRects = f => (f.tgtRects?.length || f.rects?.length);
923
+
924
+ function boxes(rects, page, key, sev, fp) {
925
+ return (rects || []).map(r => {
926
+ const l = r.x / page.width * 100, t = r.y / page.height * 100;
927
+ const w = Math.max(r.width, 4) / page.width * 100, hh = Math.max(r.height, 4) / page.height * 100;
928
+ const n = Number(key.split('-').pop()) + 1;
929
+ return `<div class="hl ${sev}" data-k="${key}" data-fp="${fp}" style="left:${l}%;top:${t}%;width:${w}%;height:${hh}%"><span class="hl-n">${n}</span></div>`;
930
+ }).join('');
931
+ }
932
+
933
+ // Which side of the screenshots a finding's rects belong to
934
+ function sideRects(f) {
935
+ return {
936
+ ref: f.refRects || [],
937
+ // layout and spelling findings only have target-side rects
938
+ tgt: f.tgtRects || f.rects || [],
939
+ };
940
+ }
941
+
942
+ function viewerHtml(r, items, prefix, stamp) {
943
+ const located = items.map((f, i) => ({ f, i })).filter(x => hasRects(x.f));
944
+ if (!r.target && !r.reference) return '';
945
+ const showRef = Boolean(r.reference);
946
+ const showTgt = Boolean(r.target);
947
+ const pd = r.pixelDiff;
948
+ // Pixel diff layer sits on the target screenshot, scaled to the diff image's own size.
949
+ const layer = shot => pd ? `<img class="pd-layer" src="/${pd.image}?t=${stamp}" alt="" style="width:${pd.width / shot.width * 100}%;opacity:${PD.opacity / 100}">` : '';
950
+ const col = (title, shot, side) => `<div><div class="vcol-title">${title}</div><div class="shot"><img src="/${shot.image}?t=${stamp}" alt="${title}">${
951
+ side === 'tgt' ? layer(shot) : ''}${
952
+ located.map(({ f, i }) => boxes(sideRects(f)[side], shot, `${prefix}-${i}`, f.severity, f.fp)).join('')}</div></div>`;
953
+ const cols = [];
954
+ if (showRef) cols.push(col('Reference (Baseline)', r.reference, 'ref'));
955
+ if (showTgt) cols.push(col('Actual (Target)', r.target, 'tgt'));
956
+ if (!cols.length) return '';
957
+ const pdCtl = pd && r.target ? `<div class="pd-ctl">
958
+ <label><input type="checkbox" class="pd-toggle" ${PD.on ? 'checked' : ''}> Pixel diff layer on Actual</label>
959
+ <span>Opacity <input type="range" class="pd-opacity" min="10" max="100" value="${PD.opacity}"></span>
960
+ <span>${pd.percent}% of pixels differ · content changes show too, use it only to spot areas worth a look</span>
961
+ </div>` : '';
962
+ return `<div class="legend"><span><i style="background:rgba(244,63,94,.5)"></i>High</span><span><i style="background:rgba(245,158,11,.55)"></i>Medium</span><span><i style="background:rgba(99,102,241,.5)"></i>Low</span></div>
963
+ ${pdCtl}<div class="viewer ${cols.length === 2 ? 'two' : ''} ${pd && PD.on ? 'pd-on' : ''}" data-viewer="${prefix}">${cols.join('')}</div>`;
964
+ }
965
+
966
+ function listHtml(items, prefix) {
967
+ if (!items.length) return '<div class="empty">✓ No issues found</div>';
968
+ return items.map((f, i) => ({ f, i })).sort((a, b) => SEV_ORDER[a.f.severity] - SEV_ORDER[b.f.severity])
969
+ .map(({ f, i }) => findingHtml(f, `${prefix}-${i}`, hasRects(f))).join('');
970
+ }
971
+
972
+ // Suggested CSS for the report: the reference value is what the target should use.
973
+ const CSS_PROP = {
974
+ fontFamily: 'font-family', fontSize: 'font-size', fontWeight: 'font-weight', lineHeight: 'line-height',
975
+ letterSpacing: 'letter-spacing', wordSpacing: 'word-spacing', color: 'color', backgroundColor: 'background-color',
976
+ borderRadius: 'border-radius', accentColor: 'color /* highlighted words */', iconWidth: 'width', iconHeight: 'height', iconColor: 'color', iconFill: 'fill',
977
+ 'row gap': 'row-gap', 'column gap': 'column-gap', borderTopWidth: 'border-top-width',
978
+ };
979
+ function suggestedCss(f) {
980
+ if (!f.diffs?.length) return '';
981
+ return f.diffs.map(d => {
982
+ if (d.prop === 'container width') return `width: ${d.ref}; /* share of the section width */`;
983
+ const prop = CSS_PROP[d.prop] || d.prop;
984
+ const val = d.prop === 'fontFamily' && /\s/.test(d.ref) ? `"${d.ref}"` : d.ref;
985
+ return `${prop}: ${val};`;
986
+ }).join('\n');
987
+ }
988
+ function locationText(f) {
989
+ const r = (f.tgtRects || f.rects || [])[0];
990
+ return r ? `x ${r.x}, y ${r.y} · ${r.width}×${r.height}px on the target page` : 'not located on the page';
991
+ }
992
+
993
+ // Attach fingerprints and viewport to every finding once, and build the spelling items.
994
+ function prepare(d) {
995
+ for (const r of d.results) {
996
+ for (const k of ['completeness', 'layout', 'styling']) {
997
+ for (const f of r[k]) { f.vp = r.viewport; f.fp = fingerprint(r.viewport.key, f); }
998
+ }
999
+ }
1000
+ const sr = d.results.find(x => x.viewport.key === d.spellingViewport) || d.results[0];
1001
+ d.spellItems = (d.spelling || []).map(w => {
1002
+ const f = {
1003
+ severity: 'medium',
1004
+ type: `${w.word} → ${w.suggestions.length ? w.suggestions[0] : 'no suggestion'}`,
1005
+ detail: `Wrong: “${w.word}”. ${w.suggestions.length ? 'Should be:' : 'No suggestion found; add it to Custom words if it is correct.'}`,
1006
+ elements: w.suggestions,
1007
+ text: w.context,
1008
+ rects: w.rects,
1009
+ vp: sr?.viewport,
1010
+ };
1011
+ f.fp = fingerprint('spelling', { type: w.word, detail: '' });
1012
+ return f;
1013
+ });
1014
+ d.allFindings = [...d.results.flatMap(r => [...r.completeness, ...r.layout, ...r.styling]), ...d.spellItems];
1015
+ }
1016
+
1017
+ async function saveReview(f, patch) {
1018
+ const res = await fetch('/api/reviews', {
1019
+ method: 'POST',
1020
+ headers: { 'Content-Type': 'application/json' },
1021
+ body: JSON.stringify({ referenceUrl: PAIR.ref, targetUrl: PAIR.tgt, fingerprint: f.fp, type: f.type, ...patch }),
1022
+ });
1023
+ const data = await res.json();
1024
+ if (!res.ok || data.error) throw new Error(data.error || 'Could not save the review');
1025
+ REVIEW = data;
1026
+ }
1027
+
1028
+ function render(d) {
1029
+ prepare(d);
1030
+ const s = d.summary;
1031
+ const stamp = Date.now();
1032
+ const byFp = new Map(d.allFindings.map(f => [f.fp, f]));
1033
+ const tab = (k, label, n) => `<button type="button" class="tab" data-tab="${k}">${label}${n ? ` (<span data-count="${k}">${n}</span>)` : ''}</button>`;
1034
+ const sum = (arr, k) => arr.reduce((n, r) => n + r[k].length, 0);
1035
+
1036
+ const vpPanel = key => d.results.map((r, vi) => {
1037
+ const prefix = `${key}${vi}`;
1038
+ return `<h3 class="vp-title">${esc(r.viewport.name)} · ${r.viewport.width}px</h3>${viewerHtml(r, r[key], prefix, stamp)}${listHtml(r[key], prefix)}`;
1039
+ }).join('');
1040
+
1041
+ // Spelling: boxes on the target screenshot of the viewport the text was read from
1042
+ function spellPanel() {
1043
+ if (!d.spellingRan) return '<div class="empty" style="color:var(--mut)">Spelling check was turned off.</div>';
1044
+ if (!d.spellItems.length) return '<div class="empty">✓ No spelling issues found</div>';
1045
+ const r = d.results.find(x => x.viewport.key === d.spellingViewport) || d.results[0];
1046
+ return `${viewerHtml({ target: r.target, reference: r.reference }, d.spellItems, 'sp', stamp)}${listHtml(d.spellItems, 'sp')}`;
1047
+ }
1048
+
1049
+ const confirmed = () => d.allFindings.filter(f => statusOf(f) === 'issue')
1050
+ .sort((a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity]);
1051
+
1052
+ function reportMarkdown() {
1053
+ const list = confirmed();
1054
+ const lines = [
1055
+ `# UI issues report`, '',
1056
+ `- Reference (baseline): ${PAIR.ref || '(checklist only)'}`,
1057
+ `- Target: ${PAIR.tgt}`,
1058
+ `- Run: ${d.runId || ''} · ${new Date(d.timestamp).toLocaleString()}`,
1059
+ `- Confirmed issues: ${list.length}`, '',
1060
+ ];
1061
+ list.forEach((f, i) => {
1062
+ lines.push(`## ${i + 1}. [${f.severity.toUpperCase()}] ${f.type}`, '');
1063
+ lines.push(`- Viewport: ${f.vp ? `${f.vp.name} (${f.vp.width}px)` : '-'}`);
1064
+ lines.push(`- Location: ${locationText(f)}`);
1065
+ lines.push(`- Details: ${f.detail}${f.elements?.length ? ` (${f.elements.join(', ')})` : ''}`);
1066
+ if (f.diffs?.length) {
1067
+ lines.push('', '| Property | Expected (reference) | Actual (target) |', '|---|---|---|');
1068
+ f.diffs.forEach(x => lines.push(`| ${x.prop} | ${x.ref} | ${x.act} |`));
1069
+ lines.push('', 'Suggested CSS:', '```css', suggestedCss(f), '```');
1070
+ }
1071
+ const note = REVIEW[f.fp]?.note;
1072
+ if (note) lines.push('', `Note: ${note}`);
1073
+ lines.push('');
1074
+ });
1075
+ return lines.join('\n');
1076
+ }
1077
+
1078
+ function reportPanel() {
1079
+ const list = confirmed();
1080
+ if (!list.length) {
1081
+ return '<div class="empty" style="color:var(--mut)">No confirmed issues yet. Use <b>Mark as issue</b> on findings in the other tabs; only those appear in this report.</div>';
1082
+ }
1083
+ return `<div class="rp-actions">
1084
+ <button type="button" class="rv-btn" id="rp-download">Download report (.md)</button>
1085
+ <button type="button" class="rv-btn" id="rp-copy">Copy as Markdown</button>
1086
+ </div>
1087
+ ${list.map((f, i) => `<div class="rp-item" data-fp="${f.fp}">
1088
+ <div class="finding-head"><span class="num-badge">${i + 1}</span><span class="sev ${f.severity}">${f.severity}</span><span>${esc(f.type)}</span></div>
1089
+ <div class="rp-meta">${f.vp ? `${esc(f.vp.name)} · ${f.vp.width}px` : ''} · ${esc(locationText(f))}</div>
1090
+ <div class="finding-detail">${esc(f.detail)}${f.elements?.length ? ` · ${f.elements.map(x => `<code>${esc(x)}</code>`).join(' ')}` : ''}</div>
1091
+ ${f.diffs?.length ? `<table class="dtable"><tr><th>Property</th><th>Expected (reference)</th><th>Actual (target)</th></tr>${
1092
+ f.diffs.map(x => `<tr><td>${esc(x.prop)}</td><td class="ref">${valueHtml(x.ref)}</td><td class="act">${valueHtml(x.act)}</td></tr>`).join('')}</table>
1093
+ <pre>${esc(suggestedCss(f))}</pre>` : ''}
1094
+ <textarea class="rp-note" placeholder="Note for the developer (optional)">${esc(REVIEW[f.fp]?.note || '')}</textarea>
1095
+ </div>`).join('')}`;
1096
+ }
1097
+
1098
+ const panels = {
1099
+ completeness: () => vpPanel('completeness'),
1100
+ layout: () => vpPanel('layout'),
1101
+ styling: () => vpPanel('styling'),
1102
+ spelling: spellPanel,
1103
+ report: reportPanel,
1104
+ };
1105
+
1106
+ resultsEl.innerHTML = `
1107
+ <h2>UI Check Results</h2>
1108
+ <div class="stat-grid">
1109
+ <div class="stat-box"><small>High</small><div class="val val-high">${s.high}</div></div>
1110
+ <div class="stat-box"><small>Medium</small><div class="val val-medium">${s.medium}</div></div>
1111
+ <div class="stat-box"><small>Low / Info</small><div class="val val-low">${s.low + s.info}</div></div>
1112
+ <div class="stat-box"><small>Misspelled words</small><div class="val val-medium">${s.misspellings}</div></div>
1113
+ </div>
1114
+ <div class="rv-summary" id="rv-summary"></div>
1115
+ <div class="tabs">
1116
+ ${tab('completeness', 'Layout &amp; Structure', sum(d.results, 'completeness'))}
1117
+ ${tab('layout', 'Layout Issues', sum(d.results, 'layout'))}
1118
+ ${tab('styling', 'Typography &amp; Styling', sum(d.results, 'styling'))}
1119
+ ${tab('spelling', 'Spelling', d.spellItems.length)}
1120
+ <button type="button" class="tab" data-tab="report">Final report (<span id="rp-count">0</span>)</button>
1121
+ </div>
1122
+ <div id="panel"></div>`;
1123
+ resultsEl.style.display = 'block';
1124
+
1125
+ const panel = resultsEl.querySelector('#panel');
1126
+ const tabs = resultsEl.querySelectorAll('.tab');
1127
+ let current = 'completeness';
1128
+
1129
+ // Reflect review decisions on rows, screenshot boxes and counters without re-rendering the screenshots.
1130
+ function applyReview() {
1131
+ const counts = { pending: 0, issue: 0, accepted: 0, ignored: 0 };
1132
+ for (const f of d.allFindings) counts[statusOf(f) || 'pending']++;
1133
+ resultsEl.querySelector('#rv-summary').innerHTML = `
1134
+ <span>To review: <b>${counts.pending}</b></span>
1135
+ <span>Issues: <b style="color:#f43f5e">${counts.issue}</b></span>
1136
+ <span>Accepted: <b style="color:#16a34a">${counts.accepted}</b></span>
1137
+ <span>Ignored: <b>${counts.ignored}</b></span>
1138
+ <label><input type="checkbox" id="rv-show" ${SHOW_REVIEWED ? 'checked' : ''}> Show accepted / ignored</label>`;
1139
+ resultsEl.querySelector('#rp-count').textContent = counts.issue;
1140
+ panel.querySelectorAll('.finding[data-fp], .hl[data-fp]').forEach(el => {
1141
+ const st = REVIEW[el.dataset.fp]?.status || null;
1142
+ el.classList.remove('rv-issue', 'rv-accepted', 'rv-ignored');
1143
+ if (st) el.classList.add(`rv-${st}`);
1144
+ el.classList.toggle('rv-hidden', !SHOW_REVIEWED && (st === 'accepted' || st === 'ignored'));
1145
+ });
1146
+ }
1147
+
1148
+ function show(k) {
1149
+ current = k;
1150
+ tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === k));
1151
+ panel.innerHTML = panels[k]();
1152
+ applyReview();
1153
+ }
1154
+ tabs.forEach(t => t.addEventListener('click', () => show(t.dataset.tab)));
1155
+
1156
+ resultsEl.addEventListener('change', ev => {
1157
+ if (ev.target.id === 'rv-show') { SHOW_REVIEWED = ev.target.checked; applyReview(); }
1158
+ if (ev.target.classList.contains('pd-toggle')) {
1159
+ PD.on = ev.target.checked;
1160
+ panel.querySelectorAll('.pd-toggle').forEach(t => { t.checked = PD.on; });
1161
+ panel.querySelectorAll('.viewer').forEach(v => v.classList.toggle('pd-on', PD.on && Boolean(v.querySelector('.pd-layer'))));
1162
+ }
1163
+ });
1164
+ resultsEl.addEventListener('input', ev => {
1165
+ if (!ev.target.classList.contains('pd-opacity')) return;
1166
+ PD.opacity = Number(ev.target.value);
1167
+ panel.querySelectorAll('.pd-opacity').forEach(t => { t.value = PD.opacity; });
1168
+ panel.querySelectorAll('.pd-layer').forEach(l => { l.style.opacity = PD.opacity / 100; });
1169
+ });
1170
+
1171
+ // Notes on confirmed issues are saved when the field loses focus.
1172
+ panel.addEventListener('focusout', async ev => {
1173
+ if (!ev.target.classList.contains('rp-note')) return;
1174
+ const f = byFp.get(ev.target.closest('[data-fp]').dataset.fp);
1175
+ if (!f || (REVIEW[f.fp]?.note || '') === ev.target.value) return;
1176
+ try { await saveReview(f, { note: ev.target.value }); } catch (err) { alert(err.message); }
1177
+ });
1178
+
1179
+ panel.addEventListener('click', async ev => {
1180
+ // Review buttons: clicking the active decision again clears it.
1181
+ const rb = ev.target.closest('.rv-btn[data-act]');
1182
+ if (rb) {
1183
+ ev.stopPropagation();
1184
+ const f = byFp.get(rb.closest('[data-fp]').dataset.fp);
1185
+ if (!f) return;
1186
+ const next = statusOf(f) === rb.dataset.act ? null : rb.dataset.act;
1187
+ try { await saveReview(f, { status: next }); } catch (err) { return alert(err.message); }
1188
+ applyReview();
1189
+ return;
1190
+ }
1191
+ if (ev.target.id === 'rp-download') {
1192
+ const blob = new Blob([reportMarkdown()], { type: 'text/markdown' });
1193
+ const a = document.createElement('a');
1194
+ a.href = URL.createObjectURL(blob);
1195
+ a.download = `ui-issues-${d.runId || 'report'}.md`;
1196
+ a.click();
1197
+ URL.revokeObjectURL(a.href);
1198
+ return;
1199
+ }
1200
+ if (ev.target.id === 'rp-copy') {
1201
+ try { await navigator.clipboard.writeText(reportMarkdown()); ev.target.textContent = 'Copied ✓'; }
1202
+ catch { alert('Could not copy; use Download instead.'); }
1203
+ return;
1204
+ }
1205
+ // Click a finding -> scroll its viewer to the highlighted box and flash it
1206
+ const row = ev.target.closest('.finding.clickable');
1207
+ if (!row) return;
1208
+ const k = row.dataset.k;
1209
+ const prefix = k.slice(0, k.lastIndexOf('-'));
1210
+ const viewer = panel.querySelector(`.viewer[data-viewer="${prefix}"]`);
1211
+ if (!viewer) return;
1212
+ const hls = [...viewer.querySelectorAll('.hl')].filter(b => b.dataset.k === k);
1213
+ if (!hls.length) return;
1214
+ viewer.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1215
+ viewer.scrollTo({ top: Math.max(0, hls[0].offsetTop - 120), behavior: 'smooth' });
1216
+ hls.forEach(b => { b.classList.remove('flash'); void b.offsetWidth; b.classList.add('flash'); });
1217
+ });
1218
+ show(current);
1219
+ }
1220
+ </script>
1221
+ </body>
1222
+ </html>